Compare commits
No files matched your search
+42
-2
@@ -25,9 +25,9 @@
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-17c7f81e0ae6/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8087 -t public bin/dev-router.php"
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-1d19ca1bb9ca/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8087
|
||||
"port": 8091
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
@@ -48,6 +48,46 @@
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/tagespflege-moldovan.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/tagespflege-moldovan-media'; $env:SITE_ORIGIN='http://127.0.0.1:8089'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8089 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8089
|
||||
},
|
||||
{
|
||||
"name": "kantit",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantit.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; $env:SITE_ORIGIN='http://127.0.0.1:8130'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8130 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8130
|
||||
},
|
||||
{
|
||||
"name": "maler-drews",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/maler-drews.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/maler-drews-media'; $env:SITE_ORIGIN='http://127.0.0.1:8093'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8093 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8093
|
||||
},
|
||||
{
|
||||
"name": "immobilien-deeg",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8094'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8094 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8094
|
||||
},
|
||||
{
|
||||
"name": "immobilien-deeg-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8095'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8095 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8095
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,8 @@ APP_DEBUG=0
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||
# Für MySQL stattdessen, mit den Zugangsdaten des Hostings:
|
||||
# DATABASE_URL="mysql://benutzer:passwort@127.0.0.1:3306/datenbank?charset=utf8mb4"
|
||||
# Die oeffentliche Adresse der Website, mit oder ohne www - so, wie sie in Sitemap, Feed und
|
||||
# Canonical stehen soll. Die andere Schreibweise leitet die Anwendung dorthin um.
|
||||
SITE_ORIGIN=https://example.de
|
||||
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||
MAILER_DSN=sendmail://default
|
||||
|
||||
@@ -83,3 +83,8 @@ jobs:
|
||||
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console cms:seed-demo
|
||||
# Vorher hoerte der Job hier auf und sagte nichts ueber Sicherung und Betrieb auf MySQL.
|
||||
- run: php bin/console cms:health
|
||||
- run: php bin/console cms:backup --keep=2
|
||||
- run: php bin/console cms:backup --list
|
||||
- run: php vendor/bin/phpunit
|
||||
@@ -1,9 +1,75 @@
|
||||
# KantSites
|
||||
|
||||
Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite lokal, MySQL/MariaDB als Hostingziel. Drei gespeicherte Beispielseiten werden ohne JavaScript als HTML ausgeliefert. Anmeldung, geschützte Bearbeitung und getrennte Veröffentlichung sind implementiert; weitere Layoutwerkzeuge folgen.
|
||||
Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Bedienprinzip des Flatsome UX Builders. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite oder MySQL/MariaDB. Öffentliche Seiten kommen als fertiges HTML ohne Pflicht-JavaScript, mit Sitemap, Feed, strukturierten Daten und `llms.txt`. Webinstaller, Releasearchive mit Update aus der Verwaltung, Sicherung, Aufgabenplan, Formulare, Statistik und Einwilligung sind eingebaut. Fünf Websites laufen lokal als eigene Installationen (Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Malerservice Drews, DEEG Immobilien), `web.kant-it.de` und das Drews-Hosting mit Releasearchiven.
|
||||
|
||||
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Adresse mit oder ohne www, Rollen wie versprochen, Gesamtprüfung (ab 1.17.0)
|
||||
|
||||
**Eine Adresse.** `SITE_ORIGIN` in `.env` entscheidet, ob die Website mit oder ohne `www` heißt; die andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um (auch die Verwaltung), ein fremder Host bleibt erreichbar. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt die Einstellung, der Bildschirm „System“ zeigt sie und warnt, wenn die Verwaltung gerade unter einer anderen Adresse läuft. Der www-Block in `public/.htaccess` ist weg.
|
||||
|
||||
**Rollen.** Redakteure bearbeiten Entwürfe, laden Medien hoch und sehen die Statistik. Veröffentlicher dürfen zusätzlich veröffentlichen, Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite pflegen. Administratoren allein ändern Design, Unternehmensdaten (samt Einwilligung und Spamschutz), Benutzer, System und eigene Skripte. Vorher genügte für Design, Einstellungen, Menüs, Bereiche und Formulare die Rolle Redakteur – entgegen der Rollenhilfe. Die Navigation zeigt nur, was die Rolle öffnen darf.
|
||||
|
||||
**Behoben.** Nach `cms:admin:path` war keine Anmeldung möglich (Sitzungscookie blieb auf `/admin`). Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete. Ein MySQL-Passwort mit Sonderzeichen ließ den Container abbrechen (`resolve:`), ebenso ein SMTP-Passwort. Die Einwilligung nahm Entscheidungen von fremden Websites an und leitete auf `/\fremd.example` weiter. `</script>` in Titel oder FAQ zerriss die strukturierten Daten. Eigenes CSS konnte mit einem Komma den Block verlassen. Eine alte Fassung konnte ein Skript an der Administratorregel vorbei zurückholen. Im Editor ließen sich Menü, Beitragsliste und Slider in älteren Dokumenten nicht speichern (Schemafassung), „Gestaltung einfügen“ und „Als Vorlage speichern“ waren fälschlich aktiv, „Ausrücken“ und Baum-Drop umgingen die Elternregeln, ein Bild auf ein Akkordeon warf einen Fehler, abgelehnte Einfügungen hinterließen leere Verlaufsschritte. Dazu: Schatten bei Überfahren/Scrollen, Tabs und Bildbetrachter in Kopf, Fuß und Sektionen, Sitemap-`lastmod`, Feed-Zeitzone, Vollbild in eingebetteten Videos, Update-Downloads nur über HTTPS-Weiterleitungen, Composer- und PHP-Aufruf aus dem Web-Update unter php-fpm, unerwartete Fehler im Updater nehmen den Rückweg, eine geplante Veröffentlichung blockiert die anderen nicht mehr, keine Unterseite unter einer Seite im Papierkorb.
|
||||
|
||||
## Spaltenbreiten einer Zeile durch Ziehen (ab 1.16.0)
|
||||
|
||||
Wähle eine Zeile oder eine ihrer Spalten: auf jeder Grenze zwischen zwei Spalten liegt ein Griff. Ziehen macht die eine Spalte breiter und die andere schmaler, Loslassen ist ein Schritt für „Rückgängig“; die Pfeiltasten verschieben um fünf Prozentpunkte. Der Wert steht auch im Design-Bedienfeld der Zeile unter **Layout → Spaltenbreiten**, etwa `2 1` oder `25 50 25`, und gilt je Gerät. Wo die Spalten gestapelt sind, etwa auf dem Handy, gibt es keinen Griff und die Spalten bleiben untereinander.
|
||||
|
||||
## Rückstände der Gesamtprüfung (unveröffentlicht)
|
||||
|
||||
Archivseiten (`/beitraege`, Kategorien, Schlagwörter) sind jetzt gewöhnliche Seiten mit Kopf, Fuß, Sektionen, eigenem CSS und Nach-oben-Knopf; blätternde Beitragslisten tragen Canonical mit Seitenzahl und `rel=prev/next`. Formulare, Beitragslisten und Brotkrumen funktionieren auch in Kopf, Fuß und verknüpften Sektionen, eigene Skripte von Bereichen laufen. Eine zweimal verknüpfte Sektion bekommt beim zweiten Mal eigene Kennungen. Tablet und Telefon trennen sich überall bei 767 px. Dunkle Paletten färben Formularfelder, Fehlermeldungen, Skip-Link und Aufruf-Buttons mit; es gibt Druckstile. **Betrieb:** Sicherung, Wiederherstellung, Archiv-Update und Rückweg laufen auch auf MySQL/MariaDB (`mysqldump` und `mysql` müssen aufrufbar sein), Updates sperren sich gegenseitig (`var/update.lock`), das Archiv wird direkt in eine Datei geladen, `public/.htaccess` behält beim Archiv-Update eingeschaltete HTTPS- und HSTS-Zeilen, und nach dem Update werden `var/update/` (bis auf zwei) und alte Caches aufgeräumt. Der Editor zeigt in der Vorschau eigenes CSS, Bereichsstile und Formulare; frisch eingefügte Kontaktdaten, Formulare, verknüpfte Sektionen und externe Inhalte sind gültig, bis man sie füllt.
|
||||
|
||||
## Plugins (unveröffentlicht)
|
||||
|
||||
Erweiterungen liegen als Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional Stylesheet und Skript unter `assets/`. Unter **Plugins** (Administrator) werden sie ein- und ausgeschaltet; ihre Bausteine erscheinen dann im Editor unter „Erweiterungen“, mit einem Eigenschaftenpanel aus dem Schema des Manifests. Der Kern prüft die Eigenschaften beim Speichern und beim Rendern, liefert Stylesheet und Skript nur auf Seiten mit dem Baustein aus und lässt die Bausteine eines ausgeschalteten Plugins unangetastet in den Seiten stehen. Ein Plugin kann außerdem Container-Bausteine (mit Liste erlaubter Kinder), Einstellungen für den Betreiber (Formular auf dem Bildschirm „Plugins“), eigene Adressen wie `/vergleich/daten` (Twig-Vorlage, Antwort als html, json, text oder xml), und Seitenvorlagen für den Editor mitbringen. Farben, Schriften und Buttons fasst ein Plugin nicht an – die ändert man unter **Design** (ADR 105). PHP-Code bringt ein Plugin nicht mit – deshalb gibt es keine Hooks und keine eigenen Tabellen (ADR 103). Mitgeliefert ist **Bildvergleich** (`plugins/vergleich`: Bildvergleich, Vergleichsgalerie, Vorlage, Adresse, Einstellungen). Wie man ein Plugin schreibt, steht in `docs/PLUGINS.md`. Migrationen `20260915000100` und `20260916000100` legen die Tabelle `plugin` mit Einstellungen an.
|
||||
|
||||
## Formulare in Schritten, mit Bedingungen und Zusammenfassung (unveröffentlicht)
|
||||
|
||||
Unter **Formulare** gibt es drei neue Feldtypen – **Zahl** (mit Unter-, Obergrenze und Einheit wie m²), **Auswahl (Optionsfelder)** und **Mehrfachauswahl** – und den Typ **Neuer Schritt**: seine Beschriftung ist der Titel des Abschnitts, sein Hilfetext die Einleitung, alle Felder bis zum nächsten Schritt gehören dazu. Jedes Feld und jeder Schritt kann von einer früheren Auswahl abhängen („Nur zeigen, wenn das Feld `immobilienart` einen dieser Werte hat: Wohnung, Haus“); der Server verlangt und speichert es dann nur, wenn die Bedingung zutrifft. Bei Optionsfeldern und Mehrfachauswahl lässt sich je Option ein **Bild aus der Mediathek** wählen; sobald eine Option eines hat, zeigt die Website die Auswahl als anklickbare Bildkarten statt als Kästchen. Unter Zustellung schaltet „Zusammenfassung vor dem Absenden zeigen“ einen letzten Schritt mit allen Angaben und „Ändern“ je Schritt ein. Auf der Website zeigt ein kleines Skript einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“; ohne JavaScript steht das Formular als Ganzes mit Abschnitten auf der Seite. Das Skript kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat. Beispiel: der Preisfinder der DEEG-Website (`scripts/seed-immobilien-deeg.php`, Formular `bewertung`).
|
||||
|
||||
## Eigene Absenderadresse je Formular (ab 1.15.1)
|
||||
|
||||
Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absender**. Bisher ging jede Nachricht von der Empfängeradresse an sich selbst; das scheitert, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist, weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt. Leer heißt weiterhin: Absender ist der Empfänger. Die Antwortadresse aus dem Formularfeld bleibt davon unberührt.
|
||||
|
||||
## Formulare, Statistik, Design und Seitenleiste überarbeitet (ab 1.15.0)
|
||||
|
||||
**Formulare** sind ein Editor: jedes Feld eine Karte mit Nummer, Beschriftung, Typ, technischem Namen, Hilfetext und Pflicht-Schalter, dazu „Feld hinzufügen“ und ein Entfernen-Knopf je Karte. Der technische Name folgt der Beschriftung, bis du ihn selbst schreibst. Unter **Aussehen** bekommt jedes Formular eine Buttonbeschriftung sowie Rahmen- und Hintergrundfarbe der Felder, Button- und Buttonschriftfarbe – aus der Palette oder als eigener Wert, leer heißt „wie die Website“. Die Vorschau daneben folgt jeder Eingabe, zeichnet den Absendeknopf mit dem Standardbutton des Designs und nennt den Kontrast der Buttonschrift.
|
||||
|
||||
**Die Statistik** beginnt mit Kennzahlen: Aufrufe mit Vergleich zum gleich langen Zeitraum davor, Schnitt je Tag, stärkster Tag, aufgerufene Seiten, Aufrufe über Verweise, nicht gefundene Adressen. Der Verlauf hat Rasterlinien, jeder Balken nennt beim Überfahren Tag und Zahl, der stärkste Tag ist hervorgehoben; neu ist ein Wochentagsdiagramm im Schnitt. Die Ranglisten tragen Rang und Anteilsbalken.
|
||||
|
||||
**Im Design** ist die Palette eine Reihe von Farbkarten, deren Fläche der Farbwähler selbst ist. Sieben fertige Paletten (Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht) lassen sich per Klick übernehmen; eigene Farben bleiben stehen. Text-, Seiten- und Linkfarbe wählst du aus der Palette oder trägst einen eigenen Wert ein. Jede Schrift zeigt eine Probe in sich selbst, die Schnitte sind Chips, und eine Typografie-Vorschau zeigt Schrift, Größe, Zeilenhöhe, Skala, Farben und Unterstreichung live.
|
||||
|
||||
**Die Seitenleiste** endet in einer Kontokarte mit Avatar, Name, Rolle und Abmeldesymbol, darunter die Fassung mit dem Update-Hinweis. Auf Tablets bleiben Konto und Abmelden rechts im Bild, nur die Liste wischt.
|
||||
|
||||
## Fußzeile des Einwilligungshinweises (ab 1.14.2)
|
||||
|
||||
„Einzeln wählen“ steht links, Datenschutz und Impressum rechts auf einer Linie; aufgeklappt nimmt die Einzelwahl die volle Breite, die Links rutschen rechtsbündig darunter.
|
||||
|
||||
## Einwilligungshinweis gestalten, Nach-oben-Knopf auch bei offener Frage (ab 1.14.1)
|
||||
|
||||
**Der Einwilligungshinweis lässt sich gestalten**, unter **Einstellungen → Einwilligung → Aussehen des Hinweises**: Anordnung (Karte unten in der Mitte, Leiste über die volle Breite, kleine Karte rechts unten), Form (abgerundet, rund, eckig – gilt für Fläche und Knöpfe zusammen), eigene Überschrift und vier Farben (Fläche, Schrift, Knopf „Alle akzeptieren“, Schrift auf dem Knopf), jeweils als `#hex` oder `@schlüssel` aus der Palette. Leer heißt Papier, Tinte und Akzent aus dem Design. Die Fläche trägt einen schmalen Streifen und ein Symbol in der Knopffarbe; „Nur notwendige“ bleibt ein gleich großer Rahmenknopf daneben. Dieselben Farben gelten auf `/einwilligung` und in den Beitragsarchiven.
|
||||
|
||||
**Der Nach-oben-Knopf** war ausgeblendet, solange die Einwilligungsfrage offen stand – auf dem Telefon bleibt sie das oft, und der Knopf kam dort nie. Jetzt setzt er sich auf die Oberkante der Leiste, wo sie die Ecke abdeckt; Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Der reservierte Abstand unten reicht auf schmalen Bildschirmen wieder für die ganze Leiste.
|
||||
|
||||
**Im Designbereich** sind belegte Eigenschaften jetzt in Orange beschriftet, nicht nur mit Punkt und Fettdruck; die Gerätechips D/T/M stehen weiter auseinander, und der Fokusring bleibt in der Lücke.
|
||||
|
||||
## Schriften vom eigenen Server, Hinweise auf jedem Bildschirm (ab 1.14.0)
|
||||
|
||||
**Schriften kommen ausschließlich von deinem Server.** Die Ausgabe verlinkt kein Stylesheet mehr von `fonts.googleapis.com` – weder die öffentliche Seite noch die Editorvorschau, die es bisher bei jedem Aufruf tat. Fehlen die Dateien, zeigt die Website die eingestellte Ersatzschrift und sagt es dir. Geholt werden sie wie bisher einmalig unter **Design → Schriften ausliefern**. Zusätzlich wird jetzt **jeder** Schnitt des Designs vorab geladen: bisher waren es die ersten zwei je Familie, weshalb die Überschriften sichtbar von der Ersatzschrift auf die richtige Schrift sprangen.
|
||||
|
||||
**Offene Hinweise stehen auf jedem Verwaltungsbildschirm**, mit dem Knopf daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren*, „Schriftdateien fehlen“ → *Schriften holen*. Keiner der beiden fragt beim Öffnen im Netz nach.
|
||||
|
||||
**System und Update** ist auf dem Telefon benutzbar: Die Seite lief seitlich aus dem Bild, die Sicherungstabelle wird zu gestapelten Blöcken, und die Texte sind knapper – das Kleingedruckte steht zugeklappt.
|
||||
|
||||
**Im Designbereich** zeigen die Gerätechips **D/T/M** jetzt in Orange, welches Gerät einen eigenen Wert trägt; das gerade bearbeitete Gerät hat einen Rahmen statt des bisherigen roten Rings, der am Rand der Spalte abgeschnitten wurde.
|
||||
|
||||
## Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (ab 1.14.0)
|
||||
|
||||
Unter **Einstellungen → Unternehmensdaten** gibt es **Öffnungszeiten** (je Wochentag Öffnen und Schließen; leer heißt geschlossen) und das **Einsatzgebiet** (ein Ort je Zeile). Beides erscheint in den strukturierten Daten der Startseite (`openingHoursSpecification`, `areaServed`); gleiche Zeiten werden zu einer Angabe zusammengefasst. Der sichtbare Text auf der Website bleibt unabhängig davon.
|
||||
|
||||
Ein Akkordeon wird per Kästchen **Als FAQ auszeichnen** zur `FAQPage`: die Titel sind die Fragen, die Textbausteine im Eintrag die Antwort. Einträge ohne Text erscheinen nicht. Das gilt nur für Akkordeons mit gesetztem Kästchen – ein Akkordeon mit Leistungen bleibt ein Akkordeon.
|
||||
|
||||
## Mehr Symbole, Menü-Knopf gestalten, Nach-oben-Knopf zuverlässig (ab 1.13.0)
|
||||
|
||||
Der Symbolsatz hat drei Zeichnungen mehr: **Menschen**, **Musik** und **Haus**. Sie stehen überall zur Wahl, wo es Symbole gibt – Symbolbaustein, Symbolkarte und Menüeinträge. Eine am Symbolbaustein eingestellte **Farbe** wirkt jetzt auch wirklich auf die Zeichnung; bisher blieb sie in der Akzentfarbe der Website stecken.
|
||||
@@ -138,7 +204,7 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
|
||||
|
||||
Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern und den Serverstand bewusst neu laden. Auch Netzwerk-/Sitzungsfehler lassen den lokalen Stand erhalten. Die Vorschau behält bei ungültigen Eingaben die letzte gültige Ausgabe.
|
||||
|
||||
@@ -204,6 +270,8 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
`test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
|
||||
@@ -253,7 +321,7 @@ Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsv
|
||||
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
|
||||
## Verwaltung ab 1.7.0
|
||||
|
||||
@@ -283,6 +351,18 @@ In der App-Vorschau erledigt das die Startkonfiguration `elektro-bernhard` in `.
|
||||
|
||||
Der Bestand wird nicht von Hand gepflegt, sondern aufgebaut: `scripts/seed-elektro-bernhard.php <Ordner mit den Originalbildern>` legt Gestaltung, Medien, Formular, Kopf- und Fußbereich, elf Seiten, vier Menüs, Einstellungen, Weiterleitungen und ein Administratorkonto an. Das Skript weigert sich, wenn die Datenbank schon Seiten enthält oder `DATABASE_URL` nicht auf diese Installation zeigt. `sh scripts/reset-elektro-bernhard.sh <Bilderordner>` wirft die Installation weg, migriert neu und baut sie wieder auf; das Passwort des Kontos steht am Ende der Ausgabe. Vorlage ist das statische Paket `Elektro-Bernhard-Website`; die drei Stellen, an denen der Nachbau bewusst abweicht, stehen in `docs/STATUS.md`.
|
||||
|
||||
### Dritte lokale Referenzwebsite: Tagespflege Moldovan
|
||||
|
||||
Der Onepager „Tagespflege Jacqueline Moldovan“ liegt in `var/tagespflege-moldovan.db` mit Medien in `var/tagespflege-moldovan-media`; die Startkonfiguration `tagespflege-moldovan` in `.claude/launch.json` nutzt Port 8089. Der Inhalt entsteht ausschließlich über `scripts/seed-tagespflege-moldovan.php <Ordner mit gemeinsam.webp, jacqueline-moldovan.png und marke.png>`; `sh scripts/reset-tagespflege-moldovan.sh [Ordner]` (Standard `var/tagespflege-moldovan-assets`) wirft die Installation weg, migriert neu und baut sie wieder auf. Vorlage war das gelieferte Paket `Tagespflege_Moldovan_Website.zip` (09.09.2026); die bewussten Abweichungen stehen im Kopf des Skripts.
|
||||
|
||||
### Vierte lokale Referenzwebsite: Ihr Malerservice Drews
|
||||
|
||||
Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `var/maler-drews.db` mit Medien in `var/maler-drews-media`; die Startkonfiguration `maler-drews` in `.claude/launch.json` nutzt Port 8093. `scripts/seed-maler-drews.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, acht Seiten, 31 Beiträge in zwei Kategorien, Menüs, Formular, Einstellungen und Weiterleitungen auf; der Wortlaut der Beiträge, des Impressums und der Datenschutzerklärung steht in `scripts/seed-maler-drews-inhalte.php`. `sh scripts/reset-maler-drews.sh [Bilderordner]` (Standard `var/maler-drews-assets`, Zuordnung in `manifest.json`) wirft die Installation weg und baut sie neu auf; die Schriften Lato 400/700 und Cormorant Garamond 700 holt es nur, wenn die Cormorant-Dateien in `public/assets/fonts` fehlen, weil `cms:fonts:fetch` die geteilte `fonts.css` sonst nur für dieses Design neu schreibt (Kant-IT und Elektro Bernhard brauchen Lato 900). Für den Abruf liegt `var/cacert.pem` (curl.se) bereit, weil das PHP 8.4 auf diesem Rechner keine Stammzertifikate kennt. Die bewussten Abweichungen vom Original stehen im Kopf des Skripts.
|
||||
|
||||
### Fünfte lokale Referenzwebsite: DEEG Immobilien & Hausverwaltung
|
||||
|
||||
Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Hausverwaltung und Immobilien GmbH) liegt in `var/immobilien-deeg.db` mit Medien in `var/immobilien-deeg-media`; die Startkonfiguration `immobilien-deeg` in `.claude/launch.json` nutzt Port 8094, `immobilien-deeg-prod` denselben Bestand im Produktionsmodus auf Port 8095 zum Messen. `scripts/seed-immobilien-deeg.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `bewertung` (Bewertungsanfrage mit Auswahl Wohnung, Haus, Mehrfamilienhaus, Grundstück); `sh scripts/reset-immobilien-deeg.sh [Bilderordner]` (Standard `var/immobilien-deeg-assets`: Logo und zwei Architekturfotos aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||
|
||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||
@@ -8,7 +8,9 @@ framework:
|
||||
cookie_httponly: true
|
||||
cookie_samesite: lax
|
||||
cookie_secure: auto
|
||||
cookie_path: /admin
|
||||
# Der Pfad folgt der Adresse der Verwaltung: hiesse sie anders und das Cookie weiter
|
||||
# /admin, schickte der Browser es nie mit, und keine Anmeldung kaeme je an.
|
||||
cookie_path: '/%kant.admin_path%'
|
||||
name: KANTSESSION
|
||||
router:
|
||||
utf8: true
|
||||
@@ -19,6 +21,7 @@ framework:
|
||||
log: true
|
||||
mailer:
|
||||
# Default to the host's sendmail, which is what ordinary PHP hosting offers. Override with
|
||||
# MAILER_DSN in .env.local for SMTP. Delivery failures never lose a submission: it is
|
||||
# MAILER_DSN in .env for SMTP. Delivery failures never lose a submission: it is
|
||||
# stored first and stays visible as undelivered in the administration.
|
||||
dsn: '%env(resolve:MAILER_DSN)%'
|
||||
# Ohne `resolve:`: ein SMTP-Passwort mit Prozentzeichen ist kein Parametername.
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
@@ -1,5 +1,8 @@
|
||||
twig:
|
||||
default_path: '%kernel.project_dir%/templates'
|
||||
# Die Vorlagen der Plugins (@plugins/<name>/...) registriert App\Twig\PluginExtension zur
|
||||
# Laufzeit - nur wenn der Ordner plugins/ existiert; ein fester Pfad hier liesse Twig auf einer
|
||||
# Installation ohne Plugins abbrechen.
|
||||
strict_variables: true
|
||||
globals:
|
||||
# Reading the decision needs the current request, so the templates ask the gate directly
|
||||
|
||||
+29
-7
@@ -26,14 +26,18 @@ services:
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
Doctrine\DBAL\Connection:
|
||||
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||
arguments: ['%env(resolve:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
App\Controller\PageController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
# Mit oder ohne www entscheidet SITE_ORIGIN; die andere Schreibweise leitet dorthin um.
|
||||
App\EventSubscriber\CanonicalHostSubscriber:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
@@ -67,20 +71,25 @@ services:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$databaseUrl: '%env(DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Command\AdminPathCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Ops\ArchiveUpdate:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
App\Ops\Updater:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
App\EventSubscriber\InstallSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -91,13 +100,13 @@ services:
|
||||
App\Ops\Installation:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
$secret: '%kernel.secret%'
|
||||
App\Ops\Backup:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
|
||||
# jedes Mal mitwaechst.
|
||||
App\Ops\Scheduler:
|
||||
@@ -118,6 +127,19 @@ services:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Infrastructure\QueryCounter: ~
|
||||
# `%env(projectdir:...)%`: ersetzt nur %kernel.project_dir%. Symfonys `resolve:` laese ein
|
||||
# prozentkodiertes MySQL-Passwort (`p%40ss`) als Parameter und brauche den Container.
|
||||
App\Infrastructure\ProjectDirEnvProcessor:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\QueryHeaderSubscriber:
|
||||
arguments:
|
||||
$environment: '%kernel.environment%'
|
||||
|
||||
App\Plugin\PluginRegistry:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Twig\PluginExtension:
|
||||
arguments:
|
||||
$loader: '@twig.loader.native_filesystem'
|
||||
+163
-4
@@ -1,7 +1,166 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 105 – Designpakete in Plugins zurückgenommen: das Design gehört der Website (16.09.2026)
|
||||
|
||||
Umut hatte auf der DEEG-Installation „Nachtblau“ eingeschaltet und wieder ausgeschaltet – und die Farben blieben. Die Ursache war nicht der Schalter (`activate()`/`deactivate()` fassen das Design nie an), sondern der zweite Knopf daneben: „Designpaket übernehmen“ schrieb die Palette des Plugins über `Theme::save()` endgültig in die Website. Technisch war das gewollt (ADR 103: ein Import, damit sich das Ergebnis unter Design weiter anpassen lässt und beim Ausschalten nichts verlorengeht) – in der Bedienung war es eine Falle: eine Aktion, die die gesamte Palette ersetzt, ohne Rückfrage und ohne Weg zurück, auf einem Bildschirm, wo daneben ein Schalter sitzt, den man gefahrlos umlegen kann.
|
||||
|
||||
**Entscheidung: `theme` fällt aus dem Manifest.** Kein `themePackage()`, kein `applyTheme()`, keine Route `admin_plugin_theme`, kein Knopf auf dem Bildschirm „Plugins“, kein `plugins/nachtblau`. Ein Manifest braucht jetzt mindestens einen Baustein, eine Adresse oder eine Vorlage. Der Alternativweg – Sicherung der alten Palette plus „Design zurücksetzen“ – wäre eine Spalte, eine Migration und ein zweiter Zustand gewesen, um eine Funktion zu retten, die niemand vermisst: Farben, Schriften und Buttons ändert man unter **Design**, dort mit Vorschau und in einem Schritt rückgängig.
|
||||
|
||||
**Folgen.** Ein Plugin kann das Aussehen der Website nur noch über das CSS seiner eigenen Bausteine beeinflussen – nie über die Palette. Der Test dazu heißt jetzt umgekehrt: Ein- und Ausschalten lässt Design **und** Inhalte unangetastet (`PluginTest::testSwitchingAPluginOnAndOffLeavesTheDesignAlone`). Bestehende Installationen behalten die Palette, die sie haben; wer ein übernommenes Paket loswerden will, setzt die Farben unter Design. Auf der DEEG-Installation ist die eigene Palette (Logo-Blau `#0081c7`, Anthrazit, Arial, eckige Buttons) aus dem Aufbauskript wieder eingespielt. Die Abnahme „Templatewechsel erhält die Inhalte“ aus dem Ausbauplan gilt damit als nicht gebaut; ein Templatewechsel gehört, wenn er kommt, in den Designbereich und nicht in ein Plugin.
|
||||
|
||||
## ADR 104 – Die Rückstände der Gesamtprüfung abgearbeitet: Archiv als Seite, Bereiche vollständig, Palette bis in die Fehlerfarbe, Betrieb auf MySQL (16.09.2026)
|
||||
|
||||
Umut: „Bitte umsetzen“ – die Liste aus ADR 100, die bis dahin nur dokumentiert war.
|
||||
|
||||
**Archivseiten sind Seiten.** `/beitraege`, `/kategorie/…` und `/schlagwort/…` bauen ein Dokument aus Bausteinen (Kopfabschnitt mit Überschrift und Zahl, ein Abschnitt mit blätternder Beitragsliste) und geben es an `renderPage()` – dieselbe Aufbaukette wie jede Seite: Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, eigenes CSS, Schriften, Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Skripte, Richtlinie. Die eigene Vorlage `archive.html.twig` ist weg. Ein Archiv hat den Typ `archive`; Brotkrumen führen „Beiträge → Kategorie“, und das steht als `BreadcrumbList` in den strukturierten Daten.
|
||||
|
||||
**Blättern ist eine Folge von Seiten.** Hat eine Seite eine blätternde Beitragsliste, nennt die Canonical ab Seite 2 die Seitenzahl (`?seite=n`), und `<link rel="prev">`/`<link rel="next">` verbinden die Folge. Vorher hielt eine Suchmaschine Seite 3 für ein Duplikat von Seite 1.
|
||||
|
||||
**Bereiche sind vollständig.** Formulare, Beitragslisten und Brotkrumen in Kopf, Fuß und verknüpften Sektionen werden jetzt wie im Inhalt aufgelöst; die Meldungen eines abgewiesenen Formulars finden ihren Block über die Formularadresse statt über die Block-ID, wo auch immer er steht. Eigene Skripte eines Bereichs laufen als `/assets/bereich/<id>.js` – vorher wurden sie gespeichert, geprüft und nie ausgeführt.
|
||||
|
||||
**Mehrfach verknüpfte Sektionen ohne doppelte IDs.** `PartialRepository::expandRepeatedLinks()` zählt in Renderreihenfolge (Kopf, Inhalt, Fuß, verschachtelt bis zur Grenze) und gibt der zweiten und jeder weiteren Verknüpfung eine Kopie mit angehängten Kennungen (`id-2`) und ohne Sprungziele; ihre Stile werden mitkompiliert. Auch die Editorvorschau macht das.
|
||||
|
||||
**Eine Grenze zwischen Tablet und Telefon: 767 px.** Design-Tokens, Website-Stile, Slider und Galerie sprangen bei 650, der Stilcompiler und die Sichtbarkeit bei 767 – zwischen 651 und 767 galt die Hälfte. Jetzt gilt überall 1024/767, wie es der Editor schon immer sagte.
|
||||
|
||||
**Die Palette reicht bis in die Fehlerfarbe.** `color-scheme`, Skip-Link, Aufruf-Button, die Fehlerfarben der Formulare und der Text auf dunklen Abschnitten kommen aus Variablen; `Theme::css()` leitet aus der Leuchtdichte des Papiers ab, ob das Schema dunkel ist und welches Rot dort steht (`--k-scheme`, `--k-error`, `--k-error-bg`, `--k-error-line`). Buttons dunkeln beim Überfahren in die Tinte statt in Schwarz. Neu: Druckstile – Menü, Einwilligung, Nach-oben und Formularknöpfe bleiben weg, Verweise zeigen ihre Adresse.
|
||||
|
||||
**Betrieb.** Sicherung und Wiederherstellung auf MySQL/MariaDB über `mysqldump --single-transaction --result-file` und `mysql --execute=source`; damit laufen Archiv-Update und Rückweg auch dort. Der Git-Updater sichert über `Backup` (VACUUM INTO bzw. Dump) statt mit einer rohen Dateikopie und spielt beim Rückweg dieselbe Sicherung ein. Eine Sperre (`var/update.lock`, flock) hält den zweiten Klick und den Archiv-Weg neben dem Git-Weg fern. Das Archiv wird direkt in eine Datei geladen (`Download::toFile()`), nicht mehr als Zeichenkette in den Speicher. `public/.htaccess` wird zusammengeführt: eingeschaltete HTTPS- und HSTS-Zeilen bleiben eingeschaltet. Nach einem gelungenen Update räumt `finish()` `var/update/` (bis auf die zwei jüngsten) und die Zwischenspeicher früherer Fassungen auf. Der MySQL-Job der CI prüft jetzt Gesundheit, Sicherung und die Tests.
|
||||
|
||||
**Editor.** Die Vorschau zeigt eigenes CSS, Bereichs- und Sektionsstile und Formulare mit ihren Farben – „sieht in der Vorschau anders aus“ ist keine Ausrede mehr. Kontaktdaten, Formular, verknüpfte Sektion und externer Inhalt sind direkt nach dem Einfügen gültig: leer heißt „noch nichts gewählt“, der Editor sagt es, die Website zeigt nichts. Der Server gibt nach dem Speichern die bereinigte Fassung zurück, und der Editor übernimmt sie als gespeicherten Stand (`EditorHistory.adopt()`), statt sie erst nach dem Neuladen zu zeigen.
|
||||
|
||||
**Kleines.** `legal.label` ist auf 60 Zeichen begrenzt, die verrutschten Docblocks in `PageController` stehen an ihren Methoden.
|
||||
|
||||
**Folgen.** `tests/BacklogTest.php` hält alles fest. Nicht gebaut: eine Wiederherstellung auf MySQL ohne `mysql`-Client (dann liegt der Dump zum Einspielen von Hand bereit), Druckstile für Beiträge mit Bildkopf.
|
||||
|
||||
## ADR 103 – Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen und Designpakete für Plugins (Designpakete zurückgenommen, ADR 105) – und warum es keine PHP-Hooks gibt (16.09.2026)
|
||||
|
||||
Umut: „Dann mach H.“ Der erste Teil (ADR 102) brachte Manifest, Schalter und geprüfte Bausteine. Offen waren Container, Einstellungen, eigene Routen, Tabellen, Hooks und Templates.
|
||||
|
||||
**Grundentscheidung: ein Plugin bleibt Manifest plus Twig.** Es bringt keinen PHP-Code mit. Das ist kein Zufall, sondern die Antwort auf zwei Punkte des Entwicklungsplans zugleich: Ein Plugin ohne PHP kann nicht als „sandboxed“ bezeichnet werden müssen, weil es gar nichts ausführt, was der Kern nicht ohnehin ausführt (Twig mit `strict_variables`, ohne Zugriff auf Datenbank, Anfrage oder Dateisystem). Und ein Update des Kerns kann ein solches Plugin nicht brechen, solange die vier Übergaben stehen – `props`, `settings`, `children`, `block`. Damit fallen zwei Wünsche des Plans bewusst weg: **Hooks** in Speichern und Veröffentlichen und **eigene Tabellen**. Beides braucht Code, der im Kern läuft. Wer das braucht, baut einen Kernbaustein (ADR 049) oder wartet auf einen dritten Teil mit einer echten Erweiterungsschnittstelle in PHP – dann mit der Ehrlichkeit, dass er Installationscode ist.
|
||||
|
||||
**Container.** `"container": true` am Baustein, optional `"children": [Typen]`. Der Kern rendert die Kinder selbst – mit demselben Kontextbündel wie jeden Kernbaustein – und reicht sie der Vorlage fertig als `children`. So kommt eine Plugin-Vorlage nie an Formulare, Beitragslisten oder die Navigation. Beim Schreiben prüft `DocumentValidator`, dass ein Nicht-Container keine Kinder trägt und ein Container nur die erlaubten; der Editor spiegelt die Regel in `canContain`, also im Werkzeugkasten und beim Ziehen.
|
||||
|
||||
**Einstellungen.** `"settings": {Schlüssel: Schema}` mit denselben sieben Arten wie Eigenschaften. Der Bildschirm „Plugins“ zeigt für jedes aktive Plugin ein Formular daraus; gespeichert wird in der neuen Spalte `plugin.settings` (Migration `20260916000100`), geprüft gegen das Schema wie eine Eigenschaft – aus einem Formular kommen Zahlen und Schalter als Text, das wird umgewandelt. Vorlagen und Adressen bekommen die Werte als `settings`, fehlende Schlüssel mit ihrer Vorgabe.
|
||||
|
||||
**Eigene Adressen.** `"routes": {name: {path, template, type}}`. Der Pfad hat die Form einer Seite (Slugs, bis zu vier Ebenen) und darf nichts belegen, was der Kern beantwortet; die Antwortart ist eine aus html, json, text, xml. `PageController::page()` fragt die Registry, wenn es zu einem Pfad keine veröffentlichte Seite gibt – eine Seite geht also immer vor, die Adresse kommt vor Weiterleitung und 404. Die Vorlage bekommt `settings`, die Abfrageparameter (nur saubere Namen, gekürzt) und die Route; die Antwort trägt dieselben Schutzkopfzeilen wie jede Seite, kein Skript, bei allem außer html `noindex`. Ausgeschaltet: 404.
|
||||
|
||||
**Seitenvorlagen.** `"presets": {name: {label, blocks}}` – fertige Bausteine, die der Editor als Ganzes einfügt, hinter den eingebauten Vorlagen mit demselben Knopf. Jedes Einfügen vergibt frische Kennungen; geprüft wird beim Speichern wie jedes Dokument. Ein Plugin kann so seinen Baustein gleich im passenden Abschnitt anbieten.
|
||||
|
||||
**Designpakete.** *(Zurückgenommen in ADR 105 – es gibt sie nicht mehr.)* `"theme": {colors, fonts, base, buttons}` im Format des Designbereichs. „Designpaket übernehmen“ auf dem Bildschirm „Plugins“ ruft `Theme::save()` – dieselbe Prüfung wie beim Speichern unter Design, dieselben Fehler. Inhalte bleiben unangetastet; genau das ist die Abnahme des Plans („Templatewechsel erhält die Inhalte“), und der Test wechselt auf einer Seite mit Plugin-Baustein das Paket und vergleicht das Dokument davor und danach. Ein Plugin darf nur aus einem Designpaket bestehen (`plugins/nachtblau`); übernehmen geht nur eingeschaltet.
|
||||
|
||||
**Referenz.** `plugins/vergleich` 1.1.0 hat jetzt Einstellungen (Standardbeschriftungen, Griff), einen Container „Vergleichsgalerie“ nur für Bildvergleiche, die Vorlage „Vorher-Nachher-Abschnitt“ und die Adresse `/vergleich/daten` (JSON mit den Einstellungen). `plugins/nachtblau` war das Designpaket und ist mit ADR 105 entfallen.
|
||||
|
||||
**Folgen.** `docs/PLUGINS.md` beschreibt alles. Nicht gebaut und nicht geplant, solange Plugins ohne PHP bleiben: Hooks, Tabellen, Ressourcen in der Verwaltung, eigene Verwaltungsbildschirme.
|
||||
|
||||
## ADR 102 – Das Pluginsystem, erste Fassung: Manifest, Schalter, geprüfte Bausteine (15.09.2026)
|
||||
|
||||
Umut: „Plugin hätte ich gerne aber umgesetzt. Also Möglichkeit.“ Bis hierher gab es nur den Erhaltungsvertrag aus ADR 011: ein Baustein mit Namensraum wurde aufbewahrt, aber nie gerendert. Jetzt gibt es die Laufzeit dazu – Arbeitspaket H, erster Teil.
|
||||
|
||||
**Ein Plugin ist ein Ordner.** `plugins/<name>/plugin.json` beschreibt Titel, Fassung, kleinste KantSites-Fassung und die Bausteine: je Baustein Beschriftung, Symbol, Twig-Vorlage, optional ein Stylesheet und ein Skript unter `assets/`, und ein Schema der Eigenschaften mit sieben Arten (Text, mehrzeilig, Zahl mit Grenzen, an/aus, Auswahl, Adresse, Bild aus der Mediathek). Der Typ des Bausteins ist `<plugin>.<baustein>` – dieselbe Form, die der Erhaltungsvertrag seit Schema v4 kennt. `PluginRegistry` liest die Manifeste bei jedem Aufruf (ein Dutzend Dateien, kein Cache nötig) und meldet ein kaputtes Manifest mit Grund statt es zu übergehen; ein solches Plugin lässt sich nicht einschalten.
|
||||
|
||||
**Der Schalter liegt in der Datenbank.** Migration `20260915000100` legt `plugin` an (Name, aktiv, Fassung beim Einschalten, Zeitpunkt). Ob ein Plugin läuft, entscheidet die Installation, nicht der Code – und nicht eine Datei, die ein Update überschreiben könnte. Der Bildschirm „Plugins“ (Administrator) zeigt jeden Ordner mit seinen Bausteinen und schaltet ein und aus; ein ausgeschaltetes Plugin lässt seine Bausteine unangetastet in den Dokumenten (Erhaltungsvertrag wie bisher).
|
||||
|
||||
**Der Kern prüft, die Vorlage vertraut.** Beim Schreiben eines Dokuments prüft `DocumentValidator` die Eigenschaften eines Plugin-Bausteins gegen das Schema – so streng wie bei einem Kernbaustein, aber nur, wenn das Plugin aktiv ist. Beim *Lesen* nicht (`validate(..., writing: true)` nur an den Schreibstellen): ein Plugin, das nach dem Speichern eingeschaltet oder geändert wurde, darf eine gespeicherte Seite nicht unlesbar machen. Dass unpassende Werte trotzdem nie in die Ausgabe gelangen, sichert der Renderer: `kant_plugin_props(block)` prüft beim Rendern erneut und liefert bei einem Fehler null – dann steht ein Kommentar statt des Bausteins. Die Vorlage bekommt genau vier Dinge: die geprüften `props`, `block` (für die `id`), `designClasses` und `editing`, dazu `kant_plugin_media()` für Bilder. Keine Datenbank, keine Einstellungen, keine Anfrage. Unbekannte Eigenschaften in einem gespeicherten Dokument werden ignoriert, damit ein Plugin eine Eigenschaft streichen kann.
|
||||
|
||||
**Dateien nur dort, wo der Baustein steht.** `PageController` sammelt aus allen Dokumenten der Seite die Plugin-Bausteine und verlinkt ihre Stylesheets und Skripte (`/plugin-assets/<name>/assets/...?v=<Fassung>`); ein Skript schaltet die Richtlinie auf `script-src 'self'`, sonst bleibt sie bei `'none'`. `PluginAssetController` liefert nur Dateien aktiver Plugins unter `assets/` mit erlaubter Endung, mit Typ, `nosniff` und einem Jahr Haltbarkeit; Manifest und Vorlagen sind nicht erreichbar. Plugins liegen bewusst außerhalb von `public/`. In der Editorvorschau wird das Stylesheet geladen, das Skript nicht – wie bei eigenem Code.
|
||||
|
||||
**Der Editor kennt die Bausteine aus dem Dokumentaufruf.** Der Editor-Endpunkt liefert mit dem Dokument die Definitionen der aktiven Plugins; `registerPlugins()` trägt Beschriftung, Symbol und Vorgaben ein und gibt ihnen die Gruppe „Erweiterungen“ im Werkzeugkasten. Das Eigenschaftenpanel entsteht aus dem Schema mit derselben Feldfabrik wie für Kernbausteine; ein Bild geht über die Mediathek. Ein Baustein, dessen Plugin fehlt, bleibt der bekannte Platzhalter. Design-Tokens (Abstände, Ausrichtung, Ecken) gelten jetzt auch für Plugin-Bausteine – die Klassen sind allgemein, die Prüfung lässt namensraumhaltige Typen durch.
|
||||
|
||||
**Kein Sandkasten, und das steht so da.** Eine Plugin-Vorlage läuft mit den Rechten der Anwendung. Der Entwicklungsplan verlangt, das nicht anders zu nennen; der Bildschirm „Plugins“ sagt es dem Betreiber.
|
||||
|
||||
**Referenzplugin „Bildvergleich“** (`plugins/vergleich`): zwei Bilder übereinander, ein Regler dazwischen – Beschriftungen, Startposition, Ecken als Eigenschaften, ein Stylesheet, ein Skript, das nur `--pos` über das CSSOM setzt (Inline-Stile bleiben durch die Richtlinie verboten). Ohne Skript steht der Vergleich bei der Startposition, das native Bereichsfeld bleibt bedienbar.
|
||||
|
||||
**Updates.** Das Releasearchiv enthält `plugins/`; das Archiv-Update übernimmt den Ordner Datei für Datei wie `public/`, sodass mitgelieferte Plugins aktualisiert werden und ein selbst installiertes stehen bleibt.
|
||||
|
||||
**Nicht in dieser Fassung:** Container-Bausteine, eigene Routen, Einstellungen, Tabellen, Hooks in Speichern und Veröffentlichen, Ressourcen in der Verwaltung, Templates (der zweite Teil von H). Anleitung für Entwickler: `docs/PLUGINS.md`.
|
||||
|
||||
## ADR 101 – Formulare mit Schritten und Bedingungen: der Preisfinder ist ein Formular, kein Plugin (15.09.2026)
|
||||
|
||||
Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so ein ausgebautes Form“ – den Preisfinder der laufenden Website: erst die Objektart, dann Eckdaten je Objektart, dann Kontakt, zum Schluss eine Zusammenfassung. Und er fragte, ob das ein eigenes Plugin braucht.
|
||||
|
||||
**Warum kein Plugin.** Ein Plugin ist in diesem CMS bis heute nur der Erhaltungsvertrag aus ADR 011: ein Baustein mit Namensraum wird gespeichert, im Editor als Platzhalter gezeigt und öffentlich als Kommentar ausgegeben. Registrierung, Laufzeit, Eigenschaftenpanel und Ressourcenladen sind Arbeitspaket H und nicht gebaut. Ein Preisfinder als Plugin hätte zuerst das ganze Pluginsystem gebraucht – und wäre dann eine Hülle um genau das, was der Formularbaukasten schon kann: Felder prüfen, speichern, verschicken, anzeigen. Wie bei der Beitragsseite (ADR 049) ist die Antwort ein Ausbau des Kerns. Ein Formular, das in Schritten fragt und Felder je nach Antwort zeigt, braucht jede Kundenwebsite.
|
||||
|
||||
**Was dazukam.** Drei Feldtypen: Zahl (mit Unter-, Obergrenze und Einheit), Auswahl als Optionsfelder und Mehrfachauswahl. 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. Jedes Feld und jeder Schritt kann eine Bedingung tragen (`showIf: {field, values}`): es gilt nur, wenn ein *früheres* Auswahlfeld einen der Werte hat. Und eine Einstellung „Zusammenfassung vor dem Absenden“.
|
||||
|
||||
**Der Server entscheidet, nicht das Skript.** `FormRepository::check()` geht die Felder in Reihenfolge durch und merkt sich die gewählten Optionen; ein Feld hinter einer Bedingung, die nicht zutrifft, wird weder verlangt noch geprüft noch gespeichert – auch wenn der Browser einen Wert mitschickt. Ein Schritt hinter einer Bedingung nimmt seine Felder mit. Beim Speichern der Definition wird jede Bedingung geprüft: das Feld muss davor stehen, ein Auswahlfeld sein, und jeder Wert eine seiner Optionen – sonst könnte die Bedingung nie zutreffen, und ein Pflichtfeld dahinter wäre unerreichbar oder immer offen. Optionen dürfen kein Komma enthalten, weil die Verwaltung daran trennt und die Mehrfachauswahl damit gespeichert wird.
|
||||
|
||||
**Ohne Skript vollständig.** Die Vorlage rendert Schritte als `fieldset` mit `legend`, Optionsfelder und Mehrfachauswahl als Gruppen, Zahlen als `type="number"` mit Grenzen; Bedingungen stehen als Datenattribute am Feld. Ohne JavaScript steht das ganze Formular auf der Seite, jeder Schritt ein Abschnitt, alles absendbar – der Server prüft ohnehin selbst. `form-steps.js` kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat (`FormRepository::needsScript()`), und die Richtlinie erlaubt es dann; es zeigt einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“, prüft den aktuellen Schritt mit der Browserprüfung, deaktiviert Felder hinter nicht zutreffenden Bedingungen (deaktiviert, nicht nur versteckt – sie werden nicht gesendet und nicht geprüft) und baut als letzten Schritt die Zusammenfassung aus den geltenden Feldern mit „Ändern“ je Schritt. Nach einer Ablehnung durch den Server öffnet es den ersten Schritt mit einem Fehler. Eine Pflicht-Mehrfachauswahl trägt `required` nicht am Kästchen – das hieße „alle ankreuzen“ – sondern als `data-required` an der Gruppe; Skript und Server prüfen sie dort.
|
||||
|
||||
**Bildauswahl.** Umut zeigte den Solar-Rechner von Elektro Bernhard: „Wieviele Personen leben im Haushalt“ als vier Bildkarten, die gewählte mit Rahmen. Optionsfelder und Mehrfachauswahl tragen dafür je Option ein Bild aus der Mediathek (`images: {Option: Kennung}`); sobald eine Option eines hat, rendert die Vorlage die Auswahl als Karten mit Bild und Text. Das native Kästchen bleibt in der Karte – sichtbar oben links, mit Fokusring auf der Karte –, damit Tastatur und Vorlesesoftware dieselbe Auswahl bedienen wie die Maus. Die Seite holt die kleinsten Fassungen aller Optionsbilder eines Formulars mit einer Abfrage (`PageController::optionImages()`); ein gelöschtes Bild lässt die Karte mit Text stehen. In der Verwaltung wählt man das Bild je Option aus einer Liste der Mediathek; ohne Skript ist es eine Zeile „Option = Kennung“.
|
||||
|
||||
**Rückweg.** Bestehende Definitionen bleiben gültig: `present()` ergänzt fehlende Schlüssel (`min`, `max`, `unit`, `images`, `showIf`, `summary`), es gibt keine Migration. Ein Formular ohne Schritte und Bedingungen rendert wie zuvor und lädt kein Skript. Die Höchstzahl der Felder steigt von 25 auf 40; ein Preisfinder mit vier Objektarten braucht sie.
|
||||
|
||||
**Folgen.** Die Editorvorschau zeigt Formulare weiterhin nicht (bekannter Rückstand). Der Preisfinder der DEEG-Website ist damit ein gewöhnliches Formular `bewertung` mit drei Schritten, 20 Feldern und Zusammenfassung, angelegt im Aufbauskript und in der Verwaltung ohne Skript bearbeitbar. Nicht gebaut: Bedingungen auf Zahlenbereiche oder mehrere Felder, Dateiuploads, ein Schritt-für-Schritt-Ablauf ohne JavaScript.
|
||||
|
||||
## ADR 100 – Gesamtprüfung: Rollen wie versprochen, Installer bleibt zu, Einwilligung nur von hier (15.09.2026)
|
||||
|
||||
**Anlass:** Umut wollte eine umfangreiche Prüfung – Fehler, Durchgängigkeit, Sauberkeit – und ausdrücklich die Frage, ob jemand ohne Administrator etwas verändern kann. Sechs getrennte Durchsichten (Backend, Betrieb, Editor, öffentliche Ausgabe, Dokumentation, Autorisierung jeder Route) lieferten rund sechzig Befunde; die mit Wirkung auf Sicherheit oder Bedienbarkeit sind umgesetzt, der Rest steht in STATUS als Rückstand.
|
||||
|
||||
**Rollen.** Die Rollenhilfe versprach „Design, Einstellungen: Administrator“ und „Redakteur: nur Entwürfe“, der Code verlangte für Design, Einstellungen, Bereiche, Menüs und Formulare aber nur `ROLE_EDITOR` – ein Redakteur konnte Kopf und Fuß live umschreiben, die Empfängeradresse der Formulare ändern, die Website per `robotsExtra` aus dem Index nehmen. Entscheidung: der Code folgt der Hilfe. **Redakteur** = Entwürfe von Seiten und Beiträgen, Medien, Statistik, eigenes Konto. **Veröffentlicher** = dazu veröffentlichen, Bereiche (Speichern ist dort Veröffentlichen), Menüs, Formulare, Weiterleitungen, 404-Seite, Einstellungen lesen. **Administrator** = dazu Design, Unternehmensdaten samt Einwilligung und Spamschutz, Benutzer, System, Aufgaben, eigene Skripte, endgültiges Löschen. Die Navigation zeigt je Rolle nur, was sie öffnen darf; die Unternehmensdaten sind für Veröffentlicher ein Hinweis statt eines Formulars. `tests/EditorSecurityTest.php` hält die Grenze mit einer Tabelle „Rolle, Route, 403“ fest, dazu die Navigation eines Redakteurs und den Weg ohne Anmeldung.
|
||||
|
||||
**Installer.** `Installation::isInstalled()` las jede Ausnahme als „nicht installiert“ – auch eine tote Datenbankverbindung. Bei einer Wartung des Hosters oder einem gedrehten Passwort stand der Installer offen, und jeder Besucher hätte eine eigene Datenbank samt Administratorkonto eintragen können. Jetzt zählt die Marke `var/installed` immer, eine fehlende Tabelle heißt „leer“, eine fehlende Verbindung heißt „gestört“, sobald eine Datenbank eingetragen ist. `tests/InstallationTest.php` prüft alle vier Lagen.
|
||||
|
||||
**Einwilligung.** `/einwilligung` hat keine Sitzung und deshalb kein CSRF-Token; ein fremdes Formular konnte jedem Besucher „alles akzeptiert“ ins Cookie schreiben. Die Route liest jetzt `Sec-Fetch-Site`, ersatzweise `Origin`/`Referer`, und merkt sich nur Entscheidungen von dieser Website; ohne Herkunftsangabe (alte Browser) bleibt sie beantwortbar. Der Rücksprung akzeptiert nur Pfade, die weder mit `//` noch mit `/\` beginnen – Browser lesen beide als protokollrelativ.
|
||||
|
||||
**Ausgabe.** JSON-LD wird mit `JSON_HEX_TAG|JSON_HEX_AMP` kodiert: ein `</script>` in Titel oder FAQ-Antwort hätte den Datenblock beendet und den Rest des Kopfes als Markup gerendert. Eigenes CSS erlaubt im verschachtelten Selektor kein Komma mehr (`&,body{…}` wurde zu `#id,body{…}`). Eine Fassung wiederherstellen prüft Skripte wie das Speichern. Das Archiv escaped alte Klartext-Bereiche wie die Seiten. Tabs und Bildbetrachter in Kopf, Fuß oder verknüpfter Sektion bekommen Skript und Richtlinie.
|
||||
|
||||
**Betrieb.** `%env(resolve:…)%` las prozentkodierte Zugangsdaten (`p%40ss`) als Parameter – der Container brach nach dem Installer bei jeder Anfrage ab. Ein eigener Prozessor `projectdir:` ersetzt nur `%kernel.project_dir%` (`src/Infrastructure/ProjectDirEnvProcessor.php`); `MAILER_DSN` kommt ohne Prozessor aus. Das Sitzungscookie folgt der Adresse der Verwaltung (`cookie_path: '/%kant.admin_path%'`) – nach `cms:admin:path` war vorher keine Anmeldung möglich. Downloads bleiben auch über Weiterleitungen bei HTTPS. Der Befehlsläufer gibt Composer und Git ein Zuhause (`HOME`, `COMPOSER_HOME`, `APPDATA`) und sucht die PHP-Kommandozeile, weil `PHP_BINARY` unter php-fpm der Server selbst ist. Ein unerwarteter Fehler im Updater nimmt den Rückweg statt den Wartungszustand stehen zu lassen.
|
||||
|
||||
**Editor.** `upgradeForType` hob das Schema auf die Fassung, in der ein Typ auftauchte, nicht auf die, die seine heutigen Eigenschaften brauchen – Menü (v16), Beitragsliste (v18) und Slider (v17) waren in älteren Dokumenten sofort ungültig. `controls()` gab nach jedem Speichern alle Knöpfe des Bedienfelds frei, auch die, die das Design-Bedienfeld bewusst gesperrt hatte („Gestaltung einfügen“ ohne Zwischenablage löschte die Gestaltung, „Als Vorlage speichern“ ohne Gestaltung löschte eine Vorlage gleichen Namens); jetzt merkt sich die Sperre, was sie geschlossen hat. „Ausrücken“ und ein Zug neben ein Element der obersten Ebene folgen denselben Regeln wie jeder andere Zug. Einfügen prüft das Ziel, bevor es das Schema hebt, sonst blieb bei einer Ablehnung ein leerer Verlaufsschritt. Ein Bild auf ein Akkordeon meldet sich statt zu werfen. Klartext unter v12 wird beim Öffnen nicht als HTML gelesen.
|
||||
|
||||
**Bewusst nicht in diesem Schritt:** Archivseiten auf dieselbe Aufbaukette wie Seiten (eigene Stile von Kopf/Fuß, Schriften, Sektionen), Formulare und Beitragslisten in Bereichen, doppelte Block-IDs bei mehrfach verknüpften Sektionen, die zwei Breakpoints (650/767 px), feste Farbwerte in dunklen Paletten, MySQL-Sicherung, `.htaccess` als Betreiberdatei beim Archiv-Update, Wachstum der Release-Caches, Druckstile, Canonical blätternder Listen – Umfang und Risiko rechtfertigen je einen eigenen Schritt (Rückstand in STATUS).
|
||||
|
||||
## ADR 099 – Mit oder ohne www entscheidet SITE_ORIGIN, nicht der Webserver (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „URL sollte aus env kommen, www oder ohne, die Seite sollte dann darauf reagieren.“ Bis dahin stand die Umleitung auskommentiert in `public/.htaccess` und musste von Hand zur Einstellung `SITE_ORIGIN` passen (LIVEGANG, Punkt 3). Zwei Stellen für eine Entscheidung laufen auseinander – und dann nennt jede Seite in Canonical, Sitemap und Feed eine Adresse, die weiterleitet.
|
||||
|
||||
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft.
|
||||
|
||||
**Bewusst nicht:** ein fremder Host wird nicht umgeleitet. Die Vorschauadresse des Hosters, eine IP-Adresse, `localhost`, eine Subdomain – alles bleibt erreichbar, auch wenn `SITE_ORIGIN` etwas anderes nennt. Sonst sperrte eine falsch eingetragene Adresse jeden aus, samt der Verwaltung, die man bräuchte, um es zu bemerken. Nur lesende Anfragen (GET, HEAD) werden umgeleitet: ein 301 macht aus einem POST ein GET und verwirft die Eingabe. Und HTTPS erzwingt die Anwendung nicht: hinter einem Proxy sieht PHP oft nur `http`, ohne konfigurierte vertrauenswürdige Proxys wäre das eine Schleife. Die drei Zeilen dafür bleiben in `.htaccess`, wo der Webserver die Verbindung wirklich kennt.
|
||||
|
||||
**Folgen:** `tests/CanonicalHostTest.php` (Paar, Fremdhost, Schema, Port, Methoden, Priorität), `tests/SiteOriginCommandTest.php`, je ein Fall in `CmsTest` (durch den Kernel) und `SystemScreenTest` (Anzeige und Hinweis). Die Startkonfigurationen der lokalen Installationen setzen `SITE_ORIGIN` auf ihre `127.0.0.1:<port>`-Adresse und sind davon nicht betroffen.
|
||||
|
||||
## ADR 098 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „bei Rows die Breite ändern – am besten per Drag and Drop im Editor.“ Bis dahin gab es nur gleich breite Spalten: die klassische Vorgabe `columns` (one/two/three/four) und die freie Eigenschaft `gridColumns` erzeugten beide `repeat(n, minmax(0, 1fr))`. Ein Symbol neben einem Text – die Karte aus seinem Beispiel – brauchte eine schmale und eine breite Spalte.
|
||||
|
||||
**Entscheidung:** Eine neue freie Stileigenschaft `columnWidths` am **Zeilenblock**, Typ `fractions`: bis zu zwölf Zahlen mit Leerzeichen, `2 1` oder `66.7 33.3`, je Gerät wie jede andere freie Eigenschaft (`StyleSchema::FRACTIONS`, Prüfung in `StyleCompiler::scalar()`, null ist ausgeschlossen). Die Anteile liegen an der Zeile und nicht an den Spalten, weil eine Grenze immer zwei Spalten zugleich verändert und die Reihenfolge der Kinder die Zuordnung ist. Kein neues Dokumentschema: die Eigenschaft fällt unter die freien Stile ab v10.
|
||||
|
||||
**CSS.** `StyleCompiler::columnTracks()` schreibt je Gerät `#id{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}`. Zwei Feinheiten: Fehlende Anteile (eine später eingefügte Spalte) bekommen den Durchschnitt, überzählige fallen weg, also passt die Regel immer zur Kinderzahl. Und die Desktop-Regel trägt eine ID und würde die Klasse `.d-mobile-columns-one` schlagen, mit der ein Handy die Spalten stapelt – deshalb bekommt ein Gerät, dessen wirksame Aufteilung `one` (klassisch) oder `gridColumns` 1 ist, unter seiner Medienabfrage `minmax(0,1fr)`. Die Abfragen sind die des Stilcompilers (1024/767 px), nicht die 650 px der klassischen Klassen; zwischen 651 und 767 px stapelt eine Zeile mit Anteilen also, wo sie ohne Anteile noch zwei Spalten hätte. Das ist gewollt: Querformat-Handys sind Handys.
|
||||
|
||||
**Editor.** `canvas-overlay.js` zeichnet für die gewählte Zeile – oder die Zeile um die gewählte Spalte – einen Griff auf jede Grenze zwischen zwei nebeneinander liegenden Spalten (`.ov-resize`, ein Knopf mit Beschriftung „Breite zwischen Spalte 1 und 2 ändern“). Ausgangswert ist die **gemessene** Breite der Spalten, nicht der gespeicherte Wert, damit der Griff auch bei geerbten oder klassischen Aufteilungen dort anfängt, wo die Spalten wirklich stehen. Während des Ziehens wird die Zeile im Vorschaudokument inline umgestellt (`grid-template-columns`), erst beim Loslassen entsteht **ein** Verlaufsschritt (`onResize` → `setStyle(row, device, 'columnWidths')`); die Vorschau lädt danach vom Server neu. Pfeiltasten verschieben um fünf Punkte und behalten den Fokus auf dem Griff über die Neuzeichnung hinweg. Kein Griff, wo die Spalten gestapelt sind, und keiner während ein Element gezogen wird. Im Design-Bedienfeld steht derselbe Wert als Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen), für alle, die lieber Zahlen tippen.
|
||||
|
||||
**Verworfen:** Anteile je Spalte (`span` aus zwölf) wie in Flatsome – zwei Werte je Grenze zu pflegen und eine Summe, die 12 ergeben muss, sind für ein Drag-Ergebnis unnötig streng; Anteile summieren sich beliebig. Eine eigene Klasse je Verhältnis (`1-3`, `2-3`) – deckt genau die Fälle ab, die jemand vorhergesehen hat, und das Ziehen erzeugt jeden anderen.
|
||||
|
||||
## ADR 097 – Der Einwilligungshinweis bekommt ein Gesicht, und der Nach-oben-Knopf weicht ihm aus (15.09.2026)
|
||||
|
||||
**Anlass.** Umut gefiel das Banner nicht: nicht gestaltbar, ein kantiger Kasten in Papierfarbe. Und der Nach-oben-Knopf „funktionierte auf dem Handy nicht“ – er war ausgeblendet, solange die Frage offen stand, und auf dem Telefon bleibt sie das oft.
|
||||
|
||||
**Gestaltung aus den Einstellungen, nicht aus dem Editor.** Der Hinweis ist kein Baustein einer Seite, sondern eine Frage der Website; seine Gestaltung gehört deshalb neben seinen Text unter Einstellungen → Einwilligung: Anordnung (`karte`, `leiste`, `ecke`), Form (`weich`, `rund`, `eckig`), Überschrift, vier Farben (Fläche, Schrift, Knopf, Schrift auf dem Knopf). Die Farben nehmen `#hex` oder `@schlüssel` aus der Palette wie der Nach-oben-Knopf; `Theme::cssColor()` löst beides für beide auf. Ausgegeben wird das als `:root{--kant-consent-…}` im gehashten Stylesblock (`Consent::styles()`), die Anordnung als Klasse. Die Form setzt zwei Radien zugleich – Fläche und Knöpfe gehören zusammen; zwei Regler dafür wären zwei, die man gegeneinander verstellt. Leer heißt weiterhin Papier, Tinte und Akzent des Themes.
|
||||
|
||||
**Was gleich bleibt, mit Absicht.** „Nur notwendige“ bleibt ein Rahmenknopf in der Schriftfarbe, genauso groß wie „Alle akzeptieren“, in derselben Zeile – die Farbe des Knopfes ist einstellbar, die Gleichwertigkeit der Antworten nicht. Kein Skript, keine Cookie-Wand.
|
||||
|
||||
**Archiv und Einwilligungsseite.** Beide trugen bisher keine Palette im Kopf. Sie bekommen jetzt Theme- und Hinweisvariablen als gehashten Stylesblock; die Einwilligungsseite erweitert ihre Richtlinie um genau diesen einen Hash.
|
||||
|
||||
**Der Nach-oben-Knopf.** Statt `display:none` bei offener Frage setzt er sich per Ankerpositionierung auf die Oberkante der Leiste (`anchor-name`, `bottom:calc(anchor(top) + 12px)`), wo sie die Ecke abdeckt: bis 799 px bei der Karte, auf jeder Breite bei Leiste und Ecke (`:has()`). Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Damit die Leiste als Anker taugt, steht sie in `page.html.twig` vor dem Knopf. Der reservierte Abstand unten wächst auf 320 px bis 620 px, weil die Leiste dort mit Standardtext bis 290 px hoch ist.
|
||||
|
||||
## ADR 096 – Schriften ausschließlich vom eigenen Server (15.09.2026)
|
||||
|
||||
**Entscheidung.** Die Ausgabe verlinkt kein Stylesheet von `fonts.googleapis.com` mehr, in keinem Pfad. Liegen die Schriftdateien lokal, liefert die Website sie selbst; liegen sie nicht vor, zeigt sie die eingestellte Ersatzschrift. `Theme::googleFontsUrl()` entfällt, und die Richtlinien nennen keinen fremden Schrifthost mehr – öffentlich `font-src 'self'`, in der Verwaltung zusätzlich `style-src 'self'`. Das Herunterladen der Dateien bleibt, wo es hingehört: ein Knopf im Designbereich beziehungsweise `cms:fonts:fetch`, einmalig und von einem Menschen ausgelöst.
|
||||
|
||||
**Warum.** ADR 037ff. hatten den Verweis auf Google als ausdrückliche Entscheidung Umuts stehen lassen, mit dem Selbsthosten als offenem Weg. Seit die Dateien lokal liegen können, ist der Rückfallpfad nur noch eine Falle: er greift genau dann, wenn niemand hinsieht, und gibt dann die IP-Adresse jedes Besuchers an einen Dritten weiter (LG München I, 3 O 17493/20). Vor allem betraf er die Editorvorschau, die `googleFonts` und **kein** `localFonts` bekam und die Schriften deshalb bei jedem Aufruf von Google holte – auf dem Bildschirm, an dem am meisten gearbeitet wird.
|
||||
|
||||
**Folge.** Eine Installation, deren Schriften fehlen, sieht anders aus als vorher: Ersatzschrift statt Wunschschrift. Das ist beabsichtigt und sichtbar gemacht – der Designbereich nennt die fehlenden Schnitte, und seit derselben Fassung sagt es ein Hinweisstreifen auf jedem Verwaltungsbildschirm mit dem Knopf daneben. Ein stilles „lädt halt von Google“ wäre die schlechtere Überraschung.
|
||||
|
||||
**Und das Vorabholen.** Es holte je Familie die ersten zwei Schnitte. Das Kant-IT-Theme nennt Lato 400, 700 und 900, die Überschriften stehen auf 800/900 und landen damit auf dem einzigen Schnitt ohne Vorabholen: er lud erst nach dem Stylesheet, und `font-display:swap` tauschte die Überschrift sichtbar. Jetzt wird jeder Schnitt des Themes vorab geholt, im lateinischen Grundbereich, und die Verweise stehen vor dem Stylesheet. Gemessen: alle Dateien sind vor dem ersten Zeichnen da.
|
||||
|
||||
## ADR 095 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (14.09.2026)
|
||||
|
||||
`LocalBusiness` nannte Name, Adresse, Telefon, E-Mail und Profile – nicht, wann jemand erreichbar ist und wo er arbeitet. Beides sind Angaben der Website, nicht einer Seite, deshalb stehen sie in den Einstellungen: `openingHours` als Liste `{day, opens, closes}` je Wochentag (Formular: sieben Zeilen mit Uhrzeitfeldern, leer heißt geschlossen; geprüft als `HH:MM`, Schließen nach Öffnen) und `areaServed` als Liste von Ortsnamen (ein Ort je Zeile, höchstens vierzig). `organisation()` fasst Tage mit gleichen Zeiten zu einer `OpeningHoursSpecification` zusammen – Mo–Fr 07:30–17:00 ist eine Angabe, nicht fünf – und gibt `areaServed` als Namensliste aus. Der Text auf der Website bleibt davon unabhängig; niemand soll zwei Wahrheiten pflegen müssen, aber das CMS erzwingt keine.
|
||||
|
||||
Eine FAQ ist dagegen Inhalt einer Seite. Ein Akkordeon ist nicht automatisch eine – die Kennzeichnung `props.faq` (Schema v23, Kästchen „Als FAQ auszeichnen“) macht aus den Einträgen Frage-Antwort-Paare einer `FAQPage`: Titel als Frage, die Textbausteine des Eintrags ohne Auszeichnungen als Antwort; Einträge ohne Text fehlen. Nur was sichtbar auf der Seite steht, steht auch in den Daten – Google verlangt genau das, und eine erfundene Antwort im Markup wäre der schnellste Weg zu einer Abstrafung. Kein Datenbankwechsel; beide Einstellungen fallen auf leere Listen zurück, das Akkordeon ohne Kennzeichnung bleibt ein Akkordeon.
|
||||
## ADR 094 – Drei Symbole mehr, ein Menü-Knopf mit eigener Gestalt, ein Nach-oben-Knopf ohne Scroll-Animation (14.09.2026)
|
||||
|
||||
**Symbole.** Der Satz hatte vierzehn Zeichnungen und keine für Menschen, Musik und Haus – drei Bilder, die eine Pflege- oder Vereinsseite als erstes braucht. Sie kommen als `people`, `music` und `home` hinzu, gezeichnet im selben Strich, weiter als reine Pfadangabe in `blocks/icon.html.twig`. Kein Schemawechsel: `ICONS` ist eine Werteliste, kein Bausteintyp; ein Dokument mit einem neuen Namen ist für ältere Fassungen ungültig, was für Werte dieser Art im Projekt bereits gilt. Ein Test hält Liste und Zeichnungen zusammen, denn ein fehlender Pfad ergibt ein leeres `<path d="">`: unsichtbar, ohne Fehlermeldung.
|
||||
@@ -387,7 +546,7 @@ Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe,
|
||||
|
||||
## ADR 017 – Formulare (Dokumentschema v8, 12.09.2026)
|
||||
|
||||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Absender (seit 1.15.1 wählbar, leer heißt Empfänger – manche Hoster stellen nur zu, was von einer Adresse der eigenen Domain kommt), Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||||
|
||||
**Kein CSRF-Token und keine Sitzung auf dem öffentlichen Weg.** Symfonys CSRF-Schutz braucht eine Sitzung, und eine Sitzung bedeutet ein Cookie auf jeder öffentlichen Seite; das vermeidet das Projekt bewusst. Die Route ist anonym und ändert nichts, was einem Besucher gehört: eine gefälschte Anfrage könnte höchstens eine Nachricht senden, und genau dagegen wirkt der Spamschutz. Die Verwaltung der Formulardefinitionen bleibt dagegen sitzungsgebunden und CSRF-geschützt.
|
||||
|
||||
@@ -435,7 +594,7 @@ Das Stylesheet steht aus Geschwindigkeitsgründen inline und ist **über seinen
|
||||
|
||||
`Theme` ist die Grundlage darüber: eine frei definierbare Farbpalette, Schriften und die Grundtypografie. Farben werden als `@schlüssel` referenziert und zu CSS-Variablen; eine Farbe zu ändern wirkt überall, wo sie benutzt wird. Die Überschriftengrößen entstehen aus einer modularen Skala, sodass eine Zahl die ganze Hierarchie verschiebt.
|
||||
|
||||
**Google Fonts auf ausdrückliche Entscheidung von Umut.** Das bedeutet eine Anfrage an Google bei jedem Seitenaufruf, mit den bekannten Folgen für Datenschutz und Ladezeit – der Entwicklungsplan verlangt eigentlich lokale Schriften. Umgesetzt ist es so, dass nur die tatsächlich benötigten Schnitte geladen werden und die Richtlinie ihre Hosts nur dann öffnet, wenn wirklich eine Google-Schrift eingestellt ist. Der Weg zum Selbsthosten bleibt offen: es wäre ein zusätzlicher Quelltyp neben `system` und `google`.
|
||||
**Schriften von Google, aber nicht zur Laufzeit.** Eine Familie lässt sich weiterhin bei Google aussuchen; ausgeliefert wird sie aber ausschließlich vom eigenen Server. Die Dateien holt ein Knopf im Designbereich einmalig her, und die Ausgabe verlinkt nie ein fremdes Stylesheet – Näheres in ADR 096.
|
||||
|
||||
Die alten Tokenwerte aus v2 bis v9 bleiben gültig und werden weiterhin gerendert. Blöcke, die noch welche tragen, zeigen sie im Editor in einem eingeklappten Bereich „Klassische Werte dieser Seite“, damit ältere Seiten bearbeitbar bleiben, ohne das neue Panel zu überladen.
|
||||
|
||||
@@ -1079,7 +1238,7 @@ Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funkt
|
||||
|
||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist?
|
||||
|
||||
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local`, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
|
||||
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local` (seit 1.7.0: `.env`, siehe ADR zur Umgebungsdatei), nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
|
||||
|
||||
**Das Ende ist das erste Konto, und es ist ein Zustand der Datenbank, keine Datei.** Eine Datei, die den Installer sperrt, ist eine Datei, die jemand löschen kann – und dann steht auf einer laufenden Website wieder ein Formular, das ein Administratorkonto anlegt. Gibt es ein Konto, antwortet jede Adresse unter `/install` mit 404.
|
||||
|
||||
|
||||
+3
-1
@@ -86,6 +86,8 @@ Abnahme: Die Startseite des Piloten ist ohne Code nachbaubar. Keine neue öffent
|
||||
|
||||
### D – Formulare — **erledigt am 12.09.2026, ohne Dateiuploads**
|
||||
|
||||
**Ergänzt am 15.09.2026 (ADR 101):** Feldtypen Zahl, Optionsfelder und Mehrfachauswahl, Schritte, Bedingungen auf frühere Auswahlfelder und eine Zusammenfassung vor dem Absenden – für den Preisfinder der DEEG-Website, als Ausbau des Kerns statt als Plugin. Dateiuploads bleiben offen.
|
||||
|
||||
*Braucht: nichts; das größte eigenständige Paket.*
|
||||
|
||||
Umgesetzt: Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, serverseitige Validierung mit Meldung je Feld, Einwilligungszeitpunkt, E-Mail-Versand mit Antwortadresse, gespeicherte Einsendungen in der Verwaltung und Spamschutz aus Honeypot, signiertem Zeitstempel und Rate-Limit. **Offen geblieben:** Dateiuploads (eigener nicht öffentlicher Ablageort und Typprüfung, Begründung in ADR 017), Löschen und Umbenennen von Formularen, Export und Aufbewahrungsfrist für Einsendungen. Der tatsächliche Versand ist ungeprüft und gehört zu Paket J.
|
||||
@@ -139,7 +141,7 @@ Umut hat den Umfang von G erweitert. Vier Punkte, in dieser Reihenfolge:
|
||||
|
||||
Abnahme: Kopf- und Fußbereich sind im Editor gestaltbar und öffentlich sichtbar. Eine verknüpfte Sektion auf zwei Seiten ändert sich an beiden Stellen gleichzeitig. Ein Textelement lässt sich formatiert und als HTML bearbeiten, und keine Auszeichnung außerhalb der Positivliste übersteht das Speichern. Eigenes CSS wirkt, eigenes Skript wirkt nur für Administratoren gesetzt und nur auf der veröffentlichten Seite. Die Verwaltung ist ohne Anleitung bedienbar.
|
||||
|
||||
### H – Plugins und Templates
|
||||
### H – Plugins und Templates — **erledigt am 16.09.2026** (ADR 102 und 103: Manifest, Schalter, geprüfte Bausteine, Dateien je Seite, Editoranbindung, Container, Einstellungen, eigene Adressen, Seitenvorlagen; Referenzplugin „Bildvergleich“. Designpakete in Plugins am selben Tag wieder entfernt – das Design gehört der Website, ADR 105. Bewusst nicht: PHP-Hooks und eigene Tabellen – Plugins bleiben Manifest plus Twig, ADR 103)
|
||||
|
||||
*Braucht: den Erhaltungsvertrag (erledigt).*
|
||||
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
|
||||
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
|
||||
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. Die Entscheidung steht an einer Stelle: `SITE_ORIGIN` in `.env` (`php bin/console cms:site:origin https://www.kant-it.de`). Die jeweils andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um; in `.htaccess` gibt es dafür keine Regel mehr. Der Bildschirm „System“ zeigt die eingetragene Adresse und warnt, wenn die Verwaltung gerade unter einer anderen läuft.
|
||||
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
|
||||
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
|
||||
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
|
||||
@@ -30,7 +30,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
|
||||
## Optional: die Verwaltung umbenennen
|
||||
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env.local` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
|
||||
## Optional: Spamschutz mit Cloudflare
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Plugins schreiben
|
||||
|
||||
Ein Plugin bringt eigene Bausteine, Vorlagen und Adressen in den Editor und auf die
|
||||
Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört der Website (ADR 105). Es ist ein Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional
|
||||
Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins**
|
||||
(Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
**Ein Plugin bringt keinen PHP-Code mit.** Alles ist Manifest plus Twig. Deshalb gibt es keine
|
||||
Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update
|
||||
des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen.
|
||||
|
||||
## Der Ordner
|
||||
|
||||
```
|
||||
plugins/
|
||||
vergleich/ ← Name des Plugins: Kleinbuchstaben und Ziffern, mit Buchstabe beginnend
|
||||
plugin.json ← das Manifest
|
||||
blocks/bildvergleich.html.twig
|
||||
blocks/galerie.html.twig
|
||||
routes/daten.json.twig
|
||||
assets/bildvergleich.css ← nur unter assets/ liegt, was die Website ausliefert
|
||||
assets/bildvergleich.js
|
||||
```
|
||||
|
||||
Das mitgelieferte Plugin ist die Vorlage zum Abschreiben: `plugins/vergleich` (Bausteine,
|
||||
Container, Einstellungen, Adresse, Seitenvorlage).
|
||||
|
||||
## Das Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "vergleich",
|
||||
"title": "Bildvergleich",
|
||||
"description": "Vorher und nachher in einem Bild.",
|
||||
"version": "1.1.0",
|
||||
"requires": "1.17.0",
|
||||
"settings": {
|
||||
"labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher" }
|
||||
},
|
||||
"blocks": {
|
||||
"bildvergleich": {
|
||||
"label": "Bildvergleich",
|
||||
"icon": "⇔",
|
||||
"template": "blocks/bildvergleich.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"script": "assets/bildvergleich.js",
|
||||
"props": {
|
||||
"before": { "kind": "media", "label": "Bild vorher" },
|
||||
"position": { "kind": "number", "label": "Startposition (%)", "min": 5, "max": 95, "default": 50 }
|
||||
}
|
||||
},
|
||||
"galerie": {
|
||||
"label": "Vergleichsgalerie",
|
||||
"template": "blocks/galerie.html.twig",
|
||||
"container": true,
|
||||
"children": ["vergleich.bildvergleich"],
|
||||
"props": { "columns": { "kind": "select", "label": "Spalten", "choices": { "2": "Zwei", "3": "Drei" } } }
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"abschnitt": { "label": "Vorher-Nachher-Abschnitt", "blocks": [ { "id": "s", "type": "section", "children": [ ] } ] }
|
||||
},
|
||||
"routes": {
|
||||
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `name` muss dem Ordnernamen entsprechen. Der Typ eines Bausteins ist `name.baustein`, hier
|
||||
`vergleich.bildvergleich`; so heißt er im Dokument und in der Ausgabe.
|
||||
- `version` in der Form `1.2.3`. Sie hängt an den Adressen der Dateien (`?v=1.1.0`), damit der
|
||||
Browser nach einem Update die neue Fassung holt.
|
||||
- `requires` (optional): die kleinste KantSites-Fassung. Passt sie nicht, erscheint das Plugin in
|
||||
der Verwaltung mit dem Grund und lässt sich nicht einschalten.
|
||||
- Ein Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage. Fehlt eine
|
||||
genannte Datei, ist das Manifest ungültig.
|
||||
|
||||
### Eigenschaften und Einstellungen (Schema)
|
||||
|
||||
Eigenschaften (`props` eines Bausteins) und Einstellungen (`settings` des Plugins) haben dasselbe
|
||||
Schema: eine Art (`kind`), eine Beschriftung (`label`), optional `help` und `default`.
|
||||
|
||||
| Art | Feld | Prüfung | Weitere Schlüssel |
|
||||
|---|---|---|---|
|
||||
| `text` | einzeilig | Text bis `maxLength` (Standard 200) | `maxLength` |
|
||||
| `textarea` | mehrzeilig | Text bis `maxLength` (Standard 2000) | `maxLength` |
|
||||
| `number` | Zahl | zwischen `min` und `max` | `min`, `max` |
|
||||
| `boolean` | Kästchen | an oder aus | – |
|
||||
| `select` | Liste | einer der Werte | `choices`: `{"wert": "Beschriftung"}` |
|
||||
| `url` | einzeilig | lokaler Pfad, `#sprungmarke`, `https://`, `mailto:`, `tel:` | `maxLength` |
|
||||
| `media` | Bild aus der Mediathek | Kennung eines Bildes oder leer | – |
|
||||
|
||||
Höchstens 30 Eigenschaften je Baustein. Unbekannte Eigenschaften in einem gespeicherten Dokument
|
||||
werden ignoriert (so kann ein Plugin eine Eigenschaft streichen), bekannte müssen passen, sonst
|
||||
lässt sich die Seite nicht speichern. Einstellungen bearbeitet der Administrator auf dem Bildschirm
|
||||
„Plugins“; sie werden genauso geprüft.
|
||||
|
||||
### Container
|
||||
|
||||
`"container": true` macht einen Baustein zum Behälter. `"children"` (optional) zählt auf, was
|
||||
hinein darf – Kernbausteine wie `text` oder Bausteine des Plugins; ohne die Liste darf alles hinein.
|
||||
Der Editor zeigt die Regel im Werkzeugkasten, der Server prüft sie beim Speichern.
|
||||
|
||||
### Seitenvorlagen (`presets`)
|
||||
|
||||
Fertige Bausteine, die der Editor als Ganzes einfügt – hinter den eingebauten Vorlagen mit demselben
|
||||
Knopf. `blocks` ist eine Liste von Bausteinen wie in einem Dokument (höchstens 64 KiB); Kennungen
|
||||
vergibt der Editor bei jedem Einfügen neu, du kannst also feste schreiben.
|
||||
|
||||
### Eigene Adressen (`routes`)
|
||||
|
||||
`path` hat die Form einer Seite (`/name` oder `/name/unter/seite`, Slugs) und darf nichts belegen,
|
||||
was der Kern beantwortet (`/admin`, `/media`, `/assets`, `/formular`, `/beitraege` …). `type` ist
|
||||
`html`, `json`, `text` oder `xml`. Eine veröffentlichte Seite mit derselben Adresse geht vor.
|
||||
Ausgeschaltet antwortet die Adresse mit 404; alles außer `html` trägt `noindex`.
|
||||
|
||||
### Was ein Plugin nicht kann: das Design
|
||||
|
||||
Es gibt kein `theme` im Manifest. Ein Plugin gestaltet seine eigenen Bausteine über deren
|
||||
Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der Website ändert
|
||||
nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort
|
||||
eingestellt wurde, geht durch ein Plugin verloren (ADR 105).
|
||||
|
||||
## Die Vorlagen
|
||||
|
||||
### Bausteine
|
||||
|
||||
Die Vorlage eines Bausteins bekommt genau sechs Dinge:
|
||||
|
||||
- `props` – die geprüften Eigenschaften, jede vorhanden, fehlende mit ihrem Vorgabewert
|
||||
- `settings` – die Einstellungen des Plugins, jede vorhanden
|
||||
- `children` – bei einem Container die fertig gerenderten Kinder (leer sonst); ausgeben mit
|
||||
`{{ children }}`
|
||||
- `block` – der Baustein selbst; `block.id` gehört als `id` an das Wurzelelement, sonst greifen
|
||||
eigenes CSS und die Editorauswahl nicht
|
||||
- `designClasses` – Klassen aus Sichtbarkeit, Design-Tokens und Einblendungen, an die `class`
|
||||
des Wurzelelements anhängen
|
||||
- `editing` – `true` in der Editorvorschau, für Hinweise wie „bitte ein Bild wählen“
|
||||
|
||||
Dazu eine Funktion: `kant_plugin_media(kennung)` liefert ein Bild der Mediathek als
|
||||
`{id, title, alt, variants: [{src, width, height}, …]}` (kleinste Fassung zuerst) oder `null`.
|
||||
|
||||
```twig
|
||||
{% set bild = props.before ? kant_plugin_media(props.before) : null %}
|
||||
<div id="{{ block.id }}" class="block-bildvergleich{{ designClasses }}" data-position="{{ props.position }}">
|
||||
{% if bild %}<img src="{{ (bild.variants|last).src }}" alt="{{ props.alt ?: settings.labelBefore }}">{% endif %}
|
||||
{{ children }}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Adressen
|
||||
|
||||
Die Vorlage einer Adresse bekommt `settings`, `query` (die Abfrageparameter mit sauberen Namen,
|
||||
gekürzt) und `route`. Für `json` und `xml` schreibt sie die Antwort selbst, etwa mit `json_encode`.
|
||||
|
||||
Was keine Vorlage bekommt: Datenbank, Einstellungen der Website, Anfrage. Sie läuft mit den
|
||||
Rechten der Anwendung – ein Sandkasten ist das nicht, und so wird es auch nicht genannt.
|
||||
|
||||
## Stylesheet und Skript
|
||||
|
||||
Beide werden nur auf Seiten geladen, auf denen der Baustein vorkommt, über
|
||||
`/plugin-assets/<name>/assets/...`. Die Content-Security-Policy erlaubt Skripte vom eigenen
|
||||
Ursprung, sobald die Seite eines braucht; Inline-Stile und Inline-Skripte bleiben verboten. Ein
|
||||
Skript setzt Werte deshalb über das CSSOM (`element.style.setProperty(...)`), nie über ein
|
||||
`style`-Attribut in der Vorlage. In der Editorvorschau wird das Stylesheet geladen, das Skript
|
||||
nicht – die Vorlage muss auch ohne Skript ein brauchbares Bild abgeben.
|
||||
|
||||
## Was der Kern übernimmt
|
||||
|
||||
- Speichern, Veröffentlichen, Fassungen, Undo/Redo – der Baustein ist ein gewöhnlicher Block.
|
||||
- Eigenes CSS je Block, Design-Tokens (Abstände, Ausrichtung, Ecken), Sichtbarkeit je Gerät,
|
||||
Einblendungen – alles über `designClasses` und die `id`.
|
||||
- Der Editor: Eintrag im Werkzeugkasten unter „Erweiterungen“, Eigenschaftenpanel aus dem
|
||||
Schema, Container-Regeln, Vorlagen, Vorschau über den Server.
|
||||
- Der Erhaltungsvertrag: ist das Plugin aus oder fehlt es, bleibt der Baustein im Dokument, der
|
||||
Editor zeigt einen Platzhalter, die Website einen HTML-Kommentar. Nichts geht verloren.
|
||||
|
||||
## Updates
|
||||
|
||||
Mitgelieferte Plugins liegen im Releasearchiv und werden beim Archiv-Update Datei für Datei
|
||||
aktualisiert. Ein selbst installiertes Plugin bleibt dabei stehen, weil sein Ordner im Archiv
|
||||
nicht vorkommt. Beim Git-Update ist ein fremder Ordner unter `plugins/` nicht von Git verwaltet;
|
||||
`git checkout` lässt ihn in Ruhe. Die Einstellungen liegen in der Datenbank und überleben beides.
|
||||
+15
-1
@@ -1,5 +1,19 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: DEEG Immobilien & Hausverwaltung gemessen, 15.09.2026 (1.17.0)
|
||||
|
||||
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der fünften Referenzwebsite „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (`var/immobilien-deeg.db`, Startkonfiguration `immobilien-deeg-prod`, Port 8095), drei Adressen: `/`, `/impressum`, `/datenschutz`. Ergebnis: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**; Startseite LCP 1,8 s mobil und 0,5 s Desktop (das Startfoto als `img` im Hero), CLS 0, TBT 0 ms. Ladebudget der Startseite 157 KB mit 6 Anfragen und 11 Datenbankabfragen. Berichte unter `reports/lighthouse-immobilien-deeg/`. Voraussetzung für die 100 bei Barrierefreiheit: Blau mit weißer Schrift als `#0076b6` statt Logo-Blau `#0081c7`, kleine blaue Schrift `#006aa3`, Copyright-Zeile mit eigener Farbe. Nach dem Umbau der Bewertungsanfrage zum dreistufigen Preisfinder (ADR 101, zweites Skript `form-steps.js` auf der Startseite) erneut gemessen: `/` mobil und Desktop weiterhin in allen fünf Kategorien 100, Ladebudget 161 KB mit 7 Anfragen.
|
||||
|
||||
## Ergänzung: Ihr Malerservice Drews gemessen, 15.09.2026 (1.16.0 + zwei unveröffentlichte Korrekturen)
|
||||
|
||||
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der vierten Referenzwebsite „Ihr Malerservice Drews“ (`var/maler-drews.db`, Port 8092), sechs Adressen: `/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`. Ergebnis: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 – Startseite 99 mobil und 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, alle übrigen 100. Der Rest zur 100 ist überall das Foto hinter dem Seitenkopf als CSS-Hintergrund (LCP mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte unter `reports/lighthouse-maler-drews/`.
|
||||
|
||||
Drei Messrunden führten dorthin. Die erste zeigte Barrierefreiheit 91–93: das Gold `#c6a36b` der Vorlage erreicht auf Weiß 2,4:1 (Kleinzeilen, Datum, Links, weiße Buttonschrift auf Gold), die Beitragslisten sprangen von H1 auf H3, der Copyright-Link hob sich nur durch Farbe ab, und der Nach-oben-Knopf lag außerhalb jeder Landmarke (axe „region“, auf jeder Seite). Behoben durch `@gold-text` (`#8a6a2f`, 5,0:1) für Text auf hellem Grund, dunkle Buttonschrift (6:1), eine unsichtbare H2 vor den Listen, Unterstreichung, und im CMS ein `nav` um den Knopf. Die zweite Runde ließ noch den aktiven Menüpunkt (Gold auf Weiß) und graue Formularbeschriftungen (`#999`, 2,8:1) übrig. Für die Leistung: nur das erste Hintergrundbild einer Seite wird vorgeladen – der Slider der Startseite liegt eine Ebene tiefer, deshalb trägt der äußere Abschnitt dasselbe Foto; jede im Design genannte Schriftstärke wird vorgeladen, deshalb Lato nur 400/700 (ohne das 300 der Vorlage, 23 KB weniger vor dem größten Bild); Kacheln und Galeriebilder bekamen feste Breiten, weil ohne sie eine 270 px breite Kachel die 2400er Datei lud (108 KB Ersparnis auf der Startseite laut `image-delivery`).
|
||||
|
||||
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px, nach dem Durchscrollen und zwei Sekunden Wartezeit) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); die erste Runde hatte 58.
|
||||
|
||||
Maschinenlesbar: `llms.txt` (8 Seiten, 31 Beiträge mit Beschreibung), `robots.txt`, `sitemap.xml` (41 Adressen), `feed.xml`, `LocalBusiness` mit Adresse, Telefon, Öffnungszeiten Mo–Do/Fr, Einsatzgebiet und Instagram als `sameAs`. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, ein echtes Touchgerät, das tatsächliche Hosting.
|
||||
|
||||
## Ergänzung: Elektro Bernhard gemessen, 14.09.2026 (1.12.1)
|
||||
|
||||
Lighthouse **13** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der Referenzwebsite Elektro Bernhard (`var/elektro-bernhard.db`, Port 8090), vier Adressen: `/`, `/leistungen`, `/leistungen/notdienst`, `/kontakt`. Ergebnis nach den Korrekturen: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung überall 100 bis auf die mobile Startseite mit **99** (LCP 2,1 s durch das Hero-Foto als Hintergrund unter Mobildrosselung; CLS 0, TBT 30 ms). Berichte unter `reports/lighthouse-elektro-bernhard-2/`; der erste Lauf davor (`reports/lighthouse-elektro-bernhard/`) zeigte Barrierefreiheit 95–97.
|
||||
@@ -255,7 +269,7 @@ Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der feste
|
||||
- **154 PHP-Tests / 563 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **15 JavaScript-Modelltests**.
|
||||
- Ein v10-Dokument mit Palettenfarbe, eigenem Farbwert, Abständen je Seite, Ecken je Ecke, Schatten, Buchstabenabstand, Hover-Zustand und eigenem Mobilwert wird Byte für Byte gespeichert und zu blockgenauem CSS: Regel für den Block, eigene Regel für `:hover,:focus-within` und eine Media Query für die schmale Breite.
|
||||
- **Vierzehn abgewiesene Eingaben schreiben nichts.** Darunter gezielt die Ausbruchsversuche: eine Farbe als Funktion (`rgb(1,2,3)`), eine Länge als Ausdruck (`calc(1px + 2px)`), eine angehängte Deklaration (`18px;position:fixed`), ein Schriftschlüssel mit Anführungszeichen und ein Hintergrundbild als Dateipfad. Jeder dieser Werte scheitert am verankerten Muster seines Typs, bevor er gespeichert wird.
|
||||
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ohne eingestellte Google-Schrift wird kein fremder Host erlaubt und keiner kontaktiert.
|
||||
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ein fremder Schrifthost wird nie erlaubt und nie kontaktiert: Schriften liefert seit 1.14.0 ausschliesslich der eigene Server (ADR 096).
|
||||
- **Acht ungültige Themes** werden abgewiesen, ohne das gespeicherte Theme zu verändern; danach liefert das Theme weiterhin die Voreinstellungen.
|
||||
- Im Editormodell geprüft: Vererbung, einzelnes Zurücksetzen, Entfernen leerer Reste, Kopieren ohne geteilte Objekte und vollständiges Undo.
|
||||
- **Im Browser geprüft wurde nur der Aufbau, nicht die Bedienung.** Im laufenden Editor wurde über das DOM bestätigt, dass das Designpanel aus dem Serverschema entsteht: sieben Gruppen, 58 Felder, 11 Farbwähler, 12 Schieberegler, 34 Einheitenauswahlen. **Es wurde kein Gestaltungsvorgang von Hand ausgeführt**, keine Tastaturbedienung erprobt und keine axe- oder Lighthouse-Messung gemacht. Für ein Werkzeug dieser Größe ist das ein erheblicher Vorbehalt.
|
||||
|
||||
+370
@@ -1,5 +1,373 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.19.0 veröffentlicht (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Ja committe das bitte und push mit neuem Release.“ Die Rücknahme der Designpakete geht als 1.19.0 heraus.
|
||||
|
||||
**Dabei aufgefallen:** Die Commits `c8a8fa7` und `53fb713` (v1.18.0) hatten den Arbeitsstand dieser Sitzung schon mitgenommen – in v1.18.0 fehlen `applyTheme()` und `plugins/nachtblau` bereits, während die mitgelieferte Dokumentation Designpakete noch beschreibt. 1.19.0 bringt beides wieder zur Deckung. Der Code ändert sich zwischen 1.18.0 und 1.19.0 nur um die Rollenmatrix des Tests.
|
||||
|
||||
**Geprüft:** `phpunit` 572 Tests in einem Prozess grün (PHP 8.4), `npm test` 34, Twig-Lint 53 Dateien, php-cs-fixer ohne Befund, `npm run test:editor` bestanden (Bildschirm „Plugins“ ohne Designpaket-Knopf, Barrierefreiheitsprüfungen durch). DEEG-Startseite im Browser gesichtet: eigene Palette wieder da. Danach Commit, Tag `v1.19.0`, Push nach `origin` und Releasearchiv.
|
||||
|
||||
## Designpakete in Plugins entfernt (16.09.2026)
|
||||
|
||||
**Anlass:** Umut auf der DEEG-Installation: „Ich habe das Designplugin aktiviert und nach dem Deaktivieren blieben die Farben.“ Nicht der Schalter war schuld, sondern „Designpaket übernehmen“ – der Knopf schreibt die Palette des Plugins endgültig in die Website. Entscheidung: die Funktion fällt weg, statt sie abzusichern (ADR 105).
|
||||
|
||||
**Gemacht.** DEEG-Design aus `scripts/seed-immobilien-deeg.php` wieder eingespielt (Logo-Blau, Anthrazit, Arial, eckige Buttons; Inhalte und Stilvorlagen waren nie betroffen). Aus dem Kern entfernt: `PluginRegistry::themePackage()` und `applyTheme()`, `theme` im Manifest und in der Pluginstruktur, Route `admin_plugin_theme` samt `Theme`-Abhängigkeit von `PluginAdminController`, der Knopf und die Designpaket-Zeile auf dem Bildschirm „Plugins“, der Ordner `plugins/nachtblau`. Ein Manifest braucht jetzt mindestens einen Baustein, eine Adresse oder eine Vorlage.
|
||||
|
||||
**Geprüft:** `tests/PluginTest.php` hat statt des Designpaket-Tests jetzt „Ein- und Ausschalten lässt das Design in Ruhe“ (Palette vorher und nachher gleich, Dokument unverändert); 8 Tests grün.
|
||||
|
||||
## Fassung 1.18.0 veröffentlicht (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bau eine neue Version bitte, Commit, Push und Release.“ Der Stand aus DEEG-Nachbau, Formularschritten, Pluginsystem und den Rückständen der Gesamtpruefung geht als 1.18.0 heraus.
|
||||
|
||||
**Vorher gefunden und behoben:** Der komplette `phpunit`-Lauf in einem Prozess starb reproduzierbar nach 80 Sekunden mit „Maximum execution time exceeded“, an wechselnden Stellen. Ursache war nicht ein Test, sondern `TaskWorkerSubscriber`: `set_time_limit()` **setzt** die Grenze, es verlängert sie nicht. Auf einem Prozess ohne Grenze – Konsole, Testlauf, Hoster mit `max_execution_time = 0` – machte `set_time_limit($budget + 60)` aus „unbegrenzt“ achtzig Sekunden, und der Prozess starb später irgendwo weit weg vom Aufgabenplan. Derselbe Fall stand im Takt der Verwaltung (`TaskController::tick()`, 120 Sekunden). Beide Stellen heben die Grenze jetzt nur an und senken sie nie. Einzeln liefen alle Testdateien immer durch, deshalb war das bisher nie aufgefallen.
|
||||
|
||||
**Geprüft:** neuer Test in `tests/SchedulerTest.php` („der Worker kürzt einen Prozess ohne Zeitgrenze nicht“): faellige Aufgabe läuft, `max_execution_time` bleibt 0. `phpunit` 572 Tests, 4768 Zusicherungen in **einem** Prozess grün (vorher Abbruch), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer. Danach Commit, Tag `v1.18.0`, Push nach `origin` und Releasearchiv.
|
||||
|
||||
## Rückstände der Gesamtprüfung abgearbeitet (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bitte umsetzen“ zur Liste aus ADR 100. ADR 104 beschreibt jeden Punkt; hier die Kurzfassung.
|
||||
|
||||
- **Archivseiten** laufen durch `renderPage()` (eigenes Dokument aus Kopfabschnitt und blätternder Beitragsliste): Kopf/Fuß mit Stilen, Sektionen, eigenes CSS, Bündel, Nach-oben, Einwilligung, Skripte. `templates/archive.html.twig` gelöscht.
|
||||
- **Formulare, Beitragslisten, Brotkrumen in Bereichen und Sektionen** werden aufgelöst; Meldungen eines abgewiesenen Formulars finden den Block über die Formularadresse. Skripte in Bereichen laufen als `/assets/bereich/<id>.js`.
|
||||
- **Doppelte Block-IDs** bei mehrfach verknüpfter Sektion: `PartialRepository::expandRepeatedLinks()` erzeugt Kopien mit eigenen Kennungen (Website und Editorvorschau).
|
||||
- **Breakpoints** überall 1024/767 (design.css neu gebaut, site.css, enhancements.css, preview.css, editor.css, Editortext).
|
||||
- **Farben**: `color-scheme`, Skip-Link, Aufruf-Button, Fehlerfarben, `.tone-ink`-Text aus der Palette; `Theme::css()` setzt `--k-scheme` und die Fehlerfarben nach Leuchtdichte des Papiers; Formularbutton dunkelt in die Tinte. Druckstile neu.
|
||||
- **Betrieb**: Sicherung/Wiederherstellung auf MySQL (mysqldump/mysql), Git-Updater sichert und rollt über `Backup`, Sperre `var/update.lock` für beide Wege, Archiv als Datei geladen, `.htaccess` zusammengeführt (HTTPS/HSTS bleiben an), `var/update/` und alte Caches nach dem Update aufgeräumt, CI-MySQL-Job mit `cms:health`, `cms:backup` und `phpunit`.
|
||||
- **SEO**: Canonical mit `?seite=n` und `rel=prev/next` bei blätternden Beitragslisten; Druckstile.
|
||||
- **Editor**: Vorschau mit eigenem CSS, Bereichs-/Sektionsstilen und Formularen; Kontaktdaten, Formular, verknüpfte Sektion und Embed direkt nach dem Einfügen gültig (leer = nichts gewählt); bereinigter Rich Text sofort im Editor (`EditorHistory.adopt()`).
|
||||
- **Kleines**: `legal.label` begrenzt, Docblocks in `PageController` gerückt.
|
||||
|
||||
**Geprüft:** neue `tests/BacklogTest.php` (7 Tests, 55 Zusicherungen: Archiv mit Fuß, Sektion, Nach-oben, Canonical/prev/next, Kategorie mit BreadcrumbList; blätternde Liste auf einer Seite; Formular im Fuß samt 422-Wiederanzeige; Sektion zweimal verknüpft; leere Bausteine; dunkle Palette; Sperre und .htaccess-Zusammenführung). Bestehende Tests angepasst: leerer Kontaktblock ist gültig, Schrittfolge des Updaters mit „Sperre“, `ReleaseOnDisk` kennt `toFile()`. Komplette Kette grün: `phpunit` 571 Tests (PHP 8.4), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer auf allen geänderten Dateien, `npm run test:editor` bestanden (Vorschau mit Formularen und eigenem CSS), DEEG `npm run test:browser` bestanden (Startseite 168 KB, 11 Anfragen – die vier Optionsbilder des Preisfinders kamen dazu), Lighthouse `/` Desktop überall 100, mobil Leistung 99 (die vier Piktogramme laden mit; Rest 100). Nicht committet.
|
||||
|
||||
## Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen, Designpakete (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Dann mach H.“ Zweiter Teil des Pluginsystems (ADR 103). Grundentscheidung: ein Plugin bleibt Manifest plus Twig, ohne PHP – deshalb keine Hooks und keine eigenen Tabellen, dafür alles, was sich ohne Installationscode sicher übergeben lässt.
|
||||
|
||||
**Gebaut.** `PluginRegistry` liest jetzt auch `settings` (Schema wie Eigenschaften, gespeichert in der neuen Spalte `plugin.settings`, Migration `20260916000100`), `container`/`children` am Baustein, `routes` (Pfad wie eine Seite, Vorlage, Antwortart html/json/text/xml, Kernadressen gesperrt), `presets` (Bausteinlisten bis 64 KiB) und `theme` (Palette, Schriften, Grundwerte, Buttons). `block.html.twig` rendert die Kinder eines Containers selbst und reicht sie der Vorlage als `children`, dazu `settings`; `DocumentValidator` weist Kinder in Nicht-Containern und nicht erlaubte Kinder ab. `PageController::page()` beantwortet eine Plugin-Adresse, wenn keine Seite den Pfad hat (vor Weiterleitung und 404), mit Einstellungen und gesäuberten Abfrageparametern, Schutzkopfzeilen, ohne Skript, außer html mit `noindex`. Editor: `registerPlugins(list, presets)` kennt Container und erlaubte Kinder (`canContain`), `insertPreset('plugin:…')` fügt Vorlagen mit frischen Kennungen ein, der Werkzeugkasten zeigt sie hinter den eingebauten. Bildschirm „Plugins“: Einstellungsformular je aktivem Plugin, Adressen verlinkt, „Designpaket übernehmen“ (`Theme::save()`, Inhalte bleiben), beide Wege in der Rollenmatrix. Referenz: `plugins/vergleich` 1.1.0 (Einstellungen, Vergleichsgalerie als Container nur für Bildvergleiche, Vorlage „Vorher-Nachher-Abschnitt“, Adresse `/vergleich/daten`), neu `plugins/nachtblau` als reines Designpaket. Alle fünf lokalen Installationen migriert. `docs/PLUGINS.md` beschreibt das vollständige Manifest.
|
||||
|
||||
**Geprüft:** `tests/PluginTest.php` jetzt 8 Tests: Einstellungen (Vorgaben, Umwandlung aus dem Formular, Abweisung, Wirkung in Vorlage und Adresse, 404 bei ausgeschaltetem Plugin), Container (Kinder gerendert, Stylesheet einmal, fremdes Kind und Kinder im Nicht-Container abgewiesen, Editor-Definitionen und Vorlagen), Designpaket (übernommen, Buttons, Dokument unverändert, nur eingeschaltet, Fehler ohne Paket). `editor-model.test.mjs` prüft registrierte Container, Vorgaben, `canContain` und Vorlagen mit frischen Kennungen (34 grün). `EditorSecurityTest` mit den neuen Routen grün. `npm run test:editor` um Einstellungen speichern, die Adresse `/vergleich/daten`, die Plugin-Vorlage im Werkzeugkasten und den Container erweitert (Bildvergleich landet in der Galerie, ein Text daneben) und bestanden; ein Lauf davor blieb beim Neuladen des Editors bei „Wird geladen“ hängen, der nächste lief ohne Änderung durch – der Check protokolliert jetzt jede Konsolenfehlermeldung, damit so etwas beim nächsten Mal einen Grund hat. Komplette Kette grün: `phpunit` 565 Tests (PHP 8.4), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer auf allen neuen und geänderten Dateien. Nicht committet.
|
||||
|
||||
## Pluginsystem, erste Fassung – Manifest, Schalter, geprüfte Bausteine, Referenzplugin „Bildvergleich“ (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Plugin hätte ich gerne aber umgesetzt. Also Möglichkeit. … Dann Plugin so wie du es gesagt hast, hört sich gut an.“ Arbeitspaket H, erster Teil (ADR 102).
|
||||
|
||||
**Gebaut.** `src/Plugin/PluginRegistry.php` liest `plugins/<name>/plugin.json` (Titel, Fassung, `requires`, Bausteine mit Beschriftung, Symbol, Vorlage, Stylesheet, Skript und Eigenschaftenschema mit sieben Arten), meldet kaputte Manifeste mit Grund und hält den Schalter in der neuen Tabelle `plugin` (Migration `20260915000100`, in `TestSchema`). `DocumentValidator` prüft die Eigenschaften eines Plugin-Bausteins beim Schreiben gegen das Schema (`validate(..., writing: true)` an allen Schreibstellen), beim Lesen nicht – ein später eingeschaltetes Plugin macht keine Seite unlesbar. `templates/blocks/block.html.twig` rendert Bausteine aktiver Plugins über `@plugins/...` mit den erneut geprüften `props` (`src/Twig/PluginExtension.php`: `kant_plugin_block`, `kant_plugin_props`, `kant_plugin_media`); passt etwas nicht, bleibt der Kommentar. `PluginAssetController` liefert nur Dateien aktiver Plugins unter `assets/` mit erlaubter Endung; `PageController` verlinkt sie je Seite und öffnet die Richtlinie für ein Skript nur dann. Design-Tokens gelten jetzt auch für Plugin-Typen. Editor: der Dokumentaufruf liefert die Definitionen (`plugins`), `registerPlugins()` in `editor-model.js` trägt sie ein (Gruppe „Erweiterungen“, Vorgaben, Schema v4), `renderPluginFields()` in `editor.js` baut das Panel aus dem Schema, ein Bild über die Mediathek; die Vorschau lädt das Stylesheet, kein Skript. Verwaltung: Bildschirm „Plugins“ (Administrator) mit Ein/Aus, CSRF, in Navigation und Rollenmatrix. Release/Update: `plugins/` im Archiv und beim Archiv-Update Datei für Datei übernommen. Entwicklerdoku `docs/PLUGINS.md`.
|
||||
|
||||
**Referenzplugin `plugins/vergleich`:** Baustein `vergleich.bildvergleich` – zwei Bilder aus der Mediathek, ein Regler dazwischen (natives Bereichsfeld, Skript setzt nur `--pos` über das CSSOM), Beschriftungen, Startposition, Ecken. Ohne Skript steht der Vergleich bei der Startposition.
|
||||
|
||||
**Geprüft:** `tests/PluginTest.php` (5 Tests, 50 Zusicherungen): Manifest lesen und schalten (Schalter überlebt eine neue Registry), drei kaputte Manifeste mit Grund und ohne Schalter, Eigenschaftenprüfung nur bei aktivem Plugin und nur beim Schreiben, Rendering mit Stylesheet/Skript/CSP gegen Kommentar bei ausgeschaltetem Plugin, Asset-Route (Typ, `immutable`, 404 für Manifest, Vorlagen, `..` und ausgeschaltete Plugins), Design-Tokens und eigenes CSS am Plugin-Baustein. `EditorSecurityTest` mit den Plugin-Routen grün (38 Tests), `npm test` 33 grün, Twig-Lint aller Vorlagen einschließlich `plugins/`. Die vier lokalen Installationen sind migriert. `npm run test:editor` um ein Plugin-Szenario erweitert und bestanden: Bildschirm „Plugins“ mit axe (die Überschriftenfolge dort war zuerst falsch, h3 unter h1), Einschalten, Baustein aus dem Werkzeugkasten, Panel aus dem Schema, zwei Bilder über die Mediathek, Vorschau mit Stylesheet und ohne Skript, Speichern und Veröffentlichen, Website mit Skript und `script-src 'self'`, Ausschalten lässt den Baustein im Dokument und zeigt den Kommentar (`reports/editor/plugins.png`, `plugin-block.png`). Danach die komplette Kette: `phpunit` zuerst mit drei Befunden – der feste Twig-Pfad `paths` in `twig.yaml` brach jede Installation ohne Ordner `plugins/` (Installer-Tests), und die Vollständigkeitsprüfung des Archiv-Updaters kannte `plugins/` nicht. Jetzt registriert `PluginExtension` den Namensraum `@plugins` zur Laufzeit nur, wenn der Ordner existiert, und der Test führt `plugins/` als Datei-für-Datei-Ordner wie `public/`. Danach `phpunit` vollständig grün (562 Tests, 4664 Zusicherungen, PHP 8.4), Twig-Lint mit `plugins/`, php-cs-fixer auf allen neuen und geänderten Dateien.
|
||||
|
||||
**Nicht gebaut:** Container-Bausteine, eigene Routen und Einstellungen, Tabellen, Hooks, Ressourcen in der Verwaltung, Templates. Kein Sandkasten – Plugin-Vorlagen laufen mit den Rechten der Anwendung, so steht es auf dem Bildschirm. Nicht committet.
|
||||
|
||||
## Formulare mit Schritten, Bedingungen und Zusammenfassung – der DEEG-Preisfinder (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Plugins funktionieren noch nicht? Was fehlt denn? Ich hätte gerne kein Kontaktformular weiteres, sondern so ein ausgebautes Form.“ Dann: „Okay, dann erweitere den Kern.“ Gemeint ist der Preisfinder der laufenden DEEG-Website: Objektart wählen, Eckdaten je Objektart, Kontakt, Zusammenfassung.
|
||||
|
||||
**Kern (ADR 101):** Drei neue Feldtypen – Zahl (Unter-/Obergrenze, Einheit), Auswahl als Optionsfelder, Mehrfachauswahl. Ein Feld „Neuer Schritt“ eröffnet einen Abschnitt (Beschriftung = Titel, Hilfetext = Einleitung). Jedes Feld und jeder Schritt kann eine Bedingung tragen: gilt nur, wenn ein früheres Auswahlfeld einen der genannten Werte hat. Einstellung „Zusammenfassung vor dem Absenden“. Höchstens 40 statt 25 Felder. Der Server prüft die Bedingungen selbst (`FormRepository::check()`): ein Feld, das nicht galt, wird weder verlangt noch gespeichert, auch wenn der Browser einen Wert schickt; beim Speichern der Definition muss jede Bedingung auf ein früheres Auswahlfeld mit passenden Optionen zeigen. Bestehende Formulare bleiben ohne Migration gültig (`present()` ergänzt die Schlüssel).
|
||||
|
||||
**Öffentlich:** `templates/blocks/form.html.twig` rendert Schritte als `fieldset`/`legend`, Gruppen für Options- und Mehrfachauswahl, `type="number"` mit Grenzen und Einheit, Bedingungen als `data-show-if`/`data-show-values`. Ohne JavaScript steht das Formular vollständig auf der Seite. Neu `public/assets/form-steps.js` (nur auf Seiten, deren Formular es braucht, `FormRepository::needsScript()`; die CSP erlaubt es dann): Fortschritt, „Zurück“/„Weiter“ mit Browserprüfung je Schritt, Felder hinter nicht zutreffenden Bedingungen deaktiviert statt nur versteckt, Zusammenfassung als letzter Schritt mit „Ändern“, nach einer Serverablehnung der erste Schritt mit Fehler. `site.css`: Stile für Schritte, Fortschritt, Gruppen, Zahl mit Einheit, Zusammenfassung; `.block-form [hidden]` schlägt jetzt `display:grid`/`inline-flex`. E-Mail: Schritte als Zwischenüberschrift, Felder, die nicht galten, bleiben weg.
|
||||
|
||||
**Bildauswahl (Nachtrag):** Umut meinte mit „Upload“ eine Bildauswahl wie im Solar-Rechner von Elektro Bernhard (Bildkarten statt Kästchen). Optionsfelder und Mehrfachauswahl tragen jetzt je Option ein Bild aus der Mediathek; sobald eine Option eines hat, wird die Auswahl auf der Website zu anklickbaren Karten mit Bild und Text, das Kästchen bleibt darin für Tastatur und Vorlesesoftware. Im DEEG-Preisfinder hat die Objektart vier Piktogramme (`var/immobilien-deeg-assets/option-*.png`, mit GD gezeichnete Platzhalter, in der Mediathek gegen Fotos tauschbar). Test `testAChoiceWithPicturesBecomesClickableCards`, dazu zwei abgelehnte Zuordnungen.
|
||||
|
||||
**Verwaltung:** Feldkarte mit Untergrenze/Obergrenze/Einheit (bei Zahl), Optionen bei Liste/Optionsfeldern/Mehrfachauswahl, „Bild je Option“ als Liste der Mediathek, Bedingung („Nur zeigen, wenn das Feld … einen dieser Werte hat“); Zustellung mit Schalter „Zusammenfassung vor dem Absenden zeigen“; Vorschau zeigt Schritte, Optionsfelder, Kästchen, Einheit und Bedingungen. Alles ohne Skript bedienbar.
|
||||
|
||||
**DEEG:** Das Formular `bewertung` im Aufbauskript ist jetzt der Preisfinder: Schritt 1 Objektart (Wohnung, Haus, Mehrfamilienhaus, Grundstück) und Anlass (Verkaufen, Vermieten); Schritt 2 Eckdaten je Objektart – Wohnfläche, Grundstücksfläche, Zimmer, Wohneinheiten, Baujahr, Zustand, Einbauküche, Aufzug, Parkmöglichkeit, Bebaubarkeit, jeweils hinter Bedingungen; Schritt 3 Lage und Kontakt; dann die Zusammenfassung. Die Feldliste folgt dem Preisfinder von immobiliendeeg.de (Wohnung-Seite, 15.09.2026). Das Kontaktformular bleibt, wie es war.
|
||||
|
||||
**Geprüft:** neue `tests/FormStepsTest.php` (5 Tests, 75 Zusicherungen): Normalisierung beim Speichern, zehn abgelehnte Definitionen mit Begründung, Bedingungen in `check()` (Grundstück ohne Wohnfläche, Wohnung mit Pflicht, Zahl mit Komma, Grenzen, fremde Optionen), Rendering samt Skript und CSP (und ein einfaches Formular weiter ohne Skript, `script-src 'none'`), POST mit Speicherung nur der geltenden Felder und 422-Wiederanzeige mit erhaltenen Kreuzen. Im Browser (Port 8094): drei Schritte durchgespielt, Felder je Objektart, Zusammenfassung mit allen Werten, Absenden mit Erfolgsmeldung, Einsendung in `form_submission` mit leeren Feldern für alles, was nicht galt; Grundstück zeigt nur zwei Eckdatenfelder, „Weiter“ hält bei leerem Pflichtfeld. Komplette Kette: `phpunit` 556 grün (PHP 8.4), `npm test` 33 grün, `npm run test:editor` vollständig bestanden (darunter die axe-Prüfung der neuen Formularverwaltung), Twig-Lint ohne Befund, php-cs-fixer auf den geänderten Dateien (die vier Befunde in `install.php` und den drei älteren Aufbauskripten sind vorbestehend). DEEG nach dem Umbau: `npm run test:browser` bestanden (Startseite jetzt 2 Skripte – Einblendungen und Formular –, 161 KB), Lighthouse `/` mobil und Desktop weiterhin überall 100.
|
||||
|
||||
**Browsercheck angepasst:** `scripts/browser-check.mjs` erlaubte bisher höchstens ein Skript je Seite. Das war schon vor dem Formularskript zu eng – Slider, Reiter, Einblendungen, Bildbetrachter und Nach-oben sind je eine eigene Datei –, fiel aber erst jetzt auf, weil keine Referenzseite zwei davon lud. Neu: höchstens drei Skripte, alle aus `/assets/` des eigenen Ursprungs, und weiterhin keins, wenn die Richtlinie keins erlaubt.
|
||||
|
||||
**Nicht gebaut:** Bedingungen auf Zahlenbereiche oder mehrere Felder, Dateiuploads, Schritt-für-Schritt ohne JavaScript, Formulare in der Editorvorschau. Nicht committet.
|
||||
|
||||
## DEEG Immobilien & Hausverwaltung nachgebaut, Anfrage als Formular (15.09.2026)
|
||||
|
||||
**Anlass:** Umut lieferte `Immobilien-DEEG-Website.zip` (Onepager für die Deeg Hausverwaltung und Immobilien GmbH, Freudenstadt) und wollte ihn „nach dem Prinzip wie die anderen Seiten mit Kontaktformular“ aufbauen; dazu die Frage, ob die Bewertungsanfrage ein eigenes Plugin braucht.
|
||||
|
||||
**Antwort auf die Plugin-Frage:** Nein. Ein Plugin ist in diesem CMS ein Baustein mit Namensraum, den der Kern aufbewahrt, aber nicht rendert (ADR zum Erhaltungsvertrag, ADR 049); eine Laufzeit dafür gibt es erst mit Phase 4. Die Anfrage ist deshalb das Formular `bewertung` (Auswahl Wohnung/Haus/Mehrfamilienhaus/Grundstück – die vier Objektarten des Preisfinders der laufenden Website –, Straße und Ort, Name, E-Mail, Telefon, Hinweise, Einwilligung), der Kontakt das Formular `kontakt` (mit Auswahl Verkauf/Vermietung/Verwaltung). Beide verschicken serverseitig und liegen unter Formulare in der Verwaltung; die Vorlage verlinkte dafür noch auf www.immobiliendeeg.de.
|
||||
|
||||
**Nachbau (`scripts/seed-immobilien-deeg.php`, `scripts/reset-immobilien-deeg.sh`).** Fünfte Installation `var/immobilien-deeg.db`, `var/immobilien-deeg-media`, Bildquellen `var/immobilien-deeg-assets` (Logo, zwei Architekturfotos aus dem Paket), Startkonfigurationen `immobilien-deeg` (Port 8094) und `immobilien-deeg-prod` (Port 8095, Produktionsmodus zum Messen). Startseite mit Hero (Foto, Schild, Bildzeile, blauer Aufruf zur Bewertung), Leistungsleiste, drei Leistungskarten mit Akkordeon, blauem Bewertungsabschnitt mit Formularkarte, Über uns mit „SEIT 1994“, Angebotsband (Link auf www.immobiliendeeg.de/immobilien/, die Immobilien-Datenbank ist nicht Teil des Pakets), Kontakt mit Telefon, E-Mail, Adresse, Bürozeiten und Formularkarte; Kopf mit heller Leiste, Logo, Menü und blauem Knopf „Immobilie bewerten“; Fuß mit Logo, Rechtszeile und Agenturlink. Impressum mit Register und Vertretung vom Impressum der laufenden Website (HRB 430747, Amtsgericht Stuttgart, Furkan Kurt); USt-ID, Aufsicht nach § 34c GewO und Bildlizenz des übernommenen Fotos als „NOCH ZU PRÜFEN“. Datenschutz als eigene Fassung ohne Cookies und Fremddienste. Schrift Arial wie die Vorlage, nichts zu holen.
|
||||
|
||||
**Für 100 geändert, nicht für die Optik:** Blau als Fläche mit weißer Schrift trägt `#0076b6` (4,9:1 statt 4,2:1 beim Logo-Blau), kleine blaue Schrift auf Weiß `#006aa3`; das Logo-Blau `#0081c7` bleibt für Linien und große Überschriften. Der Button-Baustein hängt immer „↗“ an, die Vorlage kommt ausdrücklich ohne Pfeile aus – am Button `& span{display:none}`. Die Copyright-Zeile trägt ihre Farbe selbst, weil der Fuß kleinen Text sonst auf 3,8:1 aufhellt.
|
||||
|
||||
**Browsercheck angepasst (`scripts/browser-check.mjs`):** Vor Überlauf- und axe-Prüfung spielt das Skript alle laufenden Einblendungen zu Ende (`document.getAnimations().forEach(a => a.finish())`). Vorher hing das Ergebnis vom Zeitpunkt ab: mitten in einer Einblendung rechnete axe den Kontrast gegen die halb durchsichtige Farbe (drei Scheinbefunde am Hero), und ein von rechts hereingleitender Block zählte als horizontaler Überlauf.
|
||||
|
||||
**Geprüft:** `npm run test:links` gegen Port 8094: 3 Seiten, 14 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur, ohne JavaScript bestanden, keine redaktionellen Befunde; Ladebudget der Startseite 157 KB, 6 Anfragen, 11 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8095 für `/`, `/impressum`, `/datenschutz`: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100** (Startseite LCP 1,8 s mobil / 0,5 s Desktop, CLS 0, TBT 0 ms); Berichte in `reports/lighthouse-immobilien-deeg/`. Kontaktformular einmal abgeschickt: Erfolgsmeldung, Eingang in `form_submission`. Screenshots in 1440 und 390 px gegen die Vorlage verglichen. php-cs-fixer auf dem Skript ohne Befund.
|
||||
|
||||
**Nicht geprüft:** der Editor auf den DEEG-Dokumenten im Browser, E-Mail-Zustellung (lokal `sendmail://default`, der Eingang bleibt trotzdem gespeichert), Firefox/Safari, ein echtes Touchgerät, das Hosting. Nicht committet.
|
||||
|
||||
## 1.17.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.17.0` (Commit `d07154e`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.17.0 – Adresse mit oder ohne www aus `SITE_ORIGIN`, Rollen wie die Rollenhilfe sie verspricht, Befunde der Gesamtprüfung (ADR 099, ADR 100).
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.17.0 v1.17.0`, `php scripts/build-release.php` darin): 3555 Dateien im Archiv, 4,7 MB.
|
||||
|
||||
**Anhänge:** `kantsites-1.17.0.zip` (SHA-256 `d421654cd6803e3a683fbbd893db22c005e96261da4f64892c4ae1bb868557f6`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.17.0.py`, erst Entwurf, dann veröffentlicht. Die Releasenotes (`var/release-notes-1.17.0.md`) nennen die Rollenänderung als ersten Punkt vor dem Update.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.16.1 → 1.17.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.17.0`. Die Prüfungen des Codes stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** auf `web.kant-it.de` und dem Drews-Hosting über System → Update aus dem Archiv einspielen (keine Migration), danach unter System die eingetragene Adresse prüfen (`SITE_ORIGIN` mit oder ohne www, wie die Website heißen soll) und die Rollen der vorhandenen Konten unter Benutzer durchsehen – Redakteure sehen Design, Einstellungen, Bereiche, Menüs und Formulare nicht mehr.
|
||||
|
||||
## 1.17.0 – Gesamtprüfung, Adresse mit oder ohne www, Rollen wie versprochen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Komplette Analyse – Bugs, Durchgängigkeit, ob alles sauber ist; was man besser machen kann; und: die URL soll aus env kommen, www oder ohne, die Seite soll darauf reagieren.“ Nachgeschoben: „prüfst du auch, ob man ohne Admin etwas verändern kann?“
|
||||
|
||||
**Prüfung.** Sechs getrennte Durchsichten: Backend (Controller, Content, Security, Twig), Betrieb (Ops, Commands, Subscriber, Config, Installer, CI), Editor (JS, Admin-Templates, Modelltests), öffentliche Ausgabe (Templates, CSS, Skripte, SEO, CSP, Einwilligung), Dokumentation und Repository (README, docs, Skripte, Ports, Versionen, git-Hygiene) sowie eine Routenmatrix aller 63 Verwaltungs- und 15 öffentlichen Routen mit Firewall, Rolle, CSRF und Testabdeckung. Vorab: `phpunit` 512 grün, `npm test` 33 grün, Twig/YAML/Container-Lint ohne Befund, php-cs-fixer eine vorbestehende Zeile in `CmsTest`. Repository-Hygiene in Ordnung (313 Dateien, keine Geheimnisse, `var/`, `reports/`, `.env` ignoriert, `VERSION` = Tag = STATUS).
|
||||
|
||||
**Antwort auf die Rollenfrage:** Ohne Anmeldung kann niemand Inhalte, Einstellungen oder Konten ändern – alle Verwaltungsrouten liegen hinter der Firewall, jede schreibende prüft Rolle und CSRF-Token. Zwei echte Lücken gab es trotzdem, beide behoben: (1) Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete (Wartung beim Hoster, gedrehtes Passwort) – dort hätte jeder eine eigene Datenbank samt Administratorkonto eintragen können. (2) Die Rolle Redakteur durfte Design, Einstellungen (Startseite, `robotsExtra`, Turnstile, Einwilligung), Bereiche, Menüs und Formulare samt Empfängeradresse live ändern, entgegen der Rollenhilfe. ADR 100.
|
||||
|
||||
**www / SITE_ORIGIN (ADR 099):** `CanonicalHostSubscriber` leitet die jeweils andere Schreibweise mit 301 auf die eingetragene Adresse um (Pfad und Abfrage bleiben, Schema und Port aus `SITE_ORIGIN`, auch für die Verwaltung); fremde Hosts, IP-Adressen und localhost bleiben erreichbar, nur GET/HEAD, kein HTTPS-Zwang. Neu `php bin/console cms:site:origin <adresse>`; der Bildschirm „System“ zeigt Adresse, Quelle und einen Hinweis bei abweichendem Host. Der www-Block in `public/.htaccess` ist weg, `.env.example` erklärt die Einstellung, LIVEGANG Punkt 3 und README angepasst.
|
||||
|
||||
**Behoben (Auswahl, alles mit Test):** Sitzungscookie folgt `ADMIN_PATH` (Anmeldung nach `cms:admin:path` war unmöglich); `%env(resolve:)%` gegen prozentkodierte Passwörter durch `projectdir:`-Prozessor ersetzt; Einwilligung nur von dieser Website und kein `/\`-Rücksprung; JSON-LD mit `JSON_HEX_TAG`; Komma im eigenen CSS; Skriptprüfung beim Wiederherstellen einer Fassung; keine Unterseite unter einer Seite im Papierkorb; geplante Veröffentlichung blockiert bei einem Fehler nicht die anderen; Archiv escaped alte Bereiche; Feed-Zeitzone UTC; Sitemap-`lastmod` aus der Fassung; Vollbild in Embeds; Tabs/Bildbetrachter in Bereichen und Sektionen; Schatten bei Überfahren/Scrollen erbt Form; Download nur über HTTPS-Weiterleitungen; Composer/Git-Umgebung und PHP-Kommandozeile im Befehlsläufer; Updater fängt jeden Fehler. Editor: Schemafassung für Menü/Beitragsliste/Slider, gesperrte Design-Knöpfe bleiben gesperrt, Ausrücken/Baum-Zug mit Regeln, kein leerer Verlaufsschritt bei Ablehnung, Bild auf Akkordeon meldet sich, Klartext unter v12, Zahlenfelder ab 0, Farbwähler ohne `onComplete`. Docs: `.env.local`-Reste, `motion`/`archive-update` als npm-Skripte, README-Einleitung und dritte Referenzwebsite, UPDATE.md, `install.php` erkennt `.env`, Release-Builder ohne `LICENSE`.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 grün (neu: `CanonicalHostTest` 24 Fälle, `SiteOriginCommandTest`, `ProjectDirEnvProcessorTest`, Rollen-Matrix und Cookie-Pfad in `EditorSecurityTest`, Installer-Fälle in `InstallationTest`, Systembildschirm, Tabs im Fuß, Sitemap-`lastmod`); `npm test` 33 grün (Fassungstabelle angepasst); Twig/YAML/Container-Lint ohne Befund; php-cs-fixer ohne Befund; `cms:health` gegen die Entwicklungsdatenbank mit dem neuen Prozessor `OK`; `npm run test:editor` (Playwright + axe, `PHP_BINARY=C:/php84/php.exe`) vollständig bestanden – ein erster Lauf scheiterte an einer seit 1.16.1 veralteten Zusicherung des Skripts („Spalte in der Sektion weiterhin gesperrt“), die jetzt die erlaubte Spalte erwartet. Nicht geprüft: Firefox/Safari, ein echtes Touchgerät, das Hosting, ein echter MySQL-Server.
|
||||
|
||||
**Rückstand (aus der Prüfung, nicht umgesetzt):** Archivseiten auf die Aufbaukette der Seiten heben (Kopf/Fuß-Stile, Schriften, Sektionen, Nach-oben); Formulare, Brotkrumen und Beitragslisten in Bereichen/Sektionen sind dort tot; doppelte Block-IDs bei mehrfach verknüpfter Sektion; Breakpoints 650 vs. 767 px vereinheitlichen; feste Farbwerte (`.tone-ink`, Skip-Link, CTA-Button, Fehlerrot, `color-scheme`) bei dunklen Paletten; MySQL ohne Sicherung, Archiv-Update und Rollback; Archiv-Update überschreibt `.htaccess` (HTTPS-Schalter); Release-Caches wachsen; Rollback-Kopie des Git-Updaters ohne Sperre; Archiv komplett im Speicher; Canonical/`rel=prev/next` blätternder Beitragslisten; Druckstile; Editor-Vorschau ohne eigenes CSS/Bereichsstile/Formulare; Skripte in Bereichen laufen nie; vier Blocktypen direkt nach dem Einfügen ungültig (Kontakt, Formular, Sektion, Embed); Rich-Text-Normalisierung erst nach Neuladen sichtbar; `legal.label` unvalidiert; verrutschte Docblocks; CI-`mysql`-Job ohne Tests; Ports 8090/8092 in QUALITY/STATUS sind Messserver, nicht die Startkonfigurationen.
|
||||
|
||||
**Veröffentlicht als 1.17.0** (kein neues Dokumentschema, keine Migration; bestehende Redakteurskonten verlieren Design, Einstellungen, Bereiche, Menüs und Formulare – siehe Eintrag oben). **Nächster Schritt:** `SITE_ORIGIN` auf `web.kant-it.de` und dem Drews-Hosting prüfen und die HTTPS-Zeilen in `.htaccess` einschalten.
|
||||
|
||||
## 1.16.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.16.1` (Commit `937344e`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.1 – einzelne Spalte in einer Sektion, Ausrichtung für Buttons, breite und volle Abschnitte gegen die Design-Inhaltsbreite, Nach-oben-Knopf in einer Landmarke, Cormorant Garamond lokal.
|
||||
|
||||
**Anlass:** Die neu aufgebaute Drews-Datenbank trägt das Token „Ausrichtung“ an Buttons; unter dem auf dem Hosting laufenden 1.16.0 lehnte der Validator diese Dokumente beim Rendern ab (500 auf jeder öffentlichen Seite, Verwaltung lief). Das Release bringt Code und Datenbank wieder zusammen.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.1 v1.16.1`, `php scripts/build-release.php` darin): 3552 Dateien im Archiv, 4,7 MB.
|
||||
|
||||
**Anhänge:** `kantsites-1.16.1.zip` (SHA-256 `2922027abb648839b89962351aa3dbeaa9e354dd985c94a7a2efddabb86d6731`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.16.0 → 1.16.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.1`. Die Prüfungen des Codes stehen in den beiden Einträgen darunter.
|
||||
|
||||
**Nächster Schritt:** auf dem Drews-Hosting über System → Update aus dem Archiv einspielen (keine Migration), danach `var/cache/prod` prüfen; die kopierte Datenbank kann bleiben.
|
||||
|
||||
## Einzelne Spalte in einer Sektion, Buttons ausrichten (15.09.2026)
|
||||
|
||||
**Anlass:** Umut konnte im Editor keine Spalte direkt in eine Sektion legen (ausgegraut) – so zentriert er einen Button –, und der Button „Alle Mitteilungen“ auf der Drews-Startseite stand links, ohne eine Option dafür.
|
||||
|
||||
**Editor (`editor-model.js`):** `REQUIRE.column` ist jetzt `['row', 'section']`. Eine einzelne Spalte darf direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente), in einer Spalte oder lose im Dokument weiter nicht. Test angepasst.
|
||||
|
||||
**Buttons (`design-tokens.json`, `site.css`, `design.css` neu gebaut):** Das Design-Token „Ausrichtung“ gilt jetzt auch für Buttons. Weil ein Button `inline-flex` ist, zentriert ihn `margin:auto` nicht; `site.css` macht ihn bei `align-center`/`-right` zum Block mit eigener Breite (`display:flex;width:fit-content;margin-inline:auto`, dazu `justify-self` für Raster), je Gerät an den Breakpoints von `design.css` (1024/650 px). Das Drews-Aufbauskript nutzt das Token für alle mittigen Buttons statt der wirkungslosen auto-Ränder.
|
||||
|
||||
**Geprüft:** `npm test` 33 grün (darunter der geänderte Einfügetest), PHP-Tests zu Design-Tokens 11 grün; Drews-Startseite nach Neuaufbau: „Alle Mitteilungen“ und „Alle Projekte ansehen“ mittig, Aufruf-Buttons mittig, auf 1440 und 390 px. Unveröffentlicht, gehört mit den beiden Korrekturen darunter in 1.16.1.
|
||||
|
||||
## Ihr Malerservice Drews nachgebaut, 15.09.2026
|
||||
|
||||
**Anlass:** Umut wollte maler-drews.com (Flatsome-Auftritt, Malermeister Axel Drews, Gernsbach) nach dem bekannten Prinzip in eigener Installation nachbauen – exakt im Viewer, mit Lighthouse, axe und maschinenlesbaren Ausgaben auf 100.
|
||||
|
||||
**Vorlage ausgelesen, nicht geschätzt.** Alle 9 Seiten und 31 Beiträge der laufenden Website als HTML geholt, Struktur, eigenes CSS (`malerdrews-*`, `drews-*`), Abschnittsfarben und Inline-Werte extrahiert, Screenshots in 1440 und 390 px als Vergleich. Befund: Lato + Cormorant Garamond 700, Gold `#c6a36b`, Creme `#f7f5f2`, dunkle Bänder `#232323`/`#111`/`#0a0a0a`, Kopf 90 px weiß mit Versalmenü, Startbild als Slider mit drei Folien und goldener Welle, Kartenraster aus Bild und Verlauf, Zickzackliste der Projekte, Zeitleiste der Mitteilungen. `/about-us` und `/innenaustattung` sind auf der Vorlage Lorem-ipsum-Reste, `/little-greene` ein 404.
|
||||
|
||||
**Nachbau (`scripts/seed-maler-drews.php`, `scripts/seed-maler-drews-inhalte.php`, `scripts/reset-maler-drews.sh`).** Eigene Installation `var/maler-drews.db`, `var/maler-drews-media`, Bildquellen `var/maler-drews-assets` (139 Bilder, auf 2400 px verkleinert, Zuordnung in `manifest.json`), Startkonfiguration `maler-drews` auf Port 8093. Acht Seiten (Start, Aktuelles, Unsere Projekte, Kunstmalerei, Kontakt, Impressum, Datenschutz, 404), 31 Beiträge mit Bildkopf, Text, Galerie mit Bildbetrachter, Nachbarn und Aufruf, Kategorien „Aktuelles“ (5) und „Projekte“ (26); die Listen sind Beitragslisten-Bausteine (Karten auf der Startseite, Liste als Zeitleiste, Wechselseitig als Zickzack). Kopf mit Marke und Versalmenü, Fuß in vier Spalten mit Instagram, Öffnungszeiten und Rechtszeile. Formular serverseitig, Google-Karte und elf Videos als eingebettete Inhalte hinter der Einwilligungsleiste. Impressum und Datenschutz wörtlich übernommen (die Datenschutzerklärung nennt Google Analytics und reCAPTCHA, die der Nachbau nicht nutzt – vor dem Livegang zu bereinigen). Weiterleitungen für die drei toten Vorlagenadressen und die eine über 80 Zeichen lange Beitragsadresse. Bewusste Abweichungen stehen im Kopf des Skripts.
|
||||
|
||||
**Für 100 geändert, nicht für die Optik:** Gold auf Weiß erreicht 2,4:1 – Kleinzeilen, Datum, Links und aktive Menüpunkte auf hellem Grund tragen `@gold-text` (`#8a6a2f`, 5,0:1), Buttons dunkle statt weiße Schrift auf Gold (6:1), der Einwilligungsknopf ebenso; vor den Beitragslisten steht eine nur für Vorlesesoftware sichtbare H2; der Copyright-Link ist unterstrichen. Das Startbild wird über den äußeren Abschnitt vorgeladen (nur das erste Hintergrundbild einer Seite bekommt ein `preload`).
|
||||
|
||||
**Zwei Lücken im CMS behoben (`site.css`, `page.html.twig`, unveröffentlicht, gehört in 1.16.1):** Abschnitte mit Breite „breit“ oder „voll“ blieben so schmal wie alle anderen, weil `.kant .shell{max-width:var(--k-container)}` des Designs die Breitenregeln schlug – jetzt mit `.kant` davor. Der Nach-oben-Knopf stand außerhalb jeder Landmarke (axe „region“) und steckt jetzt in einem `nav`. Beim Bauen gelernt: eigenes CSS erlaubt kein `@media`, keine Anführungszeichen, keine Klammern in Selektoren (`:not()`, `nth-child()`), keine Kommaselektoren, und eine leere `&{…}`-Regel geht verloren – Eigenschaften des Elements ohne `&{}` schreiben.
|
||||
|
||||
**Geprüft:** Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus (`APP_ENV=prod`, Port 8092) auf sechs Adressen (`/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`): **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 (Startseite 99 mobil / 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, sonst 100; LCP ist jeweils das Foto hinter dem Seitenkopf, mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte in `reports/lighthouse-maler-drews/`. Drei Messrunden davor zeigten Barrierefreiheit 91–93 (Gold-Kontrast, Überschriftenfolge, Link nur durch Farbe), dann 96–100 (aktiver Menüpunkt, Formularbeschriftungen), jetzt 100. axe (WCAG 2.0/2.1 A+AA und Best Practice, Playwright, 1440 und 390 px, nach dem Durchscrollen) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); vorher 58, davon 36 Kontrast, 18 „region“ am Nach-oben-Knopf, 2 Überschriftenfolge, 2 Link im Text. Maschinenlesbar: `llms.txt` mit allen Seiten und Beiträgen, `sitemap.xml` mit 41 Adressen, `robots.txt`, `feed.xml`, `LocalBusiness` mit Adresse, Öffnungszeiten, Einsatzgebiet und Instagram als `sameAs`. Alle 41 Adressen der Vorlage antworten mit 200 oder 301. Screenshots des Nachbaus in 1440 und 390 px gegen die der Vorlage verglichen (Startseite, Aktuelles, Projekte, Kunstmalerei, Kontakt, Beitrag, Impressum). PHP-Tests 512 grün (PHP 8.4), `npm test` 33 grün, Twig-Lint, php-cs-fixer auf den neuen Skripten.
|
||||
|
||||
**Nicht geprüft:** der Editor auf den Drews-Dokumenten im Browser (kein Anmelden mit Passwort in dieser Sitzung), Firefox/Safari, ein echtes Touchgerät, das Hosting. Die Videos liegen weiter auf maler-drews.com und brauchen für den Livegang einen eigenen Speicherort.
|
||||
|
||||
## 1.16.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.16.0` (Commit `32ee288`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.0 – Spaltenbreiten einer Zeile durch Ziehen im Editor.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.0 v1.16.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.16.0.zip` (4,7 MB, SHA-256 `8aa8d40d79dfe7bf972ca005ed92d688cf63beb93a4807c20d5dcabb9b2f9264`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.0.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.15.1 → 1.16.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.0`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann im Editor eine Zeile wählen und die Spaltengrenze ziehen – etwa bei einer Karte mit Symbol links und Text rechts.
|
||||
|
||||
## 1.16.0 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut, mit der Karte „HausKlar“ (Symbol links, Text rechts) als Beispiel: „bei Rows die Breite ändern, am besten per Drag and Drop im Editor.“ Zeilen kannten bisher nur gleich breite Spalten. Entscheidung und Begründung: ADR 098.
|
||||
|
||||
**Umsetzung:** Neue freie Stileigenschaft `columnWidths` an der Zeile (Typ `fractions`, `StyleSchema::FRACTIONS`, z. B. `66.7 33.3`), je Gerät, ohne neues Dokumentschema. `StyleCompiler::columnTracks()` macht daraus `grid-template-columns:minmax(0,66.7fr) minmax(0,33.3fr)`, füllt fehlende Anteile mit dem Durchschnitt auf, kappt überzählige und setzt ein Gerät, dessen Aufteilung stapelt (`one` oder `gridColumns` 1), auf eine Spur – sonst schlüge die ID-Regel des Desktops die Stapel-Klasse des Handys. Im Editor (`canvas-overlay.js`) liegt auf jeder Grenze zwischen zwei nebeneinander stehenden Spalten der gewählten Zeile (oder der Zeile um die gewählte Spalte) ein Griff `.ov-resize`: Ziehen stellt die Zeile in der Vorschau live um, Loslassen schreibt einen Verlaufsschritt (`onResize` in `editor.js` → `setStyle`), Pfeiltasten verschieben um fünf Punkte und behalten den Fokus. Ausgangswert sind die gemessenen Spaltenbreiten. Im Design-Bedienfeld gibt es dazu das Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen). Kein Griff bei gestapelten Spalten und während ein Element gezogen wird.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 512 von 512 grün, mit PHP 8.2 512 grün mit der vorbestehenden Warnung in `UpdaterTest`; neu `tests/ColumnWidthsTest.php` (Spuren, Stapeln je Gerät, Auffüllen und Kappen, Ablehnung ungültiger Werte). `npm test` 33 grün (Relevanz nur für Zeilen). `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden – neu darin: echter Mauszug am Griff verbreitert die erste Spalte, der Wert steht im Bedienfeld der Zeile, auf „Mobil“ gibt es keinen Griff, ein Rückgängig stellt gleiche Spalten her (`reports/editor/column-widths.png`). php-cs-fixer ohne Befund. Ein erster Lauf des Checks scheiterte an `openGroup`, das im Skript erst später definiert ist – die Gruppe wird jetzt an Ort und Stelle geöffnet.
|
||||
|
||||
**Nebenbei:** `.claude/launch.json` trägt in der Arbeitskopie den noch nicht committeten Eintrag `maler-drews` einer anderen Sitzung auf Port 8091; die Pilotkonfiguration ist deshalb lokal auf 8092 verschoben, die Datei bleibt aus diesem Commit heraus.
|
||||
|
||||
## 1.15.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.15.1` (Commit `d05dba3`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.1 – eigene Absenderadresse je Formular.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.1 v1.15.1`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.15.1.zip` (4,7 MB, SHA-256 `e15a7a69f299c16d86d372b65610b52f15b40b93904fa6402ea6991de7ed21f7`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.15.0 → 1.15.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.1`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare eine Absenderadresse der eigenen Domain eintragen und eine Testanfrage senden.
|
||||
|
||||
## 1.15.1 – Eigene Absenderadresse je Formular (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Versandadresse bei Kontaktformularen sollte man noch festlegen können – aktuell wird's über die gleiche Adresse verschickt wie der Empfänger?" Stimmte: `FormController::deliver()` setzte `From` auf die Empfängeradresse. Das geht schief, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist (z. B. eine GMX- oder Gmail-Adresse), weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt, und SPF/DMARC des fremden Anbieters die Post als Fälschung abweisen.
|
||||
|
||||
**Umsetzung:** Neue Formulareinstellung `sender` (freiwillig). `FormRepository::validateSettings()` prüft sie wie den Empfänger mit `FILTER_VALIDATE_EMAIL`, leer bleibt erlaubt; die Verwaltung (`forms.html.twig`, Abschnitt Zustellung, Feld „Absender“ direkt unter dem Empfänger mit Hinweis) und `FormAdminController` reichen sie durch. Beim Versand gilt `sender ?: recipient` – bestehende Formulare ohne den Schlüssel verhalten sich wie bisher, es ist keine Migration nötig. Die Antwortadresse aus dem Formularfeld bleibt `Reply-To`.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 509 von 509 grün, mit PHP 8.2 509 grün mit der vorbestehenden Warnung in `UpdaterTest`; `npm test` 33 grün; `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden, darin die axe-Prüfung der Formularseite. Neu in `EditorSecurityTest`: `testAFormMaySendFromItsOwnAddress` (From = Absender, To = Empfänger, Reply-To = Feld; ungültiger Absender abgewiesen, Leerzeichen werden zu leer) und im bestehenden Absendetest die Prüfung, dass ohne Absender From = To ist – beides über `getMailerMessage()` mit dem `null://`-Transport der Tests. php-cs-fixer ohne Befund. In der Pilotkopie im Browser (`kantsites-pilot`, jetzt Port 8091, weil 8087 von einer anderen Sitzung belegt war): Feld sichtbar, `formular@kant-it.de` gespeichert und nach dem Neuladen erhalten, Meldung „Das Formular wurde gespeichert.“ Ein echter Versand über SMTP wurde nicht geprüft.
|
||||
|
||||
**Nächster Schritt:** Auf `web.kant-it.de` nach dem nächsten Update unter Formulare eine Absenderadresse der eigenen Domain eintragen (z. B. `formular@kant-it.de`), dann eine Testanfrage senden und im Postfach Absender und Zustellung kontrollieren.
|
||||
|
||||
## 1.15.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.15.0` (Commit `160c394`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.0 – Formulare, Statistik, Design und Seitenleiste der Verwaltung überarbeitet, Formulare mit eigenen Farben und Vorschau.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.0 v1.15.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.15.0.zip` (4,7 MB, SHA-256 `86ef728812db3b2f565f07dd1d8aebf8d5d41b75875695544134684b77a193ed`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.0.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.14.2 → 1.15.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus, weil der saubere Checkout weder `vendor/` noch `var/kantit.db` hat); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.0`, Installeradresse 200. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare das Aussehen des Kontaktformulars wählen.
|
||||
|
||||
## 1.15.0 – Formulare, Statistik, Design und Seitenleiste überarbeitet (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand die Formularanlage unschön und wollte Felder löschen können, die Statistik „lieblos“, auf der Designseite vordefinierte Farben, eigene Farben für Link- und Seitenfarbe und eine Vorschau der Schriften, und den Fuß der Seitenleiste (Fassung, Benutzer, Abmelden) besser. Nachgeschoben: eine Vorschau des Kontaktformulars, in der Rahmen-, Hintergrund- und Buttonfarbe direkt gewählt werden.
|
||||
|
||||
**Formulare (`forms.html.twig`, neu `form-editor.js`).** Jedes Feld ist eine Karte mit Nummer, Beschriftung, Typ, technischem Name, Hilfetext, Pflicht-Schalter und einem Entfernen-Knopf; „Feld hinzufügen“ klont eine `<template>`-Karte. Der technische Name folgt der Beschriftung (`Größe der Fläche` → `groesse_der_flaeche`), bis jemand ihn selbst schreibt; Optionen erscheinen nur bei einer Auswahl; eine Einwilligung ist sichtbar immer Pflicht. Ohne Skript bleibt alles bedienbar: eine Karte ohne Beschriftung wird nicht gespeichert. Neu ist der Abschnitt **Aussehen**: Buttonbeschriftung, Rahmen und Hintergrund der Felder, Button- und Buttonschriftfarbe, jeweils aus der Palette oder als eigener Wert (`FormRepository::COLORS`, geprüft mit `StyleSchema::COLOR`). Daneben eine **Vorschau**, die jeder Eingabe folgt und den Absendeknopf mit dem Standardbutton der Website zeichnet (`buttonSample`). Auf der Website schreibt `FormRepository::css()` je Formular eine Regel `.block-form[data-form="…"]{…}` in den gehashten Stylesblock (`PageController::renderPage`); Felder lesen `--form-border`/`--form-bg` (`site.css`), der Button bekommt `--kb-fill`/`--kb-ink` **und** die Darstellungsvariablen neu – am Wurzelelement wären sie schon mit den alten Werten aufgelöst. Das „Neues Formular“ ist ein zugeklapptes Karten-Details am Ende.
|
||||
|
||||
**Statistik (`statistics.html.twig`, `Statistics::overview()`).** Sechs Kennzahlkacheln: Aufrufe mit Vergleich zum gleich langen Zeitraum davor (`previousTotal`), Schnitt je Tag, stärkster Tag mit Datum und Wochentag (`busiestDay`), aufgerufene Seiten (`pageCount`), Aufrufe über Verweise (`referred`) und nicht gefundene Adressen (`missingTotal`). Der Verlauf hat Rasterlinien, jeder Balken einen `<title>` mit Tag und Zahl, der stärkste Tag ist hervorgehoben; dazu ein neues Wochentagsdiagramm im Schnitt (`weekdays`). Die Ranglisten tragen Rang und einen Anteilsbalken (SVG-Breite, kein Inline-Stil). Der Zeitraum ist eine Leiste mit gedrücktem Knopf in der Kopfzeile; „Was diese Zahlen nicht sagen“ ist zugeklappt.
|
||||
|
||||
**Design (`theme.html.twig`, neu `theme-panel.js`).** Die Palette sind Farbkarten, deren Fläche der Farbwähler selbst ist, mit Hexwert, Name, Schlüssel und Entfernen. Sieben fertige Paletten (`Theme::PALETTES`: Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht; die Linkfarbe erreicht auf dem Papierton mindestens 4,5:1) ersetzen die fünf Grundschlüssel und lassen eigene Farben stehen. Schriften sind Karten mit einer Probe in der Schrift selbst (lokale `fonts.css` wird geladen, wenn vorhanden – `FontLibrary::hasStylesheet()`), Schnitte als Chips, die Auswahlen für Fließtext/Überschriften/Button folgen neuen Schriften. Text-, Seiten- und Linkfarbe nutzen das Palettenbedienelement des Editors (`createColorControl`), das bei jeder Palettenänderung neu aufgebaut wird; daneben eine Typografie-Vorschau mit Überschriftenskala, Zeilenhöhe, Farben und Unterstreichung.
|
||||
|
||||
**Seitenleiste (`shell.html.twig`).** Unten eine Kontokarte: Avatar, Name, Rolle (Administrator/Veröffentlicher/Redakteur), rechts ein Abmeldesymbol mit Beschriftung für Vorlesesoftware; darunter „KantSites 1.14.2“ mit dem Update-Hinweis in einer Zeile. Neue Symbole `logout`, `plus`, `remove`. Auf Tablets wischt nur noch die Liste, Konto und Abmelden bleiben rechts im Bild; dafür braucht die Rasterspalte `minmax(0, 1fr)`, sonst schob die Leiste die Seite über den Rand.
|
||||
|
||||
Die Formularvorschau nennt den Kontrast der Buttonschrift auf der Buttonfarbe (WCAG-Formel, grün ab 4,5:1, gelb ab 3:1, sonst rot), damit eine unlesbare Wahl auffällt, bevor sie auf der Website steht. Die `pattern`-Attribute der Schlüsselfelder maskieren den Bindestrich (`[a-z][a-z0-9\-]*`): Chrome prüft `pattern` inzwischen mit dem `v`-Flag und lehnte das alte Muster mit einem Konsolenfehler ab – die Prüfung war damit wirkungslos.
|
||||
|
||||
**Geprüft:** `phpunit` mit PHP 8.4 508 von 508 grün, mit PHP 8.2 508 grün mit einer vorbestehenden Warnung in `UpdaterTest` (Git-Umgebung); neu getestet: Formularfarben auf der Seite samt CSP-Hash, Ablehnung ungültiger Farben, Statistiksummen und Vorzeitraum, drei Diagramme mit Titeln. `npm run test:editor` vollständig bestanden – darin die axe-Prüfung von Formularen, Statistik und Design. `test:buttons` besteht den Designseiten-Teil (Vorlage wählen, speichern, Werte erhalten) und scheitert danach wie `test:navigation-ux` an einem Klick auf den Strukturbaum des Editors (`#structure [data-block-id=…]` „not visible“) – eine Stelle außerhalb dieser Änderung, nicht weiter untersucht. `test:admin` scheitert vor den Formularen an zwei vorbestehenden Befunden: dem Wiedergabeknopf des öffentlichen Sliders bei reduzierter Bewegung und `nested-interactive` in den Menü-Zusammenfassungen (Ziehgriff im `summary`). In der Pilotkopie im Browser: Feld hinzufügen/entfernen, Namensableitung, Farbwahl mit Vorschau und Kontrasthinweis, Speichern, Regel und Buttonfarbe auf der öffentlichen Startseite; Palette „Ozean“ übernehmen, Schrift hinzufügen, Linkfarbe wählen; Seitenleiste und Bildschirme auf 1440, 900 und 375 px. Codestil mit php-cs-fixer (verbleibende Abweichung in `tests/CmsTest.php` ist vorbestehend). Veröffentlicht als 1.15.0, siehe Eintrag oben.
|
||||
|
||||
## 1.14.2 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.2` (Commit `0aa1ff6`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.2 – Fußzeile des Einwilligungshinweises: Einzelwahl links, Rechtslinks rechts.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.14.2 v1.14.2`), nicht aus der Arbeitskopie: dort lagen zu diesem Zeitpunkt nicht committete Änderungen einer anderen Sitzung (`site.css`, `archive.html.twig`, `page.html.twig`, `tests/CmsTest.php`, neu `src/Twig/HeaderExtension.php`), und ein erster Build aus der Arbeitskopie hatte sie mitgenommen – der wurde verworfen. Das Archiv enthält keine vorgefertigten `assets/build/`-Bündel mehr (die Arbeitskopie hatte welche, sie entstehen beim ersten Aufruf); daher 3546 statt 3552 Dateien.
|
||||
|
||||
**Anhänge:** `kantsites-1.14.2.zip` (4,7 MB, SHA-256 `236dff086267591a51198b55ddfbaf2ee999db8a081ec5bb1d28752260a3db3f`), `.sha256`, `install.php`; hochgeladen mit `var/publish-release-1.14.2.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** PHP 506 Tests grün (ein erster Lauf zeigte einen Fehler, während die andere Sitzung `tests/CmsTest.php` bearbeitete; zwei weitere Läufe grün); HTTP-Archiv-Update 1.14.1 → 1.14.2 `ok`; nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.14.2`, Installeradresse 200.
|
||||
|
||||
## 1.14.2 – Einwilligungshinweis: Fußzeile aufgeräumt (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand „Einzeln wählen“ und die Rechtslinks unordentlich und schlug links/rechts vor.
|
||||
|
||||
**Umgesetzt (`consent-banner.html.twig`, `site.css`):** Beide stehen in einer Zeile `.consent-foot` – Einzelwahl links, Datenschutz · Impressum rechts, auf gemeinsamer Grundlinie. Aufgeklappt nimmt die Einzelwahl die volle Breite (`[open]{flex-basis:100%}`), die Links rutschen rechtsbündig darunter. Die Links stehen ohne Punkt dazwischen mit 14 px Abstand; ohne beide Adressen entfällt die Zeile. Ein Stolperstein: `.consent-bar p` schlug `.consent-links` und nahm den rechten Rand weg – der Selektor heißt jetzt `.consent-bar p.consent-links`.
|
||||
|
||||
**Geprüft:** vier Gestaltungsvarianten auf 1440 und 390 px, zu- und aufgeklappt (Screenshots `reports/consent-look/*-offen.png`), sechs Einwilligungstests grün. Veröffentlicht als 1.14.2, siehe Eintrag oben.
|
||||
|
||||
## 1.14.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.1` (Commit `0bdcc7f`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.1. Enthält den gestaltbaren Einwilligungshinweis, den bei offener Frage erreichbaren Nach-oben-Knopf, den korrigierten Abstand unten und die orangen Beschriftungen im Editor; dazu `860bed9` (Seed-Skript Tagespflege Moldovan, nicht im Archiv).
|
||||
|
||||
**Anhänge:** `kantsites-1.14.1.zip` (4,7 MB, 3552 Dateien, SHA-256 `c0620a3155db894373199dbe2e286744dd7b21eed2c355e8d2a890652d1d203e`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.14.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** PHP 505 Tests grün; `browser-check.mjs`, `editor-check.mjs` bestanden; HTTP-Archiv-Update 1.14.0 → 1.14.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200). Nach der Veröffentlichung ohne Anmeldung: alle drei Anhänge bytegleich, `/releases/latest` nennt `v1.14.1`, Installeradresse antwortet 200.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv, Migrationen abschließen (keine neuen), dann unter Einstellungen → Einwilligung das Aussehen wählen; `var/kantit.db` mit dem korrigierten Kopfmenü hochladen.
|
||||
|
||||
## 1.14.1 – Einwilligungshinweis gestaltbar, Nach-oben-Knopf bei offener Frage, orange Beschriftungen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut wollte das Banner gestaltbar (rund, Farben) und schöner, den Abstand unten korrigiert, belegte Eigenschaften im Editor orange statt nur fett mit Punkt, und Gerätechips, die sich „aktiv überlagern“.
|
||||
|
||||
**Einwilligungshinweis (ADR 097).** Sieben neue Einstellungen: `consentLayout` (karte/leiste/ecke), `consentShape` (weich/rund/eckig), `consentTitle`, `consentColor`, `consentInk`, `consentAccent`, `consentAccentInk`; Farben mit `StyleSchema::COLOR` geprüft, Palette über `Theme::cssColor()` (neu, ersetzt die Kopie im Nach-oben-Knopf). `Consent::styles()` schreibt `:root{--kant-consent-…}` in den gehashten Stylesblock; die Anordnung ist eine Klasse. Neues Grundlayout: Streifen und Symbol in der Knopffarbe, gleich breite Knöpfe, aufklappbares „Einzeln wählen“ mit Pfeil, unterstrichene Rechtslinks; Leiste auf breiten Bildschirmen zweispaltig. Archiv und `/einwilligung` bekommen Theme- und Hinweisvariablen als gehashten Stylesblock – das Archiv trug bisher gar keine Palette.
|
||||
|
||||
**Abstand unten:** 320 px bis 620 px (Leiste bis 299 px hoch), 270 px bis 899 px, 200 px darüber; die Fläche scrollt ab `100dvh − 24px` in sich. Nach-oben-Knopf hebt sich bei Leiste und Ecke auf jeder Breite (`:has()`), bei der Karte bis 799 px.
|
||||
|
||||
**Editor:** belegte Beschriftungen in `--used` (#9d4e0d, 5,6:1); Chips mit 6 px Lücke, Fokusring ohne Versatz, Gruppe `flex:none`. Ein Überlagern der Chips ließ sich in 1.14.0 nicht reproduzieren – am Handy (390 px) liegen die drei Chips bei 22 px Breite mit 6 px Abstand, kein `outline` gezeichnet; die Rückmeldung bezog sich wohl auf den `outline`-Ring von 1.13.0.
|
||||
|
||||
**Geprüft:** PHP 505 Tests grün, darunter neu `testTheConsentQuestionTakesLayoutShapeAndColoursFromTheSettings` (Standard, eigene Werte, unbekannter Palettenschlüssel fällt weg, Einwilligungsseite mit Hash, ungültige Farbe abgewiesen). Chromium: vier Varianten (Karte, Leiste, Ecke rund dunkel, Karte eckig dunkel) auf 1440 und 390 px – Klasse, Radien, Farben und Überschrift stimmen, Nach-oben-Knopf sichtbar, treffbar, ohne Überlappung; `/einwilligung` 200 mit gehashtem `style-src`. Nach-oben-Prüfung auf acht Breiten bestanden. `browser-check.mjs`, `editor-check.mjs` (Einstellungen, Einwilligung) und axe über die Editorseitenleiste bestanden. Screenshots: `reports/consent-look/`, `reports/admin-hints/`.
|
||||
|
||||
**Nicht geprüft:** echtes iOS-Safari; der `@supports not`-Zweig; Lighthouse.
|
||||
|
||||
## Nach-oben-Knopf bei offener Einwilligungsfrage (15.09.2026)
|
||||
|
||||
**Anlass:** Umut meldete, der Nach-oben-Knopf funktioniere auf dem Handy nicht.
|
||||
|
||||
**Befund.** Der Knopf selbst funktioniert auf dem Telefon: In iPhone- und Pixel-Emulation (Chromium), lokal wie auf `web.kant-it.de` (dort bereits 1.14.0 mit `scrolled.js`), erscheint er nach 500 px, ist treffbar, und Tippen springt nach oben – **sobald die Einwilligungsfrage beantwortet ist**. Vorher galt `body.consent-open .scroll-top{display:none}` auf allen Geräten. Live trägt jeder Erstbesuch `consent-open`, und auf dem Telefon bleibt die Leiste unten oft einfach stehen – der Knopf kam dort nie. Kein Fehler im Skript und nicht der alte Safari-Fehler aus 1.13.0.
|
||||
|
||||
**Korrektur (`site.css`, `page.html.twig`).** Der Knopf bleibt bei offener Frage sichtbar. Gemessen deckt die Leiste die rechte Ecke nur bis knapp 800 px ab (bis 620 px volle Breite, 262–285 px hoch; darüber zentriert mit höchstens 620 px). In dieser Stufe setzt sich der Knopf per Ankerpositionierung 12 px auf die Oberkante der Leiste (`anchor-name:--kant-consent`, `bottom:calc(anchor(top) + 12px)`), unabhängig davon, wie hoch ihr Text umbricht. Browser ohne Ankerpositionierung blenden ihn dort weiter aus (`@supports not`). Ab 800 px bleibt er an seinem Platz. Weil ein Anker im Dokument vor dem positionierten Element stehen muss, gibt die Vorlage die Leiste jetzt vor dem Knopf aus.
|
||||
|
||||
**Geprüft:** Chromium, isolierte Kopie der Pilotdatenbank, Einwilligung offen auf 360, 390, 700, 768, 799, 800 und 1440 px sowie beantwortet auf 390 px – in allen acht Fällen sichtbar, keine Überlappung mit der Leiste, treffbar, nach dem Tippen `scrollY` 0. Twig-Lint gültig. PHP-Tests 504 grün, `browser-check.mjs` und `editor-check.mjs` (einschließlich Einwilligungsabläufe) bestanden.
|
||||
|
||||
**Nicht geprüft:** echtes iOS-Safari – WebKit ist für Playwright auf diesem Rechner nicht installiert. Der `@supports not`-Zweig wurde nicht ausgeführt, weil Chromium Ankerpositionierung kann.
|
||||
|
||||
**Nebenbefund, nicht geändert:** Bis 620 px reserviert `body.consent-open` 260 px Abstand unten, die Leiste ist mit Standardtext bis 285 px hoch – auf kleinen Telefonen liegen die letzten rund 25 px der Seite unter der offenen Leiste.
|
||||
|
||||
**Nächster Schritt:** nicht committet und nicht veröffentlicht; live erst mit einem Release (z. B. 1.14.1).
|
||||
|
||||
## 1.14.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.0` (Commit `a434e0c`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.0. Enthält die Schriften vom eigenen Server, den Hinweisstreifen, System und Update auf dem Telefon, die Gerätechips sowie Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten.
|
||||
|
||||
**Anhänge:** `kantsites-1.14.0.zip` (4,7 MB, SHA-256 `8360566b1eaed670b29f506262953d3b17f95893e23c7f4a940ff6d5e3f62735`), `.sha256`, `install.php` mit eingetragener Prüfsumme. Hochgeladen mit `var/publish-release-1.14.0.py` über den Git-Zugang dieses Rechners, erst als Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** 504 PHP-Tests grün; `browser-check.mjs` und `editor-check.mjs` bestanden; HTTP-Archiv-Update 1.12.1 → 1.14.0 `ok`. Nach der Veröffentlichung ohne Anmeldung: alle drei Anhänge bytegleich mit den lokalen Dateien, `/releases/latest` nennt `v1.14.0`, die Installeradresse `…/releases/download/v1.14.0/kantsites-1.14.0.zip` antwortet mit 200.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv, danach Migrationen abschließen und `var/kantit.db` mit dem korrigierten Kopfmenü hochladen.
|
||||
|
||||
## Globale Hinweise, der Schriftenknopf und die Gerätechips (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fehlte ein Hinweis, der auch außerhalb der Systemseite sagt, dass etwas zu tun ist; der Knopf „Schriften holen“ sah nicht aus wie die übrigen; und im Designbereich störte ein roter Rahmen an den Gerätechips, der abgeschnitten wirkte.
|
||||
|
||||
**Hinweisstreifen auf jedem Verwaltungsbildschirm (`kant_admin_notices()`, `admin/shell.html.twig`).** Über dem Inhalt steht jetzt, was einen Knopf braucht, und der Knopf gleich daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren* (System), „Schriftdateien fehlen: …“ → *Schriften holen* (Design). Keine Zeile davon geht ins Netz – die verfügbare Fassung steht im Protokoll der täglichen Aufgabe `update-check`, die fehlenden Schriften sind ein Blick in den Ordner. Der Updatehinweis erscheint nur für `ROLE_ADMIN`, weil nur die auf die Systemseite dürfen. Warm statt rot: es ist eine Aufgabe, kein Fehler. Auf 390 px bricht der Streifen um, der Knopf rutscht unter den Text.
|
||||
|
||||
**Schriftenknopf.** Er trug gar keine Klasse und war deshalb der einzige Knopf der Verwaltung im Standardaussehen des Browsers. Jetzt `primary` (bzw. `secondary` beim erneuten Holen), und der ganze Abschnitt steht wie alles andere in einer Karte mit Kopf und Körper.
|
||||
|
||||
**Gerätechips D/T/M (`editor.css`).** Der rote Kasten war `outline:2px` mit `outline-offset:1px` auf dem gerade bearbeiteten Gerät. Ein `outline` liegt außerhalb des Kästchens: bei 2 px Abstand lief er unter den Nachbarchip und wurde von der seitlich beschnittenen Sidebar gekappt – daher der abgeschnittene Eindruck. Jetzt zwei Zustände mit zwei Mitteln:
|
||||
|
||||
- **belegt** (dieses Gerät trägt einen eigenen Wert): gefülltes Orange `--used: #9d4e0d`, weiße Schrift – 5,6:1 Kontrast. Dieselbe Farbe trägt der Punkt vor der Beschriftung, damit „hier steht ein eigener Wert“ überall gleich aussieht.
|
||||
- **gerade bearbeitet**: 2 px Rahmen **innen** (`border-width`, kein `outline`), auf belegten Chips zusätzlich ein weißer Innenring. Nichts ragt mehr aus dem Kästchen, nichts wird beschnitten.
|
||||
|
||||
Chips von 19 auf 22 px, Abstand von 2 auf 4 px – auf einen Blick lesbar, welche Geräte an einer Eigenschaft hängen.
|
||||
|
||||
**Geprüft** (isolierte Kopie der Pilotdatenbank, künstlich auf „Lato 300 fehlt“ und „Fassung 9.9.9 verfügbar“ gesetzt): beide Streifen erscheinen auf `/admin`, tragen die richtigen Ziele, und auf 390 px bleibt die Dokumentbreite bei 390. Schriftenknopf trägt `primary`. Chips: belegte auf `rgb(157,78,13)`, bearbeitete mit 2 px Rahmen, kein gezeichnetes `outline` mehr. axe über die Editorseitenleiste ohne Verstöße. PHP-Tests: 498, ein bekannter Fehler (`testAnyPublishedPageCanBecomeTheRootAndEverythingFollows`). Screenshots: `reports/admin-hints/`.
|
||||
|
||||
**Zu beachten:** `admin.css` und `editor.css` hängen an `?v={{ kant_version() }}`. Bis die `VERSION` steigt, liefert der Browser die alten Fassungen aus dem Zwischenspeicher.
|
||||
|
||||
## Schriften nur noch vom eigenen Server, kein sichtbarer Schriftwechsel mehr (14.09.2026)
|
||||
|
||||
**Anlass:** Umut sah im Browser, wie die Schrift kurz von fett auf dünn sprang, und stellte zwei Regeln auf: keine Schriften über Adressen von Google, und kein Nachladen nach dem fertigen Seitenaufbau.
|
||||
|
||||
**Zwei getrennte Ursachen, beide belegt.**
|
||||
|
||||
1. **Der späte Schnitt.** `FontLibrary::preload()` holte je Familie nur die **ersten zwei** Schnitte vorab. Das Kant-IT-Theme nennt Lato 400, 700 und 900; die Überschriften stehen auf 800/900 und landen damit auf Lato 900 – dem einzigen Schnitt ohne Vorabholen. Gemessen am Nachbau: `lato-400` und `lato-700` starteten bei 151 ms, `lato-900` erst bei 156 ms, also nach dem Stylesheet. Mit `font-display:swap` zeichnet der Browser die Überschrift bis dahin in der Ersatzschrift und tauscht sie danach – der gemeldete Sprung. Jetzt wird jeder Schnitt des Themes vorab geholt (lateinischer Grundbereich; die erweiterten Bereiche bleiben draußen).
|
||||
2. **Google in der Editorvorschau.** `EditorController` und `PartialController` gaben der Vorlage `googleFonts` und **kein** `localFonts`. In der Vorschau kamen die Schriften damit bei jedem Aufruf von `fonts.googleapis.com` und `fonts.gstatic.com` – zwei fremde Verbindungen hintereinander, und genau dort arbeitet Umut am meisten. Beide Vorschauen nutzen jetzt dieselben lokalen Dateien wie die öffentliche Seite.
|
||||
|
||||
**Die Regel steht jetzt im Code, nicht in der Einstellung.** `page.html.twig` hat keinen Google-Zweig mehr; fehlen die Schriftdateien, zeigt die Website die Ersatzschrift des Themes. Die Kopfzeilen lassen keinen fremden Schrifthost mehr zu – öffentlich `font-src 'self'`, in der Verwaltung `style-src 'self'; font-src 'self'` statt der bisherigen Ausnahmen für `fonts.googleapis.com`/`fonts.gstatic.com`. `Theme::googleFontsUrl()` ist entfernt; das Herunterladen der Dateien über die Theme-Seite bleibt unverändert und baut seine Adresse selbst. Die Vorabholen-Verweise stehen jetzt **vor** dem Stylesheet, und die Texte auf der Theme-Seite sagen nicht mehr „kommen zurzeit von Google“, sondern „die Dateien fehlen, bis dahin die Ersatzschrift“.
|
||||
|
||||
**Geprüft** (Chromium, kalter Zwischenspeicher, isolierte Kopie der Pilotdatenbank): alle drei Schriftdateien starten bei 96 ms und sind bei 166–168 ms fertig, der erste sichtbare Inhalt erscheint bei 292 ms – die Schriften stehen also vor dem ersten Zeichnen, es gibt nichts mehr zu tauschen. Jede Datei wird genau einmal angefragt. Über die ganze Sitzung – öffentliche Seite, Anmeldung, Seiteneditor mit Vorschau, Bereiche – **keine einzige Anfrage außerhalb des eigenen Servers**. PHP-Tests: 498, ein bekannter Fehler (siehe unten). `npm run test:browser` und der Lighthouse-Lauf sind **nicht** gelaufen.
|
||||
|
||||
**Tests angepasst:** `testFontsAreReadFromGoogleStylesheetAndServedFromHere` prüft jetzt die Ersatzschrift statt des Google-Verweises und alle drei Vorabhol-Dateien; `testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy` heißt `…AndNoFontIsEverFetchedFromGoogle` und prüft die engere Kopfzeile. In `testEveryAllowedIconHasADrawingAndTakesItsColourFromTheBlock` stand eine echte Zeilenschaltung im Suchmuster; unter Windows mit `core.autocrlf=true` schlägt das fehl, sobald die Arbeitskopie CRLF trägt. Das Muster nimmt jetzt `\R` und gilt für beide Schreibweisen; die Vorlage selbst bleibt unverändert.
|
||||
|
||||
**Weiterhin offen, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl – der Test erwartet `<a href="/" aria-current="page">Start</a>`, das Menü schreibt seit den Symbolen `<span class="nav-label">` um die Beschriftung.
|
||||
|
||||
## Kopfmenü im Hochformat und die Update-Ansicht auf dem Handy (14.09.2026)
|
||||
|
||||
**Anlass:** Umut meldete, das Mobilmenü im Kopf erscheine nur im Querformat, und bat darum, Texte und Ansichten der Update-Seite fürs Handy zu kürzen.
|
||||
|
||||
**Befund zum Kopfmenü – falsch gebaut, kein Fehler im CMS.** Der Kopfbereich trug zwei Menüblöcke: `area-header-menu` (waagerecht, `mobile: display:none`) und daneben `area-header-burger` (eigener Burger-Block, nur mobil sichtbar). Der veröffentlichte Stand auf `web.kant-it.de` kennt den zweiten Block nicht – abgerufen am 14.09.2026 enthält das Markup dort nur `area-header-menu` samt `@media (max-width:767px){#area-header-menu{display:none}}`. Damit ist im Hochformat (bis 767 px) gar kein Menü da, während im Querformat (768–1024 px) das Standardverhalten `collapse: tablet` denselben Block zum Burger zusammenlegt und ein Knopf erscheint. Genau die gemeldete Beobachtung. In `var/kantit.db` lag zwar ein zweiter Burger-Block, der das Loch stopfte, aber mit eigener Gestaltung: im Hochformat ein 161 px breiter Knopf, im Querformat der 101 px breite des waagerechten Menüs.
|
||||
|
||||
**Behoben in `var/kantit.db`:** ein einziger Menüblock `area-header-menu` mit `collapse: tablet` und `burgerLabel: both`, ohne `display:none` auf Mobil, `burgerFontSize: 14px`; der doppelte Block `area-header-burger` ist entfernt (Kopfbereich Fassung 8, Sicherung `var/kantit.db.vor-kopfmenue-20260914-2244.bak`). Geprüft am laufenden Nachbau: 375 px Hochformat und 844 px Querformat zeigen denselben Knopf („MENÜ“, 101 × 45 px, 14 px), die Liste bleibt in beiden verborgen und die aufklappende Fläche liegt über die volle Breite unter dem Kopf; ab 1025 px stehen die vier Einträge wieder waagerecht. Startkonfiguration `kantit` (Port 8130) in `.claude/launch.json` ergänzt.
|
||||
|
||||
**Update-Ansicht (`templates/admin/system.html.twig`, `public/assets/admin.css`).** Die Seite schob sich auf dem Handy seitlich aus dem Bild: `.stack` ist ein Raster mit automatischer Spalte, und eine automatische Rasterspalte wächst auf die Mindestbreite ihres breitesten Inhalts – eine lange Datenbankpfadangabe reichte. `.stack` bekommt `minmax(0, 1fr)`, `.facts` beide Spalten als `minmax(0, …)` und unter 620 px nur noch eine, `.card-body` und die Prüfpunkte `min-width: 0` samt `overflow-wrap: anywhere`. Die Sicherungstabelle wird unter 620 px zu gestapelten Blöcken (`.table-stack`, Spaltenüberschrift als `data-label`), statt waagerecht zu scrollen. Texte gekürzt und das Kleingedruckte in zugeklappte `<details class="hint">` gelegt, ebenso die älteren 15 der 20 Protokollzeilen.
|
||||
|
||||
**Geprüft:** 390 × 844 mit Touch-Emulation gegen eine isolierte Kopie der Pilotdatenbank (`tests/pilot-fixture.php`, eigenes Zufallskonto): Dokumentbreite 390 statt 492, kein Element mehr breiter als der Inhaltsbereich, Wortzahl der Seite 1017 → 556, axe ohne Verstöße. `/admin`, `/admin/media`, `/admin/menus`, `/admin/forms`, `/admin/theme`, `/admin/settings` bleiben mobil bei 390 px; dieselben Seiten und `/admin/system` bei 1440 px unverändert breit. Screenshots: `reports/system-mobile/`.
|
||||
|
||||
**Bekannter Fehler, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl (498 Tests, 1 Fehler) – der Test erwartet `<a href="/" aria-current="page">Start</a>`, das Menü schreibt seit den Symbolen `<span class="nav-label">` um die Beschriftung. Auch ohne diese Änderungen und mit beiden PHP-Fassungen reproduzierbar.
|
||||
|
||||
**Nächster Schritt:** Umut spielt `var/kantit.db` auf den Server; erst dort ist das Kopfmenü im Hochformat live. Für die Admin-Änderungen die `VERSION` beim nächsten Release erhöhen, sonst liefert der Browser `admin.css?v=1.13.0` weiter aus dem Zwischenspeicher.
|
||||
|
||||
## 1.14.0 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten; Elektro Bernhard aktualisiert (14.09.2026)
|
||||
|
||||
**Anlass:** Umut nannte die tatsächlichen Bürozeiten (Mo–Fr 7:30–17:00), das Einsatzgebiet von Karlsruhe bis Offenburg und wollte die FAQ „mit allem, was notwendig ist“ – das JSON-LD, das das CMS bisher nicht hergab (siehe Lücke in den Einträgen vom Abgleich).
|
||||
|
||||
**Umgesetzt (ADR 095):** Einstellungen `openingHours` und `areaServed` mit Formular unter Unternehmensdaten; `SiteSettings::organisation()` gibt `openingHoursSpecification` (gleiche Zeiten zusammengefasst) und `areaServed` aus. Akkordeon-Eigenschaft `faq` (Schema v23, Kästchen im Editor); `PageController` baut daraus eine `FAQPage` mit Frage und Antwort aus dem sichtbaren Inhalt. Fünf neue Tests: Zusammenfassung und Reihenfolge der Zeiten, Abweisung unvollständiger oder verdrehter Zeiten, FAQPage nur aus gekennzeichneten Akkordeons und nur mit Antwort, Abweisung der Kennzeichnung vor v23, und – nach einem Fund der vollen Suite – dass ein Bildschirm, der die geladenen Einstellungen unverändert zurückgibt (Anlegen der Rechtsseiten, Installer), Öffnungszeiten und Einsatzgebiet nicht löscht.
|
||||
|
||||
**Elektro Bernhard:** Bürozeiten überall Mo–Fr 07:30–17:00 Uhr (Fuß, Kontakt, Beschreibung, FAQ); Einsatzgebiet Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach, Murgtal, Ettlingen, Karlsruhe, Achern, Oberkirch, Offenburg in Text und `areaServed`; FAQ um „Wann ist das Büro erreichbar?“ und „Wie läuft eine Anfrage ab?“ ergänzt und als FAQ gekennzeichnet. Die Öffnungszeiten weichen bewusst von der laufenden Website ab (dort Mo–Do 7:00–16:30, Fr bis 13:00) – Umuts Angabe gilt.
|
||||
|
||||
**Arbeitsweise:** Weil eine parallele Sitzung im Hauptarbeitsbaum dreizehn Dateien offen hatte, darunter Validator, PageController und Editor, entstand diese Änderung im eigenen Worktree `var/wt-faq` auf dem Zweig `oeffnungszeiten-faq` und wurde von dort auf `main` gebracht. Der Hauptarbeitsbaum muss danach `git pull` ausführen.
|
||||
|
||||
## 1.13.0 – Drei Symbole mehr, gestaltbarer Menü-Knopf, Nachbau „Tagespflege Moldovan“ (14.09.2026)
|
||||
|
||||
**Anlass:** Umut ließ die ZIP-Vorlage „Tagespflege Moldovan“ nachbauen. Beim Nachbau fehlten dem Symbolsatz genau die drei Bilder, die die Vorlage zeigt; danach kamen zwei Meldungen vom Telefon: das Menü sei nicht zu sehen, und „Scroll to top“ funktioniere nicht.
|
||||
@@ -14,6 +382,8 @@
|
||||
|
||||
**Geprüft:** PHP-Suite 498 Tests (fünf neue: Symbolliste gegen Zeichnungen, drei Knopfvarianten, abgewiesene Variante, Knopffarben am `summary`); einziger Fehlschlag ist der bekannte Altfehler `testAnyPublishedPageCanBecomeTheRoot…`. 33 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung sauber, Editor-Browserprüfung vollständig erfolgreich einschließlich axe. Playwright auf dem Nachbau bei 1440, 900 und 390 px sowie als iPhone 13: Seite gegen die Vorlage verglichen, Menü öffnet als Fläche unter dem Kopf, Sprungziele, 404-Seite, keine Konsolenfehler. Nach-oben-Knopf auf der Elektro-Bernhard-Installation als iPhone geprüft: oben verborgen, ab der Schwelle sichtbar, springt an den Anfang.
|
||||
|
||||
**Veröffentlicht:** Commit `73719ba` und Tag `v1.13.0` auf `origin`; [Release 1.13.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.13.0) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`. Das Archiv wurde aus einem sauberen Worktree des Tags gebaut (`var/release-source-1.13.0`), `vendor/` ohne Entwicklungspakete; die einzige `.env` im Archiv ist die Vorlage ohne Geheimnis. Die drei Anhänge wurden anonym von der Releaseadresse geladen und bytegenau mit den gebauten Dateien verglichen; SHA-256 `14e333e0…59dcbf`, identisch mit der Prüfsumme, die `install.php` mitbringt.
|
||||
|
||||
## 1.12.1 – Elektro Bernhard gemessen: Lighthouse und axe (14.09.2026)
|
||||
|
||||
**Anlass:** Umut fragte nach Google-Bewertung, SEO, llms.txt und Barrierefreiheit der neuen Website.
|
||||
|
||||
+5
-1
@@ -124,7 +124,7 @@ var/releases/install.php → Release-Anhang
|
||||
- **Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte.** Sonst würde der Rückweg Inhalte verlieren, die während des Updates entstanden sind.
|
||||
- **Der Rauchtest ist `cms:health`** und läuft als eigener Prozess, weil nur ein frischer Prozess den neuen Code ausführt.
|
||||
- **Noch offen:** MySQL wird gesichert, indem `mysqldump` aufgerufen wird – das ist **nicht** umgesetzt, weil noch keine MySQL-Installation existiert (Arbeitspaket J). Bei einer Datenbank, die nicht als Datei vorliegt, sichert der Updater derzeit nur die Umgebungsdateien und sagt das im Schritt „Sicherung“. Vor dem ersten Livegang mit MySQL muss das nachgezogen werden.
|
||||
- **Ebenfalls offen:** der Installer aus Arbeitspaket N. Die Systemprüfung, die er braucht, existiert jetzt.
|
||||
- **Inzwischen umgesetzt:** der Installer (`/install`, ADR 041) und der Weg ohne Git über das Releasearchiv (`scripts/install.php`, System → Update aus dem Archiv). Offen bleibt allein die MySQL-Sicherung.
|
||||
|
||||
## Übergang auf 1.7.0: `.env` gehört zur Installation
|
||||
|
||||
@@ -133,3 +133,7 @@ Der Archiv-Updater erhält die Konfiguration auch bei diesem Übergang. Der erst
|
||||
In alten Git-Installationen war `.env` noch versioniert. Beim erstmaligen Wechsel kann ein alter Git-Updater diese neue Schutzlogik noch nicht besitzen. Nutze dafür den Archiv-Updater. Bei einem manuellen Git-Checkout zuerst `.env` und gegebenenfalls `.env.local` außerhalb des Checkout-Verzeichnisses sichern, beide nach dem Checkout wiederherstellen und erst danach Setup/Migration/Cache starten. Ab 1.7.0 erhält der Git-Updater die Dateien selbst vor dem ersten neuen PHP-Prozess. `.env` nicht erneut ins Repository aufnehmen.
|
||||
|
||||
Bei FTP-Updates die `.env` aus dem neuen Archiv überspringen. Die Vorlage im Archiv ist ausschließlich für Erstinstallationen bestimmt. Ein Rückweg benötigt den alten Code plus die beiden gesicherten Konfigurationsdateien.
|
||||
|
||||
## MySQL und MariaDB (seit 16.09.2026, ADR 104)
|
||||
|
||||
Sicherung und Wiederherstellung brauchen auf einer MySQL-Installation die Clients `mysqldump` und `mysql` im Pfad des PHP-Prozesses (auf geteiltem Hosting beim Anbieter erfragen; ein voller Pfad lässt sich nicht eintragen, aber `mysqldump.phar`/`mysql.phar` im Projektstamm würden gefunden). Die Sicherung schreibt `datenbank.sql` mit `--single-transaction`; die Wiederherstellung spielt sie mit `mysql --execute=source` ein. Fehlt der Client zum Einspielen, sagt die Wiederherstellung das und nennt den Pfad des Dumps. Der Archiv-Updater und der Git-Updater sichern vorher über denselben Weg und spielen beim Rückweg dieselbe Sicherung ein. Beide Wege sperren sich gegenseitig über `var/update.lock`; ein zweiter Aufruf bekommt die Meldung „Ein anderes Update läuft gerade“.
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260915000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Plugins: eine Zeile je Erweiterung mit dem Schalter, ob sie aktiv ist.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Was ein Plugin kann, steht in seinem Manifest im Ordner plugins/. Hier steht nur, ob der
|
||||
// Betreiber es eingeschaltet hat - eine Entscheidung der Installation, nicht des Codes,
|
||||
// und deshalb in der Datenbank statt in einer Datei, die ein Update ueberschreiben koennte.
|
||||
$plugin = $schema->createTable('plugin');
|
||||
$plugin->addColumn('name', 'string', ['length' => 40]);
|
||||
$plugin->addColumn('active', 'boolean', ['default' => false]);
|
||||
$plugin->addColumn('activated_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// Die Fassung, mit der es zuletzt eingeschaltet wurde - damit die Verwaltung sagen kann,
|
||||
// dass ein Plugin seit dem Einschalten aktualisiert wurde.
|
||||
$plugin->addColumn('version', 'string', ['length' => 32, 'default' => '']);
|
||||
$plugin->setPrimaryKey(['name']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('plugin');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260916000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Plugins: die Einstellungen je Erweiterung, wie sie der Betreiber in der Verwaltung setzt.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Was ein Plugin einstellbar macht, steht in seinem Manifest; was der Betreiber gewaehlt
|
||||
// hat, hier - als JSON, weil jedes Plugin andere Schluessel hat. Eine Spalte, kein
|
||||
// Tabellenneubau: auf SQLite ist ADD COLUMN eine Zeile im Katalog.
|
||||
$schema->getTable('plugin')->addColumn('settings', 'text', ['default' => '{}']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->getTable('plugin')->dropColumn('settings');
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -20,7 +20,9 @@
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs",
|
||||
"test:colors": "node scripts/color-picker-check.mjs",
|
||||
"test:accordion": "node scripts/accordion-check.mjs"
|
||||
"test:accordion": "node scripts/accordion-check.mjs",
|
||||
"test:motion": "node scripts/motion-check.mjs",
|
||||
"test:archive-update": "node scripts/archive-update-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Bildvergleich. Die Position kommt als --pos (Prozent) vom Skript; ohne Skript gilt die Mitte. */
|
||||
.block-bildvergleich{--pos:50%;margin:0}
|
||||
.bildvergleich-buehne{position:relative;margin:0;overflow:hidden;user-select:none;-webkit-user-select:none}
|
||||
.bildvergleich-rund .bildvergleich-buehne{border-radius:12px}
|
||||
.bildvergleich-buehne img{display:block;width:100%;height:auto;border-radius:0}
|
||||
.bildvergleich-vorher{position:absolute;inset:0;height:100%;object-fit:cover;clip-path:inset(0 calc(100% - var(--pos)) 0 0)}
|
||||
.bildvergleich-linie{position:absolute;top:0;bottom:0;left:var(--pos);width:2px;margin-left:-1px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25);pointer-events:none}
|
||||
.bildvergleich-linie::after{content:'⇔';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:44px;height:44px;border-radius:50%;background:#fff;color:#182d2b;display:grid;place-items:center;font-size:20px;box-shadow:0 4px 14px rgba(0,0,0,.3)}
|
||||
.bildvergleich-etikett{position:absolute;top:14px;padding:6px 10px;border-radius:6px;background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;pointer-events:none}
|
||||
.bildvergleich-etikett-vorher{left:14px}
|
||||
.bildvergleich-etikett-nachher{right:14px}
|
||||
.bildvergleich-regler{position:absolute;inset:0;display:block;cursor:ew-resize}
|
||||
.bildvergleich-regler input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:ew-resize}
|
||||
.bildvergleich-regler input:focus-visible{opacity:1;appearance:none;-webkit-appearance:none;background:transparent;outline:3px solid #fff;outline-offset:-6px}
|
||||
.block-bildvergleich .block-unavailable{margin:0}
|
||||
/* Griff als Punkt statt Pfeile (Plugin-Einstellung). */
|
||||
.bildvergleich-griff-punkt .bildvergleich-linie::after{content:'';width:22px;height:22px}
|
||||
/* Die Vergleichsgalerie: ein Raster aus Bildvergleichen. */
|
||||
.block-vergleichsgalerie{margin:0}
|
||||
.vergleichsgalerie-titel{margin:0 0 18px}
|
||||
.vergleichsgalerie-raster{display:grid;gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
@media(max-width:767px){.vergleichsgalerie-raster,.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Bildvergleich: der Regler setzt --pos auf der Bühne. Ohne dieses Skript steht der Vergleich
|
||||
* bei der gespeicherten Startposition; Tastatur und Vorlesesoftware bedienen das native
|
||||
* Bereichsfeld in beiden Fällen.
|
||||
*/
|
||||
(() => {
|
||||
for (const block of document.querySelectorAll('[data-bildvergleich]')) {
|
||||
const range = block.querySelector('input[type="range"]');
|
||||
if (!range) continue;
|
||||
const apply = () => block.style.setProperty('--pos', `${range.value}%`);
|
||||
range.addEventListener('input', apply);
|
||||
apply();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
{# Bildvergleich: das Nachher-Bild liegt unten, das Vorher-Bild darüber und wird per clip-path
|
||||
auf den linken Teil beschnitten. Der Regler ist ein natives Bereichsfeld – ohne Skript bleibt
|
||||
er stehen, beide Bilder sind trotzdem zu sehen. Die Vorlage bekommt nur, was der Kern geprüft
|
||||
hat: `props` nach dem Schema, `settings` des Plugins, `block.id`, `designClasses`. #}
|
||||
{% set vorher = props.before ? kant_plugin_media(props.before) : null %}
|
||||
{% set nachher = props.after ? kant_plugin_media(props.after) : null %}
|
||||
{% set labelVorher = props.labelBefore ?: settings.labelBefore|default('Vorher') %}
|
||||
{% set labelNachher = props.labelAfter ?: settings.labelAfter|default('Nachher') %}
|
||||
<div id="{{ block.id }}" class="block-bildvergleich{{ props.rounded ? ' bildvergleich-rund' : '' }} bildvergleich-griff-{{ settings.handle|default('pfeile') }}{{ designClasses }}" data-bildvergleich data-position="{{ props.position }}">
|
||||
{% if vorher and nachher %}
|
||||
{% set gross = nachher.variants|last %}
|
||||
<figure class="bildvergleich-buehne">
|
||||
<img class="bildvergleich-nachher" src="{{ gross.src }}" srcset="{% for v in nachher.variants %}{{ v.src }} {{ v.width }}w{{ loop.last ? '' : ', ' }}{% endfor %}" sizes="(max-width: 1320px) 100vw, 1320px" width="{{ gross.width }}" height="{{ gross.height }}" alt="{{ props.alt ?: nachher.alt }}" loading="lazy" decoding="async">
|
||||
{% set grossVorher = vorher.variants|last %}
|
||||
<img class="bildvergleich-vorher" src="{{ grossVorher.src }}" srcset="{% for v in vorher.variants %}{{ v.src }} {{ v.width }}w{{ loop.last ? '' : ', ' }}{% endfor %}" sizes="(max-width: 1320px) 100vw, 1320px" width="{{ grossVorher.width }}" height="{{ grossVorher.height }}" alt="" loading="lazy" decoding="async">
|
||||
<span class="bildvergleich-etikett bildvergleich-etikett-vorher" aria-hidden="true">{{ labelVorher }}</span>
|
||||
<span class="bildvergleich-etikett bildvergleich-etikett-nachher" aria-hidden="true">{{ labelNachher }}</span>
|
||||
<span class="bildvergleich-linie" aria-hidden="true"></span>
|
||||
<label class="bildvergleich-regler"><span class="visually-hidden">{{ labelVorher }} und {{ labelNachher }} vergleichen: Regler verschieben</span><input type="range" min="0" max="100" value="{{ props.position }}" step="1"></label>
|
||||
</figure>
|
||||
{% else %}
|
||||
<p class="block-unavailable">{{ editing ? 'Bildvergleich: bitte unter Inhalt ein Bild für vorher und eines für nachher wählen.' : '' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
{# Die Vergleichsgalerie: ein Container. Seine Kinder kommen fertig gerendert als `children`
|
||||
herein - der Kern hat sie mit demselben Kontext gerendert wie jeden anderen Baustein. #}
|
||||
<div id="{{ block.id }}" class="block-vergleichsgalerie vergleichsgalerie-{{ props.columns }}{{ designClasses }}">
|
||||
{% if props.title %}<h3 class="vergleichsgalerie-titel">{{ props.title }}</h3>{% endif %}
|
||||
{% if children|trim %}
|
||||
<div class="vergleichsgalerie-raster">{{ children }}</div>
|
||||
{% elseif editing %}
|
||||
<p class="block-unavailable">Vergleichsgalerie: Bildvergleiche hier hineinziehen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "vergleich",
|
||||
"title": "Bildvergleich",
|
||||
"description": "Vorher und nachher in einem Bild: ein Regler schiebt zwischen zwei Aufnahmen – für Renovierungen, Malerarbeiten, Sanierungen.",
|
||||
"version": "1.1.0",
|
||||
"requires": "1.17.0",
|
||||
"settings": {
|
||||
"labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher", "maxLength": 40 },
|
||||
"labelAfter": { "kind": "text", "label": "Standardbeschriftung nachher", "default": "Nachher", "maxLength": 40 },
|
||||
"handle": { "kind": "select", "label": "Griff des Reglers", "choices": { "pfeile": "Pfeile", "punkt": "Punkt" }, "default": "pfeile" }
|
||||
},
|
||||
"blocks": {
|
||||
"bildvergleich": {
|
||||
"label": "Bildvergleich",
|
||||
"description": "Zwei Bilder übereinander, ein Regler dazwischen.",
|
||||
"icon": "⇔",
|
||||
"template": "blocks/bildvergleich.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"script": "assets/bildvergleich.js",
|
||||
"props": {
|
||||
"before": { "kind": "media", "label": "Bild vorher" },
|
||||
"after": { "kind": "media", "label": "Bild nachher" },
|
||||
"labelBefore": { "kind": "text", "label": "Beschriftung vorher", "help": "Leer: die Standardbeschriftung aus den Plugin-Einstellungen.", "maxLength": 40 },
|
||||
"labelAfter": { "kind": "text", "label": "Beschriftung nachher", "maxLength": 40 },
|
||||
"alt": { "kind": "text", "label": "Was die Bilder zeigen", "help": "Ein Satz für Vorlesesoftware, zum Beispiel „Fassade vor und nach dem Anstrich“.", "maxLength": 200 },
|
||||
"position": { "kind": "number", "label": "Startposition des Reglers (%)", "min": 5, "max": 95, "default": 50 },
|
||||
"rounded": { "kind": "boolean", "label": "Abgerundete Ecken", "default": true }
|
||||
}
|
||||
},
|
||||
"galerie": {
|
||||
"label": "Vergleichsgalerie",
|
||||
"description": "Mehrere Bildvergleiche nebeneinander im Raster.",
|
||||
"icon": "⊞",
|
||||
"template": "blocks/galerie.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"container": true,
|
||||
"children": ["vergleich.bildvergleich"],
|
||||
"props": {
|
||||
"columns": { "kind": "select", "label": "Spalten am Schreibtisch", "choices": { "2": "Zwei", "3": "Drei" }, "default": "2" },
|
||||
"title": { "kind": "text", "label": "Überschrift der Galerie", "maxLength": 120 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"abschnitt": {
|
||||
"label": "Vorher-Nachher-Abschnitt",
|
||||
"description": "Eine Sektion mit Überschrift, kurzem Text und einem Bildvergleich.",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "vergleich-abschnitt", "type": "section", "layout": { "tone": "paper", "width": "normal" },
|
||||
"children": [
|
||||
{ "id": "vergleich-titel", "type": "heading", "props": { "text": "Vorher und nachher", "level": 2 } },
|
||||
{ "id": "vergleich-text", "type": "text", "props": { "text": "Ziehen Sie den Regler und sehen Sie den Unterschied." } },
|
||||
{ "id": "vergleich-bild", "type": "vergleich.bildvergleich", "props": { "before": "", "after": "", "labelBefore": "", "labelAfter": "", "alt": "", "position": 50, "rounded": true } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{# Eine eigene Adresse des Plugins: die Einstellungen als JSON - fuer eine App oder ein Skript,
|
||||
das die Beschriftungen kennen will. Die Vorlage bekommt `settings`, `query` und `route`. #}
|
||||
{{ {plugin: 'vergleich', labels: {before: settings.labelBefore, after: settings.labelAfter}, handle: settings.handle, echo: query}|json_encode(constant('JSON_UNESCAPED_UNICODE') b-or constant('JSON_UNESCAPED_SLASHES'))|raw }}
|
||||
+3
-7
@@ -14,13 +14,9 @@ RewriteRule ^ index.php [L]
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Eine Domain, eine Adresse. Ohne diese Umleitung ist dieselbe Seite unter www und ohne www
|
||||
# erreichbar - zwei Adressen fuer denselben Inhalt, und Suchmaschinen muessen raten, welche gilt.
|
||||
# Die Richtung muss zu SITE_ORIGIN passen; hier die Fassung "immer mit www".
|
||||
#RewriteCond %{HTTP_HOST} !^www\. [NC]
|
||||
#RewriteCond %{HTTP_HOST} !^localhost [NC]
|
||||
#RewriteCond %{HTTP_HOST} !^127\.0\.0\.1 [NC]
|
||||
#RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
# Eine Domain, eine Adresse - mit oder ohne www. Das erledigt die Anwendung selbst nach der
|
||||
# Einstellung SITE_ORIGIN in .env: die jeweils andere Schreibweise antwortet mit 301 dorthin.
|
||||
# Hier braucht es dafuer keine Regel; eine zweite Stelle wuerde nur auseinanderlaufen.
|
||||
|
||||
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
|
||||
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
|
||||
|
||||
+280
-66
@@ -147,31 +147,61 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
|
||||
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
|
||||
|
||||
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
|
||||
.shell-user { display: flex; align-items: center; gap: 10px; padding: 8px 10px; font-size: 12px }
|
||||
.shell-user strong { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
/* Der Fuss der Leiste: Kontokarte, Abmeldesymbol, Fassung. Eine Einheit statt dreier Zeilen. */
|
||||
.shell-foot { margin-top: auto; padding-top: 14px; border-top: 1px solid #2a3d35; display: grid; gap: 8px }
|
||||
.shell-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
border-radius: 13px;
|
||||
background: #ffffff0b;
|
||||
border: 1px solid #ffffff10;
|
||||
}
|
||||
.shell-user {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 9px;
|
||||
text-decoration: none;
|
||||
color: var(--shell-ink);
|
||||
}
|
||||
.shell-user:hover, .shell-user[aria-current=page] { background: #ffffff12 }
|
||||
.shell-user[aria-current=page] .shell-avatar { box-shadow: 0 0 0 2px var(--mint) }
|
||||
.shell-user-text { display: grid; min-width: 0; line-height: 1.3 }
|
||||
.shell-user-text strong { font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.shell-user-text span { font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px; height: 28px;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #2b4a3b;
|
||||
color: var(--mint);
|
||||
font-size: 12px;
|
||||
background: var(--mint);
|
||||
color: var(--shell);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shell-foot form { margin: 0 }
|
||||
.shell-foot button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
.shell-logout {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px; height: 38px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--shell-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.shell-foot button:hover { background: #21362d; color: var(--shell-ink) }
|
||||
.shell-logout .nav-icon { color: inherit }
|
||||
.shell-logout:hover { background: #ffffff14; color: #fff }
|
||||
.shell-logout:focus-visible { outline-color: var(--mint); outline-offset: -2px }
|
||||
.shell-version { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; padding: 0 8px 2px; font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-version a { text-decoration: none }
|
||||
.shell-version > a:hover { color: var(--shell-ink); text-decoration: underline }
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
@@ -211,13 +241,37 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.shell-heading p { font-size: 13px; margin: 7px 0 0; max-width: 62ch }
|
||||
.shell-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap }
|
||||
.shell-body { padding: 30px 38px 60px; max-width: 1180px; width: 100% }
|
||||
.stack { display: grid; gap: 20px }
|
||||
/* Eine Spalte, die nie breiter wird als der Bildschirm: ohne `minmax(0, 1fr)` waechst
|
||||
die automatische Rasterspalte auf die Mindestbreite ihres breitesten Inhalts - auf dem
|
||||
Handy schiebt eine einzige lange Pfadangabe dann die ganze Seite zur Seite. */
|
||||
.stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px }
|
||||
|
||||
.notice { padding: 13px 17px; border-radius: 8px; line-height: 1.6; font-size: 13px; margin: 0 }
|
||||
.notice.ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.error, .notice.error { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.shell-body > .notice { margin-bottom: 4px }
|
||||
|
||||
/* Der Hinweisstreifen ueber jedem Verwaltungsbildschirm: eine Sache, die getan werden will, und
|
||||
gleich daneben der Knopf, der sie tut. Warm statt rot - es ist eine Aufgabe, kein Fehler. */
|
||||
.shell-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 18px;
|
||||
margin: 0 0 4px;
|
||||
padding: 12px 14px 12px 17px;
|
||||
border: 1px solid #e3cf9b;
|
||||
border-radius: 10px;
|
||||
background: #fdf6e4;
|
||||
color: #5b4412;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shell-notice > span { min-width: 0; flex: 1 1 260px }
|
||||
.shell-notice .secondary { flex: none; border-color: #dcc48a; background: var(--paper); color: #5b4412 }
|
||||
.shell-notice .secondary:hover { background: #fbeecd; border-color: #cbb072 }
|
||||
|
||||
/* ---- cards and sections ---------------------------------------------------------------- */
|
||||
|
||||
.card { background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
@@ -232,7 +286,12 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
}
|
||||
.card-head h2, .card-head h3 { margin: 0 }
|
||||
.card-head .muted { font-size: 12px }
|
||||
.card-body { padding: 8px 22px 24px }
|
||||
.card-body { padding: 8px 22px 24px; min-width: 0 }
|
||||
.card-body code { overflow-wrap: anywhere }
|
||||
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
|
||||
.hint { margin: 12px 0 0 }
|
||||
.hint > summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; min-height: 44px; display: flex; align-items: center }
|
||||
.hint[open] > summary { margin-bottom: 2px }
|
||||
/* A heading for a group of rows, outside any card. */
|
||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 20px; flex-wrap: wrap; margin: 16px 2px -6px }
|
||||
.section-head h2 { margin: 0 }
|
||||
@@ -428,7 +487,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.table .check-cell input { width: 18px; min-height: 18px }
|
||||
.table-new input, .table-new select { background: #fafbf8 }
|
||||
.table form { margin: 0 }
|
||||
/* Column widths per screen: the two tables carry different things. */
|
||||
/* Column widths per screen: the menu table carries text, links and switches. */
|
||||
.menu-table td:first-child input { width: 82px }
|
||||
.menu-table td:nth-child(2) select { width: 148px }
|
||||
.menu-table td:nth-child(3) input { min-width: 170px }
|
||||
@@ -436,15 +495,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.menu-table td:nth-child(5) select { min-width: 190px }
|
||||
.menu-table td:nth-child(6) input { width: 130px }
|
||||
.menu-table td:nth-child(7) input { min-width: 190px }
|
||||
.field-table td:first-child input { width: 140px }
|
||||
.field-table td:nth-child(2) select { width: 150px }
|
||||
.field-table td:nth-child(3) input { min-width: 170px }
|
||||
.field-table td:nth-child(5) input { min-width: 180px }
|
||||
.field-table td:nth-child(6) input { min-width: 170px }
|
||||
.theme-table td input { width: 190px }
|
||||
.theme-table td select { width: 150px }
|
||||
.theme-table td input[type=color] { width: 52px }
|
||||
.theme-table .weights-cell { white-space: nowrap }
|
||||
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
|
||||
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
|
||||
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
|
||||
@@ -456,12 +506,12 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.card-body .section-head:first-child { margin-top: 14px }
|
||||
.card-body > .primary, .card-body > .secondary { margin-top: 24px }
|
||||
|
||||
/* A screen made of several equal sections (menu slots, form definitions). */
|
||||
/* A screen made of several equal sections (menu slots). */
|
||||
.stack-section { display: grid; gap: 10px }
|
||||
.menu-form > label:first-of-type, .form-def > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > input[name=name] { max-width: 420px }
|
||||
.menu-form .table, .form-def .table { margin-top: 22px }
|
||||
.menu-form .primary, .form-def .primary { margin-top: 20px }
|
||||
.menu-form .table { margin-top: 22px }
|
||||
.menu-form .primary { margin-top: 20px }
|
||||
|
||||
.empty {
|
||||
padding: 34px;
|
||||
@@ -479,10 +529,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.contrast-ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.contrast-warn { background: #fff6e2; color: #6f4e18; border: 1px solid #e6cf9c }
|
||||
.contrast-bad { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.theme-form .theme-swatch { width: 52px; min-height: 38px; padding: 2px; cursor: pointer }
|
||||
.theme-form .weights-cell { white-space: nowrap }
|
||||
.theme-form .weights-cell label { display: inline-flex; align-items: center; gap: 4px; margin: 0 9px 0 0; font-size: 11px; font-weight: 400 }
|
||||
.theme-form .weights-cell input { width: 14px; min-height: 14px }
|
||||
|
||||
/* ---- submissions ----------------------------------------------------------------------- */
|
||||
|
||||
@@ -518,25 +564,33 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
/* ---- narrow screens: the sidebar becomes a strip above the content --------------------- */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.admin-shell { grid-template-columns: 1fr }
|
||||
/* minmax(0, 1fr), nicht 1fr: sonst nimmt die Spalte die Mindestbreite der Leiste an und
|
||||
schiebt die ganze Seite ueber den rechten Rand hinaus. */
|
||||
.admin-shell { grid-template-columns: minmax(0, 1fr) }
|
||||
.shell-nav {
|
||||
position: static;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow: visible;
|
||||
gap: 6px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
/* Nur die Liste wischt; Konto und Abmelden bleiben rechts im Bild. */
|
||||
.shell-menu { flex: 1 1 auto; min-width: 0; overflow-x: auto }
|
||||
.shell-foot { flex: none }
|
||||
.shell-nav .brand { margin: 0 10px 0 0; font-size: 0 }
|
||||
.shell-nav .brand-icon { margin: 0; font-size: 15px }
|
||||
.nav-label { display: none }
|
||||
.nav-list { display: flex; gap: 4px }
|
||||
.nav-item { white-space: nowrap; padding: 8px 12px }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 14px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center }
|
||||
.shell-user strong { display: none }
|
||||
.shell-foot button { width: auto }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
|
||||
.shell-account { padding: 3px; gap: 2px }
|
||||
.shell-user { padding: 3px }
|
||||
.shell-user-text { display: none }
|
||||
.shell-version { padding: 0 }
|
||||
.shell-top { padding: 22px 20px 18px }
|
||||
.shell-body { padding: 20px 20px 48px }
|
||||
}
|
||||
@@ -550,6 +604,18 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.row.depth-1, .row.depth-2, .row.depth-3 { margin-left: 14px }
|
||||
.login-card { padding: 26px }
|
||||
.login-body { padding: 16px }
|
||||
/* Zwei Rasterspalten aus Bezeichner und Wert sind auf einem Handy eine Spalte zu viel. */
|
||||
.facts { grid-template-columns: minmax(0, 1fr); gap: 2px }
|
||||
.facts dt { margin-top: 10px }
|
||||
.facts dt:first-child { margin-top: 0 }
|
||||
/* Eine sechsspaltige Tabelle passt auf kein Handy. Jede Zeile wird zu einem Block, und die
|
||||
Spaltenueberschrift steht als Bezeichner vor ihrem Wert. */
|
||||
.table-stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%) }
|
||||
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100% }
|
||||
.table-stack tr { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-top: 10px }
|
||||
.table-stack tbody tr + tr td { border-top: 0 }
|
||||
.table-stack td { display: flex; gap: 14px; justify-content: space-between; align-items: baseline; padding: 5px 0; text-align: right }
|
||||
.table-stack td::before { content: attr(data-label); color: var(--muted); text-align: left; flex: none }
|
||||
}
|
||||
|
||||
/* A group of related checkboxes needs a name of its own, not just a label per box. */
|
||||
@@ -557,40 +623,70 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-set > legend { font-size: 12px; font-weight: 700; padding: 0 6px }
|
||||
.field-set .check-label { margin-top: 14px }
|
||||
|
||||
/* ---- statistics: charts drawn by the server, styled by class ---------------------------- */
|
||||
/* ---- statistics: tiles, charts drawn by the server, ranked lists ------------------------ */
|
||||
|
||||
.range-switch { display: flex; gap: 8px; flex-wrap: wrap }
|
||||
.range-switch a { font-size: 12px; padding: 8px 14px; min-height: 36px }
|
||||
.chart { display: block; width: 100%; height: 180px; overflow: visible }
|
||||
/* Der Zeitraum als eine Leiste mit einem gedrueckten Knopf, nicht vier lose Schaltflaechen. */
|
||||
.range-switch { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper) }
|
||||
.range-switch a { display: inline-flex; align-items: center; min-height: 32px; padding: 6px 13px; border-radius: 7px; font-size: 12px; font-weight: 600; text-decoration: none; color: var(--muted) }
|
||||
.range-switch a:hover { color: var(--ink); background: #eef2ea }
|
||||
.range-switch a[aria-current=page] { background: var(--shell); color: #fff }
|
||||
/* Die Kennzahlen: eine grosse dunkle Kachel fuer die Summe, kleine helle fuer alles, was sie einordnet. */
|
||||
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); gap: 12px }
|
||||
.stat-tile { display: grid; align-content: start; gap: 6px; min-width: 0; padding: 16px 18px; background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
.stat-tile-main { grid-column: span 2; background: var(--shell); border-color: var(--shell); color: var(--shell-ink) }
|
||||
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted) }
|
||||
.stat-value { font-size: 28px; font-weight: 650; letter-spacing: -.8px; line-height: 1.1; font-variant-numeric: tabular-nums }
|
||||
.stat-note { font-size: 12px; line-height: 1.45; color: var(--muted); overflow-wrap: anywhere }
|
||||
.stat-tile-main .stat-label, .stat-tile-main .stat-note { color: var(--shell-muted) }
|
||||
.stat-tile-main .stat-value { font-size: 40px; color: #fff }
|
||||
.stat-tile-main .stat-up { color: #bfe3a5 }
|
||||
.stat-tile-main .stat-down { color: #f3b9a8 }
|
||||
.chart { display: block; width: 100%; height: 190px; overflow: visible }
|
||||
.chart-hours, .chart-weekdays { height: 150px }
|
||||
.chart-bar { fill: var(--accent) }
|
||||
.chart-bar:hover { fill: #1c4735 }
|
||||
.chart-axis { stroke: var(--line); stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-bar-peak { fill: var(--ring) }
|
||||
.chart-grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 3 3 }
|
||||
.chart-axis { stroke: #c3cec7; stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; gap: 8px; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-legend-even span { flex: 1; text-align: center }
|
||||
.chart-table { margin-top: 18px }
|
||||
.chart-table > summary { cursor: pointer; font-size: 12px; font-weight: 600; padding: 6px 0 }
|
||||
.table-wrap { max-height: 320px; overflow: auto; margin-top: 10px }
|
||||
.stats-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px }
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px; counter-reset: rank }
|
||||
.stats-columns-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)) }
|
||||
/* Rang, Name, Anteil am Spitzenwert als Balken, Zahl. */
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px }
|
||||
.stats-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) 72px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-top: 1px solid #eef1ed;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stats-list li:first-child { border-top: 0 }
|
||||
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
|
||||
.stats-rank { font-size: 11px; font-weight: 700; color: var(--muted); text-align: center }
|
||||
.stats-list span:not(.stats-rank), .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-share { display: block; width: 100%; height: 6px; overflow: visible }
|
||||
.stats-share-bar { fill: var(--accent); opacity: .5 }
|
||||
.stats-list li:first-child .stats-share-bar { opacity: 1 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right }
|
||||
@media (max-width: 620px) {
|
||||
.stat-tile-main { grid-column: 1 / -1 }
|
||||
.stats-list li { grid-template-columns: 22px minmax(0, 1fr) auto }
|
||||
.stats-share { display: none }
|
||||
}
|
||||
|
||||
/* ---- system: preflight, update steps, log ---------------------------------------------- */
|
||||
|
||||
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts dt { color: var(--muted) }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
|
||||
.check-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -605,7 +701,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.check-bad { color: var(--danger) }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
|
||||
|
||||
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||
|
||||
@@ -701,9 +796,8 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
|
||||
.shell-version { display: none }
|
||||
.shell-user strong { display: none }
|
||||
}
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
@@ -726,14 +820,134 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
|
||||
|
||||
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
|
||||
.form-def{min-width:0}.field-table{table-layout:fixed;width:100%}.field-table input:not([type=checkbox]),.field-table select{width:100%;min-width:0;max-width:100%}
|
||||
@media(max-width:1100px){
|
||||
.field-table,.field-table tbody{display:block}.field-table thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)}
|
||||
.field-table tbody{display:grid;gap:16px}.field-table tr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px}
|
||||
.field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left}
|
||||
}
|
||||
@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}}
|
||||
.form-def{min-width:0;margin:0}
|
||||
|
||||
.version-update{display:block;width:fit-content;margin-top:8px;padding:5px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
|
||||
.version-update{display:inline-block;padding:4px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
|
||||
|
||||
.menu-entry summary::after{content:none}.menu-entry summary{list-style:none;gap:10px;padding:10px 14px}.menu-entry summary::-webkit-details-marker{display:none}.menu-drag-handle{display:grid;place-items:center;width:36px;min-width:36px;height:40px;padding:0;border:0;background:transparent;color:#576e60;cursor:grab;touch-action:none}.menu-drag-handle:active{cursor:grabbing}.menu-chevron{flex:none;color:#52665a;transition:transform .15s}.menu-entry details[open] .menu-chevron{transform:rotate(180deg)}.menu-entry-kind{font-size:11px;border:1px solid #dae4dc;border-radius:5px;padding:3px 6px;color:#506659;background:#f7f9f6;white-space:nowrap}.menu-entry-title{display:flex;align-items:center;gap:8px;min-width:0}.menu-entry-title svg{width:19px;height:19px;flex:none}.menu-dragging{opacity:.6;box-shadow:0 4px 16px #203b3426;border-color:#2c6b4f}.menu-icon-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}@media(max-width:560px){.menu-entry summary{padding:8px}.menu-entry-kind{display:none}.menu-icon-fields{grid-template-columns:1fr}}
|
||||
|
||||
/* ---- the form editor: a card per field, the look, and a preview beside it -------------- */
|
||||
|
||||
.form-screens { display: grid; gap: 20px }
|
||||
.form-editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 20px 36px; padding-top: 2px }
|
||||
.form-editor-main { min-width: 0 }
|
||||
.form-part { margin: 26px 0 0; font-size: 11px; font-weight: 700; letter-spacing: .9px; text-transform: uppercase; color: var(--muted) }
|
||||
.form-editor-main > .form-part:first-child, .form-editor-preview .form-part, .type-preview-column .form-part { margin-top: 14px }
|
||||
.form-fields-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 28px }
|
||||
.form-fields-head .form-part { margin: 0 }
|
||||
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
|
||||
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
|
||||
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
|
||||
.field-card { position: relative; padding: 2px 16px 16px; border: 1px solid var(--line); border-radius: 11px; background: #fafbf8 }
|
||||
.field-card-new { border-style: dashed; background: var(--paper) }
|
||||
.field-card-head { display: grid; grid-template-columns: 26px minmax(0, 1fr) 190px 38px; gap: 0 12px; align-items: end }
|
||||
.field-card-index { display: grid; place-items: center; width: 26px; height: 26px; margin-bottom: 8px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card label { margin-top: 12px }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 38px }
|
||||
.field-card-body .field-help { margin-top: 5px }
|
||||
.field-card-flags { align-self: end; padding-bottom: 11px }
|
||||
.field-card-flags .check-label { margin: 12px 0 0 }
|
||||
.field-remove { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 2px; padding: 0; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted) }
|
||||
.field-remove .nav-icon { width: 17px; height: 17px; color: inherit }
|
||||
.field-remove:hover { background: #fff0eb; border-color: #e2b2a7; color: var(--danger) }
|
||||
/* Die Vorschau bleibt beim Blaettern durch lange Feldlisten im Bild. */
|
||||
.form-editor-preview { min-width: 0; position: sticky; top: 16px; align-self: start }
|
||||
/* Die Vorschau uebernimmt Text- und Seitenfarbe der Website per Skript; die Felder lesen dieselben
|
||||
Variablen wie auf der Website selbst. */
|
||||
.form-preview { display: grid; gap: 16px; margin-top: 12px; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; font-size: 14px; line-height: 1.5 }
|
||||
.form-preview-field { display: grid; gap: 6px }
|
||||
.form-preview-label { font-weight: 600 }
|
||||
.form-preview-input { display: block; min-height: 40px; padding: 9px 12px; border: 1px solid var(--form-border, currentColor); border-radius: 8px; background: var(--form-bg, #fff); color: inherit }
|
||||
.form-preview-select { display: flex; align-items: center; justify-content: space-between }
|
||||
.form-preview-select::after { content: '⌄'; font-size: 16px; line-height: 1 }
|
||||
.form-preview-area { min-height: 92px }
|
||||
.form-preview-check { display: flex; align-items: flex-start; gap: 9px }
|
||||
.form-preview-box { flex: none; width: 18px; height: 18px; margin-top: 2px; border: 1px solid var(--form-border, currentColor); border-radius: 4px; background: var(--form-bg, #fff) }
|
||||
.form-preview-help { font-size: 12px }
|
||||
.form-preview-step { padding-top: 6px; border-top: 1px solid var(--line); display: grid; gap: 2px }
|
||||
.form-preview-step-title { font-weight: 700; font-size: 15px }
|
||||
.form-preview-radio { border-radius: 50% }
|
||||
.form-preview-number { display: flex; align-items: center; justify-content: flex-end; opacity: .7 }
|
||||
.form-preview-conditional { padding-left: 10px; border-left: 2px dashed var(--line) }
|
||||
.field-card-number { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 12px }
|
||||
.field-card-condition { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px }
|
||||
.field-card-condition .field-help { grid-column: 1 / -1 }
|
||||
.field-card-images { grid-column: 1 / -1 }
|
||||
.field-image-pickers { display: grid; gap: 6px; margin-top: 6px }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker select { margin: 0 }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
.form-editor-foot { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.form-editor-foot .muted { font-size: 12px }
|
||||
.form-look .color-control { margin-top: 0 }
|
||||
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
|
||||
@media (max-width: 700px) {
|
||||
.field-card { padding-top: 8px }
|
||||
.field-card-head { grid-template-columns: 26px minmax(0, 1fr) }
|
||||
.field-card-title, .field-card-type { grid-column: 2 }
|
||||
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
|
||||
.field-card-body { padding-left: 0 }
|
||||
}
|
||||
|
||||
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
|
||||
|
||||
.theme-form > .card { margin: 0 }
|
||||
.palette-presets { margin-top: 14px }
|
||||
.palette-presets-label { display: block; margin-bottom: 8px; font-size: 12px; font-weight: 600 }
|
||||
.palette-preset-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.palette-preset { display: grid; gap: 6px; justify-items: center; padding: 8px 10px 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); font-size: 11px; font-weight: 600 }
|
||||
.palette-preset svg { display: block; width: 90px; height: 24px; border-radius: 6px; overflow: hidden; box-shadow: inset 0 0 0 1px #203b3426 }
|
||||
.palette-preset:hover { border-color: var(--accent); background: #f5f8f1 }
|
||||
.palette-presets .field-help { margin-top: 10px }
|
||||
.swatch-list { list-style: none; margin: 18px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px }
|
||||
.swatch-card { position: relative; display: grid; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.swatch-card-new { border-style: dashed }
|
||||
/* Der Farbwaehler ist die Flaeche selbst: nichts liegt dazwischen. */
|
||||
.swatch-well { position: relative; display: grid; margin: 0; cursor: pointer }
|
||||
.swatch-input { display: block; width: 100%; height: 76px; min-height: 0; padding: 0; border: 0; border-radius: 0; background: none; cursor: pointer }
|
||||
.swatch-input::-webkit-color-swatch-wrapper { padding: 0 }
|
||||
.swatch-input::-webkit-color-swatch { border: 0; border-radius: 0 }
|
||||
.swatch-input::-moz-color-swatch { border: 0 }
|
||||
.swatch-hex { position: absolute; left: 10px; bottom: 8px; padding: 2px 7px; border-radius: 5px; background: #ffffffe6; color: var(--ink); font-size: 11px; box-shadow: 0 1px 2px #0000001f }
|
||||
.swatch-fields { display: grid; padding: 0 12px 12px }
|
||||
.swatch-fields label { margin: 10px 0 4px; font-size: 11px }
|
||||
.swatch-fields input { min-height: 36px; padding: 6px 10px; font-size: 13px }
|
||||
.swatch-card .field-remove { position: absolute; top: 6px; right: 6px; width: 32px; height: 32px; background: #ffffffe6 }
|
||||
.list-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px }
|
||||
.list-actions .field-help { flex: 1 1 300px; margin: 0 }
|
||||
.font-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px }
|
||||
.font-card { position: relative; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.font-card-new { border-style: dashed }
|
||||
/* Die Probe steht in der Schrift selbst - das Skript setzt die Familie, die Datei liegt auf dem Server. */
|
||||
.font-sample { display: flex; align-items: center; gap: 18px; min-height: 88px; padding: 18px 60px 18px 22px; background: #fafbf8; border-bottom: 1px solid var(--line) }
|
||||
.font-sample-big { flex: none; font-size: 44px; line-height: 1; font-weight: 500 }
|
||||
.font-sample-line { display: grid; gap: 6px; min-width: 0 }
|
||||
.font-sample-text { font-size: 19px; line-height: 1.3; overflow-wrap: anywhere }
|
||||
.font-sample-weights { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted) }
|
||||
.font-weight-100 { font-weight: 100 } .font-weight-200 { font-weight: 200 } .font-weight-300 { font-weight: 300 }
|
||||
.font-weight-400 { font-weight: 400 } .font-weight-500 { font-weight: 500 } .font-weight-600 { font-weight: 600 }
|
||||
.font-weight-700 { font-weight: 700 } .font-weight-800 { font-weight: 800 } .font-weight-900 { font-weight: 900 }
|
||||
.font-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0 16px; padding: 0 22px 18px }
|
||||
.font-fields label { margin-top: 12px }
|
||||
.font-weights { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 0; padding: 0; border: 0 }
|
||||
.font-weights legend { float: left; margin-right: 8px; padding: 0; font-size: 12px; font-weight: 600 }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 5px 10px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); font-size: 12px; font-weight: 500; cursor: pointer }
|
||||
.chip input { width: 14px; min-height: 14px; margin: 0 }
|
||||
.chip:has(input:checked) { border-color: var(--accent); background: #edf4e8; color: #22553e }
|
||||
.font-card .field-remove { position: absolute; top: 10px; right: 10px }
|
||||
.type-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); gap: 16px 36px }
|
||||
.type-fields { min-width: 0 }
|
||||
.type-colors .color-control { margin-top: 0 }
|
||||
.type-preview-column { min-width: 0 }
|
||||
.type-preview { margin-top: 12px; padding: 26px 26px 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; overflow: hidden }
|
||||
.type-preview p { margin: 0 }
|
||||
.type-preview .type-preview-h1 { margin-bottom: 10px; font-size: 34px; font-weight: 700; line-height: 1.15; letter-spacing: -.5px }
|
||||
.type-preview .type-preview-h2 { margin-bottom: 14px; font-size: 26px; font-weight: 600; line-height: 1.2 }
|
||||
.type-preview-p { font-size: 17px; line-height: 1.6 }
|
||||
.type-preview-link { text-decoration: underline; text-underline-offset: 3px }
|
||||
.theme-save { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
@@ -35,7 +35,7 @@ const TOOLS = [
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop, onResize = () => {} }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
@@ -205,6 +205,7 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
placeResizers();
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) {
|
||||
@@ -213,6 +214,124 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Column widths. Umut: "am besten per Drag and Drop im Editor".
|
||||
//
|
||||
// The border between two columns of the selected row is a handle. Dragging it moves share from
|
||||
// one neighbour to the other; the row is restyled inline while the pointer moves, and the
|
||||
// result is written to the document once, when the pointer is released. The arrow keys do the
|
||||
// same in steps of five, so the widths are reachable without a mouse.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
const resizers = [];
|
||||
let focusResizer = null;
|
||||
|
||||
/** The row whose borders can be dragged: the selected row, or the row around the selected column. */
|
||||
function resizableRow() {
|
||||
const item = getSelected() ? locate(getDocument().blocks, getSelected()) : null;
|
||||
if (!item) return null;
|
||||
if (item.block.type === 'row') return item.block;
|
||||
return item.parent?.type === 'row' ? item.parent : null;
|
||||
}
|
||||
|
||||
/** The measured widths of the columns as shares of their sum - the truth a drag starts from. */
|
||||
function sharesOf(rowId) {
|
||||
const kids = childElements(rowId);
|
||||
const total = kids.reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
return total > 0 ? kids.map(kid => kid.box.width / total * 100) : null;
|
||||
}
|
||||
|
||||
/** Moves `delta` points of share from the right neighbour to the left one; neither may vanish. */
|
||||
function shift(shares, index, delta) {
|
||||
const pair = shares[index] + shares[index + 1];
|
||||
const least = Math.min(10, pair / 2);
|
||||
const left = Math.max(least, Math.min(pair - least, shares[index] + delta));
|
||||
const next = shares.slice();
|
||||
next[index] = left;
|
||||
next[index + 1] = pair - left;
|
||||
return next;
|
||||
}
|
||||
|
||||
const tenth = share => Math.round(share * 10) / 10;
|
||||
const formatShares = shares => shares.map(share => String(tenth(share))).join(' ');
|
||||
const tracksOf = shares => shares.map(share => `minmax(0,${tenth(share)}fr)`).join(' ');
|
||||
|
||||
function placeResizers() {
|
||||
const row = dragging ? null : resizableRow();
|
||||
const kids = row ? childElements(row.id) : [];
|
||||
// Stacked columns - a phone, or a single-column layout - have no border to drag.
|
||||
const sideBySide = kids.length > 1 && kids.every((kid, index) => index === 0 || kid.box.left >= kids[index - 1].box.right - 1);
|
||||
const wanted = sideBySide ? kids.length - 1 : 0;
|
||||
while (resizers.length > wanted) resizers.pop().remove();
|
||||
while (resizers.length < wanted) resizers.push(resizeButton());
|
||||
const width = 14 / getScale();
|
||||
resizers.forEach((button, index) => {
|
||||
const left = kids[index].box, right = kids[index + 1].box;
|
||||
button.dataset.row = row.id;
|
||||
button.dataset.index = String(index);
|
||||
const label = `Breite zwischen Spalte ${index + 1} und ${index + 2} ändern`;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.title = `${label}: ziehen oder Pfeiltasten`;
|
||||
button.style.left = `${(left.right + right.left) / 2 - width / 2}px`;
|
||||
button.style.top = `${Math.min(left.top, right.top)}px`;
|
||||
button.style.width = `${width}px`;
|
||||
button.style.height = `${Math.max(left.bottom, right.bottom) - Math.min(left.top, right.top)}px`;
|
||||
if (focusResizer && focusResizer.row === row.id && focusResizer.index === index) { button.focus(); focusResizer = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function resizeButton() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-resize';
|
||||
button.addEventListener('pointerdown', startResize);
|
||||
button.addEventListener('keydown', event => {
|
||||
const step = event.key === 'ArrowLeft' ? -5 : event.key === 'ArrowRight' ? 5 : 0;
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const shares = sharesOf(rowId);
|
||||
if (!shares) return;
|
||||
focusResizer = { row: rowId, index };
|
||||
onResize(rowId, formatShares(shift(shares, index, step)));
|
||||
});
|
||||
layer.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
if (event.button !== 0) return;
|
||||
const button = event.currentTarget;
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const start = sharesOf(rowId);
|
||||
const node = doc()?.getElementById(rowId);
|
||||
if (!start || !node) return;
|
||||
event.preventDefault();
|
||||
button.setPointerCapture(event.pointerId);
|
||||
layer.classList.add('is-resizing');
|
||||
// Pointer pixels are editor pixels; the shares live in the frame's scale, on the summed
|
||||
// width of the columns without the gaps between them.
|
||||
const content = childElements(rowId).reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
const startX = event.clientX;
|
||||
let current = start;
|
||||
const move = moved => {
|
||||
current = shift(start, index, (moved.clientX - startX) / getScale() / content * 100);
|
||||
node.style.gridTemplateColumns = tracksOf(current);
|
||||
sync();
|
||||
};
|
||||
const finish = () => {
|
||||
button.removeEventListener('pointermove', move);
|
||||
button.removeEventListener('pointerup', finish);
|
||||
button.removeEventListener('pointercancel', finish);
|
||||
layer.classList.remove('is-resizing');
|
||||
if (formatShares(current) !== formatShares(start)) onResize(rowId, formatShares(current));
|
||||
else sync();
|
||||
};
|
||||
button.addEventListener('pointermove', move);
|
||||
button.addEventListener('pointerup', finish);
|
||||
button.addEventListener('pointercancel', finish);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@ const node = (tag, className, text) => {
|
||||
};
|
||||
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||||
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) {
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete = () => {} }) {
|
||||
const wrap = node('div', 'color-control');
|
||||
const trigger = node('button', 'color-trigger');
|
||||
trigger.type = 'button'; trigger.id = id;
|
||||
|
||||
@@ -248,6 +248,23 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
/**
|
||||
* The column shares of a row, "2 1" for two thirds and a third. Dragging the border between two
|
||||
* columns in the preview writes the same value; the field is for anyone who prefers numbers.
|
||||
*/
|
||||
const fractionsControl = (id, value, apply) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = id;
|
||||
input.value = value ?? '';
|
||||
input.placeholder = 'gleich breit';
|
||||
input.pattern = String.raw`\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}`;
|
||||
input.setAttribute('inputmode', 'decimal');
|
||||
input.title = 'Anteile je Spalte, durch Leerzeichen getrennt: 2 1 oder 25 50 25';
|
||||
input.addEventListener('change', () => { if (input.checkValidity()) apply(input.value.trim()); });
|
||||
return input;
|
||||
};
|
||||
|
||||
const numberControl = (id, value, property, apply) => {
|
||||
const wrap = element('div', 'number-control');
|
||||
const slider = document.createElement('input');
|
||||
@@ -531,6 +548,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
|
||||
else if (property.type === 'fractions') control = fractionsControl(id, own ? current.value : '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"iconcard",
|
||||
"stat",
|
||||
"quote",
|
||||
"cta"
|
||||
"cta",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"left": [
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
.kant .d-tablet-radius-small{border-radius:8px}
|
||||
.kant .d-tablet-radius-large{border-radius:24px}
|
||||
}
|
||||
@media(max-width:650px){
|
||||
@media(max-width:767px){
|
||||
.kant .d-mobile-align-left{text-align:left;margin-inline:0}
|
||||
.kant .d-mobile-align-center{text-align:center;margin-inline:auto}
|
||||
.kant .d-mobile-align-right{text-align:right;margin-inline:auto 0}
|
||||
|
||||
@@ -30,10 +30,35 @@ export function whyNot(type) {
|
||||
}
|
||||
|
||||
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
||||
/**
|
||||
* Die Bausteine der aktiven Plugins, vom Server mit dem Dokument geliefert. Sie bekommen
|
||||
* Beschriftung, Symbol, Vorgaben und eine eigene Gruppe im Werkzeugkasten; was das Dokument
|
||||
* enthält, aber hier nicht steht, bleibt ein Baustein einer fehlenden Erweiterung.
|
||||
*/
|
||||
export const PLUGIN_BLOCKS = {};
|
||||
/** Die Seitenvorlagen der aktiven Plugins: id => {label, blocks}. */
|
||||
export const PLUGIN_PRESETS = {};
|
||||
export function registerPlugins(list, presets = []) {
|
||||
for (const key of Object.keys(PLUGIN_BLOCKS)) { delete PLUGIN_BLOCKS[key]; delete labels[key]; delete icons[key]; }
|
||||
for (const key of Object.keys(PLUGIN_PRESETS)) delete PLUGIN_PRESETS[key];
|
||||
for (const preset of presets || []) if (typeof preset?.id === 'string' && Array.isArray(preset.blocks)) PLUGIN_PRESETS[preset.id] = preset;
|
||||
const types = [];
|
||||
for (const definition of list || []) {
|
||||
if (!PLUGIN_TYPE.test(definition?.type || '')) continue;
|
||||
PLUGIN_BLOCKS[definition.type] = definition;
|
||||
labels[definition.type] = definition.label;
|
||||
icons[definition.type] = definition.icon || '⬡';
|
||||
types.push(definition.type);
|
||||
}
|
||||
const group = CATEGORIES.find(category => category.name === 'Erweiterungen');
|
||||
if (group) group.types = types;
|
||||
else if (types.length) CATEGORIES.push({ name: 'Erweiterungen', hint: 'Bausteine der eingeschalteten Plugins.', types });
|
||||
}
|
||||
export const pluginDefinition = block => PLUGIN_BLOCKS[block?.type] ?? null;
|
||||
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
|
||||
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
|
||||
export const blockIcon = block => icons[block?.type] ?? '❔';
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type);
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type) || PLUGIN_BLOCKS[block?.type]?.container === true;
|
||||
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
|
||||
// Containers that accept only one kind of child, mirrored by the server side validator.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -55,8 +80,12 @@ export const isContainer = block => ['section', 'row', 'column', 'gallery', 'log
|
||||
|
||||
/** Containers that take one kind of child and nothing else. */
|
||||
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
|
||||
/** Blocks that exist only inside one of these parents. */
|
||||
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* Blocks that exist only inside one of these parents. A column belongs to a row - and, as a
|
||||
* single one, directly to a section: that is how a button or a short text gets centred and
|
||||
* narrowed without a one-column row around it. Umut asked for exactly that.
|
||||
*/
|
||||
export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
|
||||
* hold one - but it is not a thing a document starts with. At the top level a page begins with
|
||||
@@ -64,8 +93,10 @@ export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem:
|
||||
*/
|
||||
export const ROOT_NEEDS_CONTAINER = ['row'];
|
||||
|
||||
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
|
||||
&& (ALLOW[parent.type] === undefined || ALLOW[parent.type].includes(block?.type))
|
||||
// Ein Plugin-Container nimmt auf, was sein Manifest erlaubt; ein Plugin ohne Definition nichts.
|
||||
const allowedIn = parent => PLUGIN_BLOCKS[parent?.type] ? (PLUGIN_BLOCKS[parent.type].children ?? undefined) : ALLOW[parent?.type];
|
||||
export const canContain = (parent, block) => isContainer(parent) && (!isPluginBlock(parent) || PLUGIN_BLOCKS[parent.type]?.container === true)
|
||||
&& (allowedIn(parent) === undefined || allowedIn(parent).includes(block?.type))
|
||||
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
|
||||
export const clone = value => structuredClone(value);
|
||||
|
||||
@@ -82,6 +113,11 @@ export function locate(blocks, id, parent = null) {
|
||||
export function createBlock(type) {
|
||||
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
|
||||
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
|
||||
if (PLUGIN_BLOCKS[type]) {
|
||||
block.props = Object.fromEntries(Object.entries(PLUGIN_BLOCKS[type].props || {}).map(([key, schema]) => [key, schema.default ?? '']));
|
||||
if (PLUGIN_BLOCKS[type].container) block.children = [];
|
||||
return block;
|
||||
}
|
||||
if (isContainer(block)) block.children = [];
|
||||
if (type === 'accordion') block.children = [createBlock('accordionitem')];
|
||||
// A single tab is not a tab group, and the script needs at least two to enhance anything.
|
||||
@@ -117,16 +153,22 @@ export function createBlock(type) {
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
if (PLUGIN_BLOCKS[type]) upgradeDocument(document, 4);
|
||||
// Die Fassung, die der Server fuer die Eigenschaften verlangt, mit denen createBlock den
|
||||
// Baustein anlegt - nicht die, in der der Typ zuerst auftauchte. Sonst steht ein neuer
|
||||
// Baustein sofort mit 422 in der Vorschau und laesst sich nicht speichern.
|
||||
if (type === 'postlist') upgradeDocument(document, 18);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
if (type === 'breadcrumb') upgradeDocument(document, 9);
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'menu') upgradeDocument(document, 16);
|
||||
if (type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
|
||||
if (type === 'icon' || type === 'progress') upgradeDocument(document, 15);
|
||||
if (type === 'slider') upgradeDocument(document, 17);
|
||||
if (type === 'social') upgradeDocument(document, 23);
|
||||
}
|
||||
|
||||
@@ -189,7 +231,9 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
|
||||
if (inside && !isContainer(target.block)) return false;
|
||||
if (inside && !canContain(target.block, source.block)) return false;
|
||||
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
|
||||
// Neben ein Element der obersten Ebene darf nur, was dort auch stehen darf - eine Spalte oder
|
||||
// ein Akkordeoneintrag nicht.
|
||||
if (!inside && !dropList(document, target.parent?.id ?? null, source.block)) return false;
|
||||
source.siblings.splice(source.index, 1);
|
||||
const destination = locate(document.blocks, targetId);
|
||||
if (inside) (destination.block.children ??= []).push(source.block);
|
||||
@@ -220,10 +264,18 @@ function place(document, block, parentId, index) {
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/** Inserts a new block at an exact position instead of behind the selection. */
|
||||
/**
|
||||
* Inserts a new block at an exact position instead of behind the selection.
|
||||
*
|
||||
* Erst pruefen, ob das Ziel den Baustein nimmt, dann die Fassung anheben: sonst bliebe bei einer
|
||||
* Ablehnung nur die Schemaaenderung uebrig - ein Verlaufsschritt, ein "ungespeichert", und nichts
|
||||
* zu sehen.
|
||||
*/
|
||||
export function insertBlockAt(document, type, parentId, index) {
|
||||
const block = createBlock(type);
|
||||
if (!dropList(document, parentId, block)) return null;
|
||||
upgradeForType(document, type);
|
||||
return place(document, createBlock(type), parentId, index);
|
||||
return place(document, block, parentId, index);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,12 +381,19 @@ export function mediaProps(media) {
|
||||
|
||||
export function insertMedia(document, selected, items) {
|
||||
const target = locate(document.blocks, selected)?.block;
|
||||
upgradeDocument(document, 3);
|
||||
if (target?.type === 'image') { target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' }; return target.id; }
|
||||
if (target?.type === 'image') {
|
||||
upgradeDocument(document, 3);
|
||||
target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' };
|
||||
return target.id;
|
||||
}
|
||||
let parent = target;
|
||||
if (target?.type !== 'gallery') {
|
||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||
// Ein Akkordeon nimmt nur Eintraege: dann gibt es keine Galerie und nichts zu tun.
|
||||
const id = insertBlock(document, 'gallery', selected);
|
||||
if (id === null) return null;
|
||||
parent = locate(document.blocks, id).block;
|
||||
}
|
||||
upgradeDocument(document, 3);
|
||||
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
|
||||
return parent.id;
|
||||
}
|
||||
@@ -395,21 +454,34 @@ function buildRow(columns) {
|
||||
|
||||
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
|
||||
export function insertRowAt(document, columns, parentId, index) {
|
||||
const row = buildRow(columns);
|
||||
if (!dropList(document, parentId, row)) return null;
|
||||
upgradeDocument(document);
|
||||
return place(document, buildRow(columns), parentId, index);
|
||||
return place(document, row, parentId, index);
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
const row = buildRow(columns);
|
||||
upgradeDocument(document);
|
||||
// Same placement rule as a single element, so the row does not land somewhere else than the
|
||||
// toolbox promised.
|
||||
const target = insertionTarget(document, selected, 'row');
|
||||
return target ? place(document, row, target.parentId, target.index) : null;
|
||||
if (!target) return null;
|
||||
upgradeDocument(document);
|
||||
return place(document, buildRow(columns), target.parentId, target.index);
|
||||
}
|
||||
|
||||
/** Frische Kennungen für eine Vorlage aus einem Plugin - jedes Einfügen ist ein eigener Baustein. */
|
||||
const freshIds = blocks => blocks.map(block => ({ ...structuredClone(block), id: `b_${crypto.randomUUID().replaceAll('-', '')}`, ...(Array.isArray(block.children) ? { children: freshIds(block.children) } : {}) }));
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
if (name === 'hero') return insertHero(document, selected);
|
||||
if (PLUGIN_PRESETS[name]) {
|
||||
const blocks = freshIds(PLUGIN_PRESETS[name].blocks);
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
|
||||
document.blocks.splice(index, 0, ...blocks);
|
||||
upgradeDocument(document, SCHEMA_VERSION);
|
||||
return blocks[0].id;
|
||||
}
|
||||
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
|
||||
const section = createBlock('section');
|
||||
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
|
||||
@@ -443,6 +515,14 @@ export class EditorHistory {
|
||||
this.mergeKey = null;
|
||||
this.saved = JSON.stringify(document);
|
||||
}
|
||||
/** Uebernimmt die vom Server bereinigte Fassung als aktuellen Stand, der Verlauf bleibt. */
|
||||
adopt(document) {
|
||||
this.past.push(this.document);
|
||||
if (this.past.length > 100) this.past.shift();
|
||||
this.document = clone(document);
|
||||
this.future = [];
|
||||
this.mergeKey = null;
|
||||
}
|
||||
change(mutator, mergeKey = null) {
|
||||
const next = clone(this.document);
|
||||
const result = mutator(next);
|
||||
@@ -574,6 +654,7 @@ export function relevantProperty(block, key, property) {
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (key === 'columnWidths') return block.type === 'row';
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
|
||||
return true;
|
||||
}
|
||||
+30
-10
@@ -3,6 +3,9 @@
|
||||
|
||||
The layout follows the Flatsome UX Builder: a dark bar on top, one sidebar on the left that
|
||||
shows one thing at a time, and the page itself filling the rest. */
|
||||
/* Die Farbe fuer "hier steht ein eigener Wert". Dunkler als der Fokusring, damit weisse
|
||||
Schrift darauf die Kontrastschwelle sicher nimmt (5,6:1). */
|
||||
:root{--used:#9d4e0d}
|
||||
dialog{max-width:450px;width:calc(100% - 40px);padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
|
||||
dialog::backdrop{background:#172e2966}
|
||||
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
@@ -32,7 +35,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
#media-status{font-size:12px;margin-block:15px}
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
|
||||
/* ---- shell ------------------------------------------------------------------------------- */
|
||||
html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
@@ -185,8 +188,10 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.design-row{margin-top:14px}
|
||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
/* Belegte Zeilen tragen dieselbe Farbe wie die belegten Geraetechips - eine Farbe, eine
|
||||
Bedeutung: hier steht ein eigener Wert und nicht der geerbte. */
|
||||
.design-own .design-row-head label{color:var(--used);font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--used)}
|
||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||
.length-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:6px}
|
||||
@@ -210,12 +215,21 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
|
||||
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
|
||||
.side-unit-select{flex:1;min-width:0}
|
||||
/* The device chips on a single option: which devices carry an own value, and switch to one. */
|
||||
.device-chips{display:flex;gap:2px;margin-left:auto}
|
||||
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.device-chip:hover{border-color:var(--accent)}
|
||||
.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)}
|
||||
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
|
||||
/* The device chips on a single option: which devices carry an own value, and switch to one.
|
||||
|
||||
Zwei Zustaende, zwei Mittel. "Belegt" - dieses Geraet traegt einen eigenen Wert - ist die
|
||||
Auskunft, die man ueber die ganze Liste hinweg sucht, und bekommt deshalb die kraeftige Farbe.
|
||||
"Gerade bearbeitet" ist ein Rahmen, und zwar ein Rahmen INNEN: ein `outline` liegt ausserhalb
|
||||
des Kaestchens, laeuft bei 2 px Abstand unter den Nachbarn und wird von der seitlich
|
||||
beschnittenen Spalte gekappt - es sah aus wie ein abgeschnittener roter Kasten. */
|
||||
.device-chips{display:flex;flex:none;gap:6px;margin-left:auto}
|
||||
.device-chip{width:22px;height:22px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--muted);font:700 10px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.device-chip:hover{border-color:var(--ink)}
|
||||
.device-chip-own{background:var(--used);border-color:var(--used);color:#fff}
|
||||
.device-chip[aria-pressed=true]{border-width:2px;border-color:var(--ink)}
|
||||
.device-chip-own[aria-pressed=true]{border-color:var(--ink);box-shadow:inset 0 0 0 1px #fff}
|
||||
/* Der Fokusring bleibt innerhalb der Luecke zwischen zwei Chips - nichts liegt uebereinander. */
|
||||
.device-chip:focus-visible{outline-width:2px;outline-offset:0}
|
||||
.design-side-head{margin-bottom:5px}
|
||||
.design-side-head .design-group-label{margin:0}
|
||||
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
|
||||
@@ -277,8 +291,14 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
||||
/* The border between two columns of the selected row: a grab area with a short bar in it. */
|
||||
.ov-resize{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:0;background:transparent;cursor:col-resize;pointer-events:auto;touch-action:none}
|
||||
.ov-resize::before{content:"";width:4px;height:40px;max-height:60%;border-radius:2px;background:var(--ring,#b55b12);box-shadow:0 0 0 1px #fff8;opacity:.55;transition:opacity .12s}
|
||||
.ov-resize:hover::before,.ov-resize:focus-visible::before,.canvas-overlay.is-resizing .ov-resize::before{opacity:1}
|
||||
.ov-resize:focus-visible{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.canvas-overlay.is-resizing .ov-hover,.canvas-overlay.is-resizing .ov-toolbar,.canvas-overlay.is-resizing .ov-insert{display:none}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert,.canvas-overlay.is-dragging .ov-resize{display:none}
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* ---- narrow screens ---------------------------------------------------------------------- */
|
||||
|
||||
+91
-14
@@ -8,7 +8,7 @@ import { preview, rowPreview } from './library-previews.js';
|
||||
import { decorateContainers } from './preview-containers.js';
|
||||
import { initMotion } from './motion.js';
|
||||
import { initSliders } from './slider.js';
|
||||
import { VISIBILITY, setVisibility } from './editor-model.js';
|
||||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS } from './editor-model.js';
|
||||
|
||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
||||
@@ -123,12 +123,20 @@ function controls() {
|
||||
$('#move-up').disabled ||= current.index === 0;
|
||||
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||
$('#outdent').disabled ||= !current.parent;
|
||||
// Ausruecken heisst: neben den Elternblock. Was dort nicht stehen darf (eine Spalte, ein
|
||||
// Akkordeoneintrag), bleibt, wo es ist.
|
||||
$('#outdent').disabled ||= !current.parent || !dropList(history.document, locate(history.document.blocks, current.parent.id)?.parent?.id ?? null, current.block);
|
||||
$('#go-parent').disabled ||= !current.parent;
|
||||
}
|
||||
updateLibrary();
|
||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||
// Waehrend des Speicherns ist alles gesperrt - und danach nur das wieder frei, was diese
|
||||
// Sperre geschlossen hat. Das Design-Bedienfeld schaltet Knoepfe gezielt ab ("Gestaltung
|
||||
// einfuegen" ohne Zwischenablage), und die muessen abgeschaltet bleiben.
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => {
|
||||
if (busy) { if (!input.disabled) { input.disabled = true; input.dataset.busyLock = '1'; } }
|
||||
else if (input.dataset.busyLock) { input.disabled = false; delete input.dataset.busyLock; }
|
||||
});
|
||||
document.querySelectorAll('[data-panel],[data-viewport],.sidebar-nav [role=tab],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
@@ -235,7 +243,7 @@ function renderProperties() {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = 'crumb'; button.textContent = item.type ? blockLabel(item) : 'Seite';
|
||||
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
|
||||
}
|
||||
if (block && isPluginBlock(block)) {
|
||||
if (block && isPluginBlock(block) && !pluginDefinition(block)) {
|
||||
// The core has no schema for a plugin block, so it offers structure actions only and never
|
||||
// touches its stored properties.
|
||||
const note = document.createElement('p'); note.className = 'panel-note';
|
||||
@@ -281,7 +289,7 @@ function renderContent(block, panel) {
|
||||
for (const [val, text] of Object.entries(options.choices)) { const option = document.createElement('option'); option.value = val; option.textContent = text; input.append(option); }
|
||||
} else input = document.createElement(options.multiline ? 'textarea' : 'input');
|
||||
input.id = id; input.value = value ?? '';
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min ?? '1'; input.max = options.max ?? '8000'; }
|
||||
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||||
if (options.maxLength) input.maxLength = options.maxLength;
|
||||
let previousChoice = input.value;
|
||||
@@ -309,6 +317,7 @@ function renderContent(block, panel) {
|
||||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||
if (key === 'collapse') upgradeDocument(doc, 22);
|
||||
if (key === 'faq') upgradeDocument(doc, 23);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||
@@ -328,6 +337,8 @@ function renderContent(block, panel) {
|
||||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||||
return;
|
||||
}
|
||||
const plugin = pluginDefinition(block);
|
||||
if (plugin) { renderPluginFields(block, panel, plugin, field); return; }
|
||||
if (block.type === 'text') {
|
||||
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
|
||||
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
|
||||
@@ -338,7 +349,7 @@ function renderContent(block, panel) {
|
||||
if (current) current.props.text = markup;
|
||||
}, `rich-${block.id}`);
|
||||
controls(); schedulePreview();
|
||||
}, { label: 'Text' });
|
||||
}, { label: 'Text', plain: history.document.schemaVersion < 12 });
|
||||
const label = document.createElement('label'); label.textContent = 'Text';
|
||||
const help = document.createElement('p'); help.className = 'field-help';
|
||||
help.textContent = 'Fett, kursiv, Listen und Links. Über HTML lässt sich die Auszeichnung direkt bearbeiten. Der Server lässt nur eine feste Liste an Auszeichnungen durch; alles andere wird beim Speichern entfernt, der Text bleibt.';
|
||||
@@ -430,11 +441,11 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'progress') {
|
||||
field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Wert (%)', 'value', block.props.value, { number: true, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
|
||||
field('Wert (%)', 'value', block.props.value, { number: true, min: 0, max: 100, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
|
||||
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
|
||||
}
|
||||
if (block.type === 'slider') {
|
||||
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
|
||||
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, min: 0, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
|
||||
field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' });
|
||||
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
|
||||
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
|
||||
@@ -452,6 +463,7 @@ function renderContent(block, panel) {
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
|
||||
const hint = document.createElement('p'); hint.className = 'field-help';
|
||||
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
|
||||
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
|
||||
@@ -580,7 +592,6 @@ function renderAdvanced(block, panel) {
|
||||
: 'Eigenes Skript kann nur ein Administrator ändern, weil es Code im Browser jedes Besuchers ausführt. Der Inhalt ist hier nur zu sehen.', 7);
|
||||
if (!mayScript) {
|
||||
scriptInput.readOnly = true;
|
||||
scriptInput.setAttribute('aria-describedby', 'own-script-locked');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +667,7 @@ function renderLegacyTokens(block, panel) {
|
||||
// Token based values from documents written before schema v10. Shown only while a block
|
||||
// still carries them, so older pages stay editable without cluttering the new panel.
|
||||
const hint = document.createElement('p'); hint.className = 'design-scope';
|
||||
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
|
||||
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 767 px'}`;
|
||||
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
|
||||
panel.append(hint, help);
|
||||
for (const [key, token] of Object.entries(designTokens)) {
|
||||
@@ -705,11 +716,42 @@ function renderLegacyEffects(block, panel) {
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Felder eines Plugin-Bausteins, aus dem Schema seines Manifests erzeugt. Dieselbe
|
||||
* Feldfabrik wie für Kernbausteine; nur ein Bild braucht die Mediathek.
|
||||
*/
|
||||
function renderPluginFields(block, panel, plugin, field) {
|
||||
if (plugin.description) { const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = plugin.description; panel.append(hint); }
|
||||
for (const [key, schema] of Object.entries(plugin.props || {})) {
|
||||
const value = block.props?.[key] ?? schema.default;
|
||||
const options = { help: schema.help || undefined };
|
||||
if (schema.kind === 'textarea') field(schema.label, key, value, { ...options, multiline: true, maxLength: schema.maxLength });
|
||||
else if (schema.kind === 'number') field(schema.label, key, value, { ...options, number: true, min: schema.min, max: schema.max });
|
||||
else if (schema.kind === 'boolean') field(schema.label, key, value === true, { ...options, checkbox: true });
|
||||
else if (schema.kind === 'select') field(schema.label, key, value, { ...options, choices: schema.choices });
|
||||
else if (schema.kind === 'media') {
|
||||
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
|
||||
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = schema.label; wrap.append(label);
|
||||
const preview = createMediaPreview({ mediaId: value || '' }); wrap.append(preview.element);
|
||||
const set = id => { history.change(doc => { const current = locate(doc.blocks, block.id)?.block; if (current) (current.props ??= {})[key] = id; }); renderProperties(); changed(); };
|
||||
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild aus Mediathek wählen';
|
||||
pick.addEventListener('click', () => openMedia({ multiple: false, onChoose: items => { if (items[0]?.id) set(items[0].id); } }));
|
||||
wrap.append(pick);
|
||||
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
|
||||
if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); }
|
||||
panel.append(wrap);
|
||||
}
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength });
|
||||
}
|
||||
}
|
||||
|
||||
function chooseMedia(multiple) {
|
||||
const target = selected;
|
||||
openMedia({ multiple, onChoose: items => {
|
||||
if (!items.length || busy) return;
|
||||
selected = history.change(doc => insertMedia(doc, target, items)); changed();
|
||||
const id = history.change(doc => insertMedia(doc, target, items));
|
||||
if (id === null) { notify('Hier lassen sich keine Bilder einfügen – wähle eine Sektion, Spalte oder Galerie.', true); return; }
|
||||
selected = id; changed();
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -1001,6 +1043,9 @@ async function load() {
|
||||
if (!designSchema) await loadDesignSchema();
|
||||
history = new EditorHistory(data.document);
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
|
||||
// bekommt dafür eine eigene Gruppe.
|
||||
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
@@ -1013,7 +1058,13 @@ async function save(publish = false) {
|
||||
try {
|
||||
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
history.markSaved(snapshot);
|
||||
// Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der
|
||||
// Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren.
|
||||
if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) {
|
||||
history.adopt(data.document);
|
||||
history.markSaved(data.document);
|
||||
renderTree(); renderProperties(); schedulePreview();
|
||||
} else history.markSaved(snapshot);
|
||||
notify();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
@@ -1090,6 +1141,23 @@ function markInsertTarget() {
|
||||
// editor describes in its own hint was not the structure the buttons followed.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Die Seitenvorlagen der Plugins, hinter den eingebauten, mit demselben Knopf. */
|
||||
function buildPluginPresets() {
|
||||
const presets = $('#presets');
|
||||
presets.querySelectorAll('[data-plugin-preset]').forEach(button => button.remove());
|
||||
for (const preset of Object.values(PLUGIN_PRESETS)) {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.dataset.preset = preset.id; button.dataset.pluginPreset = '';
|
||||
button.title = preset.description ? `${preset.description} (${preset.plugin})` : preset.plugin;
|
||||
const icon = document.createElement('span'); icon.setAttribute('aria-hidden', 'true'); icon.textContent = '⬡';
|
||||
button.append(icon, document.createTextNode(` ${preset.label}`));
|
||||
presets.append(button);
|
||||
}
|
||||
controls();
|
||||
}
|
||||
|
||||
/** Die Zeichnung für einen Plugin-Baustein im Werkzeugkasten: ein Steckstück. */
|
||||
const PLUGIN_PREVIEW = '<svg viewBox="0 0 64 40" aria-hidden="true"><path d="M20 8h-8v24h40V8h-8v4a4 4 0 0 1-8 0V8h-8v4a4 4 0 0 1-8 0Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>';
|
||||
|
||||
function buildLibrary() {
|
||||
const library = $('#library');
|
||||
library.replaceChildren();
|
||||
@@ -1117,7 +1185,7 @@ function buildLibrary() {
|
||||
const picture = document.createElement('span');
|
||||
picture.className = 'library-preview';
|
||||
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
|
||||
picture.innerHTML = columns ? rowPreview(columns) : preview(type);
|
||||
picture.innerHTML = columns ? rowPreview(columns) : (preview(type) || PLUGIN_PREVIEW);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'library-name';
|
||||
text.textContent = name;
|
||||
@@ -1323,7 +1391,9 @@ $('#delete').addEventListener('click', () => actions.delete(selected));
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
||||
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||||
$('#outdent').addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
|
||||
// Derselbe Weg wie ein Zug im Baum, mit denselben Regeln: neben den Elternblock, sofern der
|
||||
// Grosselternblock den Baustein nimmt.
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); moveBlockTo(doc, selected, parent.parent?.id ?? null, parent.index + 1); }); changed();
|
||||
});
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
|
||||
@@ -1345,6 +1415,13 @@ overlay = createCanvasOverlay({
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
},
|
||||
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
|
||||
// the whole row for the current device, one undo step per drag.
|
||||
onResize: (rowId, value) => {
|
||||
if (busy || !history) return;
|
||||
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
|
||||
changed();
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (payload.kind === 'add') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
@media(max-width:767px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dat
|
||||
von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und
|
||||
die Verwendung des reservierten Namens für eine geänderte Fassung.
|
||||
|
||||
**Cormorant Garamond** (Überschriften der Referenzwebsite „Ihr Malerservice Drews") steht ebenfalls
|
||||
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
|
||||
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -10,3 +10,7 @@
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Der Formulareditor in der Verwaltung.
|
||||
*
|
||||
* Ohne Skript ist die Seite vollständig bedienbar: Felder sind Karten mit Eingaben, ein Feld ohne
|
||||
* Beschriftung wird nicht gespeichert, die Vorschau zeigt den gespeicherten Stand. Das Skript
|
||||
* macht daraus einen Editor: Felder hinzufügen und entfernen, den technischen Namen aus der
|
||||
* Beschriftung ableiten, Optionen nur bei einer Auswahl zeigen, Farben aus der Palette wählen -
|
||||
* und eine Vorschau, die jeder Eingabe folgt, mit dem Standardbutton der Website.
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
|
||||
const root = document.querySelector('[data-form-schema]');
|
||||
if (root) {
|
||||
const schema = JSON.parse(root.dataset.formSchema);
|
||||
const bilder = JSON.parse(root.dataset.formImages || '[]');
|
||||
const defaults = {
|
||||
fieldBorderColor: schema.base.color,
|
||||
fieldBackgroundColor: '#ffffff',
|
||||
buttonColor: schema.buttons.backgroundColor,
|
||||
buttonInk: schema.buttons.color,
|
||||
};
|
||||
const hex = value => /^#[0-9a-f]{6}/i.test(value || '') ? value.slice(0, 7) : schema.colors.find(color => `@${color.key}` === value)?.value || '';
|
||||
// Dunkler beim Überfahren, so wie es die Seite selbst tut (82 % Farbe, Rest Schwarz).
|
||||
const darker = value => {
|
||||
const rgb = hex(value); if (!rgb) return value;
|
||||
return '#' + [1, 3, 5].map(index => Math.round(parseInt(rgb.slice(index, index + 2), 16) * 0.82).toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
const luminance = rgb => {
|
||||
const [r, g, b] = [1, 3, 5].map(index => parseInt(rgb.slice(index, index + 2), 16) / 255)
|
||||
.map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const contrast = (back, front) => {
|
||||
if (!back || !front) return null;
|
||||
const [light, dark] = [luminance(back), luminance(front)].sort((a, b) => b - a);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
};
|
||||
const slug = text => text.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 32);
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag); node.className = className;
|
||||
if (text !== undefined) node.textContent = text; return node;
|
||||
};
|
||||
|
||||
for (const form of root.querySelectorAll('[data-form-editor]')) {
|
||||
const list = form.querySelector('[data-field-list]'), template = form.querySelector('[data-field-template]');
|
||||
const preview = form.querySelector('[data-form-preview]'), add = form.querySelector('[data-add-field]');
|
||||
const buttonLabel = form.querySelector('[data-form-button-label]');
|
||||
const colorInputs = {};
|
||||
const cards = () => [...list.querySelectorAll('[data-field]')];
|
||||
let next = cards().length;
|
||||
|
||||
const renderPreview = () => {
|
||||
preview.replaceChildren();
|
||||
preview.style.color = hex(schema.base.color) || '#203b34';
|
||||
preview.style.background = hex(schema.base.backgroundColor) || '#ffffff';
|
||||
preview.style.setProperty('--form-border', hex(colorInputs.fieldBorderColor.value) || 'currentColor');
|
||||
preview.style.setProperty('--form-bg', hex(colorInputs.fieldBackgroundColor.value) || '#ffffff');
|
||||
let shown = 0;
|
||||
for (const card of cards()) {
|
||||
const label = card.querySelector('[data-field-label]').value.trim();
|
||||
if (!label) continue;
|
||||
shown++;
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const help = card.querySelector('[data-field-help]').value.trim();
|
||||
if (type === 'step') {
|
||||
// Ein Schritt ist eine Zwischenüberschrift in der Vorschau, mit seiner Einleitung.
|
||||
const step = el('div', 'form-preview-step');
|
||||
step.append(el('span', 'form-preview-step-title', label));
|
||||
if (help) step.append(el('span', 'form-preview-help', help));
|
||||
preview.append(step);
|
||||
continue;
|
||||
}
|
||||
const required = type === 'consent' || card.querySelector('[data-field-required]').checked;
|
||||
const showIf = card.querySelector('[data-field-showif]').value.trim();
|
||||
const field = el('div', `form-preview-field${showIf ? ' form-preview-conditional' : ''}`);
|
||||
const options = () => card.querySelector('[data-field-options-input]').value.split(',').map(option => option.trim()).filter(Boolean);
|
||||
if (type === 'consent') {
|
||||
const check = el('span', 'form-preview-check');
|
||||
check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`));
|
||||
field.append(check);
|
||||
} else if (type === 'radio' || type === 'checkboxes') {
|
||||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||||
for (const option of options()) {
|
||||
const check = el('span', 'form-preview-check');
|
||||
check.append(el('span', `form-preview-box${type === 'radio' ? ' form-preview-radio' : ''}`), document.createTextNode(option));
|
||||
field.append(check);
|
||||
}
|
||||
} else {
|
||||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||||
const unit = type === 'number' ? card.querySelector('[data-field-unit]').value.trim() : '';
|
||||
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}${type === 'number' ? ' form-preview-number' : ''}`, type === 'select' ? '— bitte wählen —' : unit));
|
||||
}
|
||||
if (help) field.append(el('span', 'form-preview-help', help));
|
||||
if (showIf) field.append(el('span', 'form-preview-help', `Nur wenn ${showIf} = ${card.querySelector('[data-field-showvalues]').value.split(',').map(v => v.trim()).filter(Boolean).join(' oder ') || '…'}`));
|
||||
preview.append(field);
|
||||
}
|
||||
if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.'));
|
||||
const values = { ...schema.buttons };
|
||||
const fill = colorInputs.buttonColor.value, ink = colorInputs.buttonInk.value;
|
||||
if (fill) { values.backgroundColor = fill; values.hoverBackgroundColor = darker(fill); }
|
||||
if (ink) { values.color = ink; values.hoverColor = ink; }
|
||||
preview.append(buttonSample(buttonLabel.value.trim() || 'Absenden', values, schema));
|
||||
// Der Kontrast der Buttonschrift auf der Buttonfarbe, nach WCAG: unter 4,5:1 ist der Text
|
||||
// fuer viele nicht mehr lesbar - das soll man sehen, bevor es auf der Website steht.
|
||||
const ratio = contrast(hex(values.backgroundColor), hex(values.color));
|
||||
note.hidden = ratio === null;
|
||||
if (ratio !== null) {
|
||||
note.className = `contrast-note ${ratio >= 4.5 ? 'contrast-ok' : ratio >= 3 ? 'contrast-warn' : 'contrast-bad'}`;
|
||||
note.textContent = `Buttonschrift auf Buttonfarbe: Kontrast ${ratio.toFixed(1).replace('.', ',')}:1 – ${ratio >= 4.5 ? 'gut lesbar.' : ratio >= 3 ? 'nur für große Schrift ausreichend, für den Button zu wenig.' : 'zu wenig, der Text ist kaum lesbar.'}`;
|
||||
}
|
||||
};
|
||||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||||
preview.after(note);
|
||||
|
||||
// Statt Kennungen zu tippen: je Option eine Liste der Mediathek. Das Textfeld bleibt das
|
||||
// gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein.
|
||||
const renderImagePickers = card => {
|
||||
const area = card.querySelector('[data-field-images-input]');
|
||||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||||
let box = card.querySelector('[data-image-pickers]');
|
||||
if (!box) {
|
||||
box = el('div', 'field-image-pickers'); box.dataset.imagePickers = '';
|
||||
area.hidden = true; area.after(box);
|
||||
}
|
||||
if (box.dataset.signature === options.join('|')) return;
|
||||
const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim())));
|
||||
box.dataset.signature = options.join('|');
|
||||
box.replaceChildren();
|
||||
for (const option of options) {
|
||||
const row = el('label', 'field-image-picker');
|
||||
row.append(el('span', 'field-image-picker-option', option));
|
||||
const select = document.createElement('select');
|
||||
select.append(new Option('Kein Bild', ''));
|
||||
for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id));
|
||||
select.dataset.option = option;
|
||||
select.addEventListener('change', () => {
|
||||
const map = {};
|
||||
for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value;
|
||||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||||
renderPreview();
|
||||
});
|
||||
row.append(select); box.append(row);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type);
|
||||
card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type);
|
||||
if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card);
|
||||
card.querySelector('[data-field-number]').hidden = type !== 'number';
|
||||
const remove = card.querySelector('[data-remove-field]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
|
||||
const required = card.querySelector('[data-field-required]');
|
||||
// Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken.
|
||||
if (type === 'consent') { required.checked = true; required.disabled = true; }
|
||||
else if (type === 'step') { required.checked = false; required.disabled = true; }
|
||||
else required.disabled = false;
|
||||
});
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
// Die leere Karte für ein neues Feld braucht es nur, solange es kein Feld gibt: mit Skript
|
||||
// kommt jedes weitere über den Knopf.
|
||||
const empty = cards().filter(card => card.classList.contains('field-card-new'));
|
||||
if (cards().length > empty.length) empty.forEach(card => card.remove());
|
||||
add.hidden = false;
|
||||
add.addEventListener('click', () => {
|
||||
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
|
||||
const card = list.lastElementChild; card.classList.remove('field-card-new');
|
||||
refresh(); card.querySelector('[data-field-label]').focus();
|
||||
});
|
||||
list.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-field]');
|
||||
if (!button) return;
|
||||
button.closest('[data-field]').remove(); refresh(); add.focus();
|
||||
});
|
||||
list.addEventListener('input', event => {
|
||||
const card = event.target.closest('[data-field]');
|
||||
if (event.target.matches('[data-field-label]')) {
|
||||
// Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt.
|
||||
const name = card.querySelector('[data-field-name]');
|
||||
if (!name.dataset.touched) name.value = slug(event.target.value);
|
||||
}
|
||||
if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : '';
|
||||
refresh();
|
||||
});
|
||||
list.addEventListener('change', refresh);
|
||||
for (const card of cards()) {
|
||||
const name = card.querySelector('[data-field-name]');
|
||||
if (name.value) name.dataset.touched = '1';
|
||||
}
|
||||
|
||||
for (const input of form.querySelectorAll('[data-form-color]')) {
|
||||
const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent;
|
||||
const id = input.id; input.type = 'hidden'; input.id += '-value';
|
||||
colorInputs[key] = input;
|
||||
const control = createColorControl({
|
||||
id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors,
|
||||
onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {},
|
||||
});
|
||||
control.querySelector('.color-inherit').textContent = 'Wie die Website';
|
||||
input.after(control);
|
||||
}
|
||||
buttonLabel.addEventListener('input', renderPreview);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Formulare mit Schritten, Bedingungen und Zusammenfassung.
|
||||
*
|
||||
* Ohne dieses Skript steht das Formular vollständig da: jeder Schritt ein Abschnitt, jedes Feld
|
||||
* sichtbar, der Server prüft beim Absenden, welche Felder galten. Das Skript ändert nur die
|
||||
* Darstellung: ein Schritt nach dem anderen, Felder hinter einer Bedingung nur bei passender
|
||||
* Antwort, und vor dem Absenden eine Zusammenfassung der Eingaben. Es wird nur auf Seiten
|
||||
* geladen, deren Formular eines davon braucht.
|
||||
*/
|
||||
(() => {
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
const setup = form => {
|
||||
const rules = [...form.querySelectorAll('[data-show-if]')];
|
||||
const steps = [...form.querySelectorAll('[data-step]')];
|
||||
const submit = form.querySelector('.cms-button[type="submit"]');
|
||||
const wantsSummary = form.hasAttribute('data-summary') && steps.length > 0 && submit;
|
||||
if (!rules.length && !steps.length) return;
|
||||
|
||||
// Die gewählten Werte eines Feldes - nur, solange es gilt. Ein Feld in einem ausgeblendeten
|
||||
// Schritt zählt nicht, auch wenn dort noch etwas angekreuzt ist.
|
||||
const valuesOf = name => [...form.querySelectorAll(`[name="${name}"], [name="${name}[]"]`)]
|
||||
.filter(input => !input.closest('[data-inactive]'))
|
||||
.filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : true)
|
||||
.map(input => input.value)
|
||||
.filter(value => value !== '');
|
||||
|
||||
// Bedingungen in Dokumentreihenfolge: ein Feld hängt nur von früheren Feldern ab, also steht
|
||||
// deren Zustand schon fest, wenn es an der Reihe ist.
|
||||
const apply = () => {
|
||||
for (const rule of rules) {
|
||||
const wanted = rule.dataset.showValues.split('|');
|
||||
const have = valuesOf(rule.dataset.showIf);
|
||||
rule.toggleAttribute('data-inactive', !wanted.some(value => have.includes(value)));
|
||||
}
|
||||
// Was nicht gilt, wird nicht geprüft und nicht gesendet: deaktiviert, nicht nur versteckt.
|
||||
for (const input of form.querySelectorAll('input, select, textarea')) {
|
||||
if (input.type === 'hidden') continue;
|
||||
input.disabled = input.closest('[data-inactive]') !== null;
|
||||
}
|
||||
};
|
||||
form.addEventListener('change', () => { apply(); if (steps.length) refreshNav(); });
|
||||
apply();
|
||||
|
||||
if (!steps.length) return;
|
||||
|
||||
// Die Zusammenfassung ist ein eigener, letzter Schritt - erst hier steht der Absendeknopf.
|
||||
let summary = null;
|
||||
if (wantsSummary) {
|
||||
summary = el('div', 'form-step form-summary');
|
||||
summary.setAttribute('data-step', '');
|
||||
const title = el('p', 'form-step-title', 'Zusammenfassung');
|
||||
title.tabIndex = -1;
|
||||
summary.append(title, el('p', 'form-step-intro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'));
|
||||
summary.append(el('div', 'form-summary-body'));
|
||||
steps[steps.length - 1].after(summary);
|
||||
steps.push(summary);
|
||||
}
|
||||
|
||||
const progress = el('ol', 'form-progress');
|
||||
progress.setAttribute('aria-label', 'Schritte des Formulars');
|
||||
steps.forEach((step, index) => {
|
||||
const item = el('li', 'form-progress-step');
|
||||
item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`));
|
||||
progress.append(item);
|
||||
});
|
||||
form.insertBefore(progress, steps[0]);
|
||||
|
||||
const nav = el('div', 'form-step-nav');
|
||||
const back = el('button', 'button cms-button form-step-back', 'Zurück');
|
||||
back.type = 'button';
|
||||
const next = el('button', 'button cms-button form-step-next', 'Weiter');
|
||||
next.type = 'button';
|
||||
nav.append(back, next);
|
||||
(submit || form).before(nav);
|
||||
if (submit) nav.append(submit);
|
||||
|
||||
const visible = () => steps.map((step, index) => step.hasAttribute('data-inactive') ? -1 : index).filter(index => index >= 0);
|
||||
let current = 0;
|
||||
|
||||
const labelOf = field => field.dataset.label || '';
|
||||
const valueOf = field => {
|
||||
const inputs = [...field.querySelectorAll('input, select, textarea')].filter(input => !input.disabled);
|
||||
const picked = inputs.filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : input.value.trim() !== '');
|
||||
if (!picked.length) return '';
|
||||
const unit = field.querySelector('.form-unit')?.textContent || '';
|
||||
return picked.map(input => {
|
||||
if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return 'Ja';
|
||||
if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value;
|
||||
return input.value + (unit ? ` ${unit}` : '');
|
||||
}).join(', ');
|
||||
};
|
||||
|
||||
const renderSummary = () => {
|
||||
const body = summary.querySelector('.form-summary-body');
|
||||
body.replaceChildren();
|
||||
steps.forEach((step, index) => {
|
||||
if (step === summary || step.hasAttribute('data-inactive')) return;
|
||||
const block = el('section', 'form-summary-step');
|
||||
const head = el('div', 'form-summary-head');
|
||||
head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`));
|
||||
const change = el('button', 'form-summary-change', 'Ändern');
|
||||
change.type = 'button';
|
||||
change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`);
|
||||
change.addEventListener('click', () => show(index));
|
||||
head.append(change);
|
||||
block.append(head);
|
||||
const list = el('dl', 'form-summary-list');
|
||||
for (const field of step.querySelectorAll('[data-field]')) {
|
||||
if (field.closest('[data-inactive]')) continue;
|
||||
const value = valueOf(field);
|
||||
list.append(el('dt', '', labelOf(field)), el('dd', value ? '' : 'form-summary-empty', value || '—'));
|
||||
}
|
||||
block.append(list);
|
||||
body.append(block);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshNav = () => {
|
||||
const order = visible();
|
||||
const position = order.indexOf(current);
|
||||
const last = position === order.length - 1;
|
||||
back.hidden = position <= 0;
|
||||
next.hidden = last;
|
||||
if (submit) submit.hidden = !last;
|
||||
progress.querySelectorAll('li').forEach((item, index) => {
|
||||
item.hidden = steps[index].hasAttribute('data-inactive');
|
||||
item.classList.toggle('is-done', order.indexOf(index) < position);
|
||||
if (index === current) item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current');
|
||||
});
|
||||
};
|
||||
|
||||
const show = (index, focus = true) => {
|
||||
current = index;
|
||||
steps.forEach((step, i) => { step.hidden = i !== index; });
|
||||
if (summary && steps[index] === summary) renderSummary();
|
||||
refreshNav();
|
||||
if (focus) {
|
||||
steps[index].querySelector('.form-step-title')?.focus({ preventScroll: true });
|
||||
if (progress.getBoundingClientRect().top < 0) progress.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
// Erst der Schritt selbst prüft seine Felder: der Browser meldet das erste ungültige Feld,
|
||||
// eine Pflicht-Mehrfachauswahl ohne Kreuz meldet die Gruppe.
|
||||
const valid = step => {
|
||||
for (const group of step.querySelectorAll('[data-required]')) {
|
||||
if (group.closest('[data-inactive]')) continue;
|
||||
const note = group.querySelector('.field-error[data-live]');
|
||||
if (!group.querySelector('input:checked')) {
|
||||
if (!note) group.append(Object.assign(el('p', 'field-error', 'Bitte wähle mindestens eine Option.'), { dataset: { live: '' } }));
|
||||
else note.hidden = false;
|
||||
group.classList.add('has-error');
|
||||
group.querySelector('input')?.focus();
|
||||
return false;
|
||||
}
|
||||
if (note) note.remove();
|
||||
group.classList.remove('has-error');
|
||||
}
|
||||
for (const input of step.querySelectorAll('input, select, textarea')) {
|
||||
if (input.disabled || input.checkValidity()) continue;
|
||||
input.reportValidity();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
next.addEventListener('click', () => {
|
||||
if (!valid(steps[current])) return;
|
||||
const order = visible();
|
||||
const following = order[order.indexOf(current) + 1];
|
||||
if (following !== undefined) show(following);
|
||||
});
|
||||
back.addEventListener('click', () => {
|
||||
const order = visible();
|
||||
const previous = order[order.indexOf(current) - 1];
|
||||
if (previous !== undefined) show(previous);
|
||||
});
|
||||
// Enter in einem Feld geht zum nächsten Schritt, statt das Formular vorzeitig abzuschicken.
|
||||
form.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'BUTTON') return;
|
||||
if (!next.hidden) { event.preventDefault(); next.click(); }
|
||||
});
|
||||
|
||||
// Nach einer Ablehnung durch den Server: der erste Schritt mit einem Fehler, sonst der erste.
|
||||
const failed = steps.findIndex(step => step.querySelector('.has-error'));
|
||||
const order = visible();
|
||||
show(failed >= 0 ? failed : (order[0] ?? 0), false);
|
||||
form.classList.add('form-steps-ready');
|
||||
};
|
||||
|
||||
document.querySelectorAll('form.block-form').forEach(setup);
|
||||
})();
|
||||
@@ -22,4 +22,4 @@
|
||||
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
|
||||
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
|
||||
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
|
||||
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
@media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
@@ -39,7 +39,7 @@ function button(label, title, onClick, className = 'rt-button') {
|
||||
* Builds the control. `apply` is called with the markup whenever it changes; the caller decides
|
||||
* what to do with it (history entry, preview refresh).
|
||||
*/
|
||||
export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
export function createRichText(value, apply, { label = 'Text', plain = false } = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'rich-text';
|
||||
|
||||
@@ -55,13 +55,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
surface.setAttribute('role', 'textbox');
|
||||
surface.setAttribute('aria-multiline', 'true');
|
||||
surface.setAttribute('aria-label', label);
|
||||
surface.innerHTML = value ?? '';
|
||||
// Unter Schema v12 ist der Text Klartext: "Preis <b>5 Euro</b>" ist dann eine Zeichenfolge, kein
|
||||
// Markup, und darf beim Oeffnen nicht als HTML gelesen werden.
|
||||
if (plain) surface.textContent = value ?? '';
|
||||
else surface.innerHTML = value ?? '';
|
||||
|
||||
const source = document.createElement('textarea');
|
||||
source.className = 'rt-source';
|
||||
source.hidden = true;
|
||||
source.setAttribute('aria-label', `${label} als HTML`);
|
||||
source.value = value ?? '';
|
||||
source.value = plain ? surface.innerHTML : (value ?? '');
|
||||
|
||||
const push = markup => { source.value = markup; apply(markup); };
|
||||
|
||||
|
||||
+144
-35
@@ -5,13 +5,13 @@ h1,h2,h3,h4,h5,h6,p,a{overflow-wrap:break-word}
|
||||
h1,h2,h3,h4,h5,h6{hyphens:manual}
|
||||
p{hyphens:auto}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
:root{color-scheme:var(--k-scheme,light);--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;--error:var(--k-error,#8b2f22);--error-bg:var(--k-error-bg,#fff0eb);--error-line:var(--k-error-line,#e2b2a7);font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}
|
||||
/* Über dem Inhalt: der Kopf nimmt keinen Platz ein, die erste Sektion beginnt unter ihm. Farbe
|
||||
und Schatten kommen aus der Gestaltung des Kopfbereichs, auch die für den gescrollten Zustand. */
|
||||
.site-header.is-overlay{position:absolute;top:0;left:0;right:0;z-index:40;border-bottom:0;background:transparent;box-shadow:none}
|
||||
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:var(--paper);padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:767px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
|
||||
@@ -27,7 +27,7 @@ p{hyphens:auto}
|
||||
/* Posts */
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
|
||||
.post-more::after{content:'→';transition:transform .2s}
|
||||
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:760px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:767px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
|
||||
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
|
||||
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
|
||||
@@ -64,7 +64,7 @@ p{hyphens:auto}
|
||||
.quote-rating{margin:0;letter-spacing:3px}
|
||||
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
|
||||
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
|
||||
.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.kant .block-cta .button{background:var(--accent);color:var(--paper);border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,var(--ink))}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
|
||||
.block-cta p{margin:0;line-height:1.6}
|
||||
.block-contact{display:grid;gap:14px;font-style:normal}
|
||||
@@ -96,22 +96,73 @@ p{hyphens:auto}
|
||||
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
|
||||
.block-form{display:grid;gap:20px;max-width:640px}
|
||||
.block-form>.cms-button{justify-self:start}
|
||||
/* Ausrichtung eines Buttons (Design-Token „align"): ein inline-flex-Element zentriert sich nicht
|
||||
über margin:auto, darum hier als Block mit eigener Breite - in Spalten, Zeilen und Rastern. */
|
||||
.kant .cms-button.d-desktop-align-left{justify-self:start;margin-inline:0}
|
||||
.kant .cms-button.d-desktop-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}
|
||||
.kant .cms-button.d-desktop-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}
|
||||
@media(max-width:1024px){.kant .cms-button.d-tablet-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-tablet-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-tablet-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
|
||||
@media(max-width:767px){.kant .cms-button.d-mobile-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-mobile-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-mobile-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
|
||||
.form-field{display:grid;gap:7px}
|
||||
.form-field label{font-weight:600;font-size:14px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid currentColor;border-radius:8px;background:#fff;color:inherit;font:inherit;min-height:46px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid var(--form-border,currentColor);border-radius:8px;background:var(--form-bg,#fff);color:inherit;font:inherit;min-height:46px}
|
||||
.form-field textarea{min-height:140px;line-height:1.6;resize:vertical}
|
||||
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:#8b2f22}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
|
||||
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:var(--error)}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:var(--error)}
|
||||
.field-note{margin:0;font-size:13px;opacity:.8}
|
||||
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7;font-weight:600}
|
||||
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:var(--error-bg);color:var(--error);border:1px solid var(--error-line);font-weight:600}
|
||||
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:color-mix(in srgb,var(--accent) 10%,var(--paper));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 35%,var(--paper));font-weight:600}
|
||||
.form-missing{margin:0;padding:14px 18px;border:1px dashed currentColor;border-radius:8px}
|
||||
.check-row{display:flex;align-items:flex-start;gap:11px}
|
||||
.check-row input{width:20px;min-height:20px;flex-shrink:0;margin-top:3px}
|
||||
.check-row label{font-weight:400;line-height:1.55}
|
||||
.required-mark{color:#8b2f22}
|
||||
.required-mark{color:var(--error)}
|
||||
/* Off screen rather than display:none so a filled value still proves automation. */
|
||||
.form-trap{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
|
||||
/* Schritte, Bedingungen, Zusammenfassung. Ohne Skript: Abschnitte untereinander, alles sichtbar. */
|
||||
.form-step{display:grid;gap:20px;margin:0;padding:0;border:0;min-width:0}
|
||||
/* Ein Schritt und der Absendeknopf sind Raster bzw. inline-flex - das hidden-Attribut muss trotzdem gelten. */
|
||||
.block-form [hidden]{display:none!important}
|
||||
.form-step-title{padding:0;margin:0;font-weight:600;font-size:18px;line-height:1.3}
|
||||
.form-step-title:focus{outline:none}
|
||||
.form-step-intro{margin:-8px 0 0;font-size:14px;opacity:.85}
|
||||
.form-choice{display:grid;gap:10px;margin:0;padding:0;border:0;min-width:0}
|
||||
.form-choice legend{padding:0;margin-bottom:2px;font-weight:600;font-size:14px}
|
||||
.form-choice .check-row{align-items:center}
|
||||
.form-choice .check-row input{margin-top:0}
|
||||
/* Bildauswahl: jede Option eine Karte, das Kästchen bleibt für Tastatur und Vorlesesoftware. */
|
||||
.form-choice-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:12px}
|
||||
.form-choice-card{position:relative}
|
||||
.form-choice-card input{position:absolute;top:10px;left:10px;width:20px;height:20px;margin:0;z-index:1}
|
||||
.form-choice-card label{display:grid;gap:8px;padding:8px;border:2px solid var(--form-border,currentColor);border-radius:10px;background:var(--form-bg,#fff);cursor:pointer;font-weight:600;font-size:14px;text-align:center;transition:border-color .2s,box-shadow .2s}
|
||||
.form-choice-card label:hover{border-color:var(--kb-fill,var(--accent))}
|
||||
.form-choice-card input:checked+label{border-color:var(--kb-fill,var(--accent));box-shadow:0 0 0 2px var(--kb-fill,var(--accent))}
|
||||
.form-choice-card input:focus-visible+label{outline:3px solid var(--kb-fill,var(--accent));outline-offset:3px}
|
||||
.form-choice-picture{display:block;aspect-ratio:4/3;border-radius:6px;overflow:hidden;background:color-mix(in srgb,currentColor 6%,transparent)}
|
||||
.form-choice-picture img{display:block;width:100%;height:100%;object-fit:cover}
|
||||
.form-choice-text{display:block;padding:0 4px 4px}
|
||||
.form-number{display:flex;align-items:center;gap:10px}
|
||||
.form-number input{flex:1 1 auto;min-width:0}
|
||||
.form-unit{flex:none;font-size:14px;opacity:.8}
|
||||
[data-inactive]{display:none!important}
|
||||
.form-progress{display:flex;flex-wrap:wrap;gap:8px 18px;list-style:none;margin:0;padding:0;font-size:13px}
|
||||
.form-progress-step{display:flex;align-items:center;gap:8px;opacity:.55}
|
||||
.form-progress-step[aria-current]{opacity:1;font-weight:600}
|
||||
.form-progress-step.is-done{opacity:.85}
|
||||
.form-progress-number{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;border:1px solid currentColor;font-size:12px}
|
||||
.form-progress-step[aria-current] .form-progress-number,.form-progress-step.is-done .form-progress-number{background:var(--kb-fill,var(--accent));color:var(--kb-ink,#fff);border-color:transparent}
|
||||
.form-step-nav{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
|
||||
.form-step-nav .form-step-back{--kb-fill:transparent;--kb-ink:currentColor;--kb-hover-fill:transparent;--kb-hover-ink:currentColor;border:1px solid currentColor;background:transparent;color:inherit}
|
||||
.form-summary-body{display:grid;gap:18px}
|
||||
.form-summary-step{padding:16px 18px;border:1px solid var(--form-border,currentColor);border-radius:8px}
|
||||
.form-summary-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:8px}
|
||||
.form-summary-title{margin:0;font-size:15px;font-weight:600}
|
||||
.form-summary-change{border:0;padding:0;background:none;color:inherit;font:inherit;font-size:14px;text-decoration:underline;cursor:pointer}
|
||||
.form-summary-list{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:6px 16px;margin:0;font-size:14px}
|
||||
.form-summary-list dt{font-weight:600}
|
||||
.form-summary-list dd{margin:0;overflow-wrap:anywhere}
|
||||
.form-summary-empty{opacity:.6}
|
||||
@media(max-width:767px){.form-summary-list{grid-template-columns:1fr;gap:2px 0}.form-summary-list dd{margin-bottom:8px}}
|
||||
|
||||
.block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px}
|
||||
.block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6}
|
||||
@@ -122,35 +173,60 @@ p{hyphens:auto}
|
||||
for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was
|
||||
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
|
||||
later, so the dark tone wins where it has to. */
|
||||
.tone-ink .block-column .block-text{color:#dae3da}
|
||||
.tone-ink .block-column .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}
|
||||
|
||||
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
|
||||
stays readable while the decision is open, because a wall would make "accept" the only way
|
||||
forward. Accepting and refusing are the same button in the same row. */
|
||||
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6}
|
||||
.kant .consent-bar h2{font-size:16px;margin:0 0 8px}
|
||||
.consent-bar p{margin:0 0 14px}
|
||||
forward. Accepting and refusing are the same button in the same row.
|
||||
|
||||
Aussehen aus den Einstellungen: `--kant-consent-*` im Kopf der Seite (Consent::styles), die
|
||||
Anordnung als Klasse. Ohne Einstellung gilt Papier, Tinte und Akzent des Themes. Ein Wert wie
|
||||
`--k-consent` aus der Palette bleibt als Zwischenstufe erhalten. */
|
||||
.consent-bar{position:fixed;inset-inline:16px;bottom:16px;z-index:50;max-width:640px;max-height:calc(100dvh - 24px);overflow:auto;margin-inline:auto;background:var(--kant-consent-bg,var(--paper));color:var(--kant-consent-ink,var(--ink));border:1px solid color-mix(in srgb,currentColor 12%,transparent);border-radius:var(--kant-consent-radius,18px);padding:22px 24px 20px;box-shadow:0 24px 60px color-mix(in srgb,var(--ink) 22%,transparent);font-size:14px;line-height:1.6;overscroll-behavior:contain}
|
||||
/* Ein schmaler Streifen in der Akzentfarbe oben: die Fläche gehört sichtbar zur Website. */
|
||||
.consent-bar::before{content:'';position:absolute;inset:0 0 auto 0;height:4px;background:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.kant .consent-bar h2{display:flex;align-items:center;gap:10px;font-size:17px;line-height:1.3;margin:0 0 8px}
|
||||
.consent-icon{flex:none;color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.consent-bar p{margin:0 0 16px;color:color-mix(in srgb,currentColor 82%,transparent)}
|
||||
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
||||
.consent-actions .consent-button:last-child{background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;flex:1 1 150px;min-height:46px;padding:11px 18px;border-radius:var(--kant-consent-button-radius,10px);border:1.5px solid var(--kant-consent-accent,var(--k-consent,var(--accent)));background:var(--kant-consent-accent,var(--k-consent,var(--accent)));color:var(--kant-consent-accent-ink,#fff);font:inherit;font-weight:600;cursor:pointer;transition:filter .12s,background-color .12s}
|
||||
.consent-decline,.consent-choice{background:transparent;color:inherit;border-color:color-mix(in srgb,currentColor 28%,transparent)}
|
||||
.consent-button:hover{filter:brightness(.94)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
||||
.consent-details{margin-top:14px}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}
|
||||
.consent-decline:hover,.consent-choice:hover{filter:none;background:color-mix(in srgb,currentColor 7%,transparent)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--kant-consent-accent,var(--k-consent,var(--accent)));outline-offset:2px}
|
||||
/* Fusszeile der Frage: Einzelwahl links, Rechtslinks rechts, auf einer Linie. Aufgeklappt
|
||||
nimmt die Einzelwahl die volle Breite und die Links rutschen rechtsbuendig darunter. */
|
||||
.consent-foot{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:8px 20px;margin-top:14px}
|
||||
.consent-details{flex:0 1 auto;min-width:0}
|
||||
.consent-details[open]{flex-basis:100%}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:6px 0;list-style:none;display:inline-flex;align-items:center;gap:6px}
|
||||
.consent-details>summary::-webkit-details-marker{display:none}
|
||||
.consent-details>summary::after{content:'';width:7px;height:7px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:translateY(-2px) rotate(45deg);transition:transform .15s}
|
||||
.consent-details[open]>summary::after{transform:translateY(2px) rotate(-135deg)}
|
||||
.consent-details>summary:hover{text-decoration:underline}
|
||||
.consent-groups{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:10px}
|
||||
.consent-groups label{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.5}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0}
|
||||
.consent-choice{margin-top:14px;background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-links{margin:14px 0 0;font-size:12px}
|
||||
.consent-links a{color:var(--accent)}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0;accent-color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.consent-choice{margin-top:14px;flex:none;width:auto}
|
||||
/* Eigener Selektor mit Element, sonst gewinnt `.consent-bar p` und nimmt den Rand rechts weg. */
|
||||
.consent-bar p.consent-links{margin:0 0 0 auto;display:flex;gap:14px;font-size:12px;color:color-mix(in srgb,currentColor 72%,transparent)}
|
||||
.consent-links a{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
||||
.consent-links a:hover{color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
/* Leiste: über die ganze Breite am unteren Rand, auf breiten Bildschirmen Text links und Knöpfe
|
||||
rechts. Ecke: eine kleine Karte rechts unten, die den Inhalt kaum verdeckt. */
|
||||
.consent-layout-leiste{inset-inline:0;bottom:0;max-width:none;border-radius:var(--kant-consent-radius,18px) var(--kant-consent-radius,18px) 0 0;border-inline:0;border-bottom:0;padding-inline:max(24px,calc((100% - 1180px) / 2))}
|
||||
@media (min-width:900px){.consent-layout-leiste{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 40px;align-items:center}.consent-layout-leiste .consent-body p{margin-bottom:0}.consent-layout-leiste .consent-form{width:360px;max-width:100%}}
|
||||
.consent-layout-ecke{inset-inline:auto 16px;max-width:400px;margin:0}
|
||||
/* While the question is open the bar is fixed at the bottom, so the page has to end above it -
|
||||
otherwise it covers the last element, and an embed's own consent button sat right underneath.
|
||||
Found by the browser test, which could not click it. */
|
||||
body.consent-open{padding-bottom:260px}
|
||||
Found by the browser test, which could not click it. Auf schmalen Bildschirmen ist die Leiste
|
||||
mit Standardtext bis 290 px hoch; 320 px lassen ihr Luft, ohne einen Bildschirm zu verschenken. */
|
||||
body.consent-open{padding-bottom:320px}
|
||||
@media (min-width:621px){body.consent-open{padding-bottom:270px}}
|
||||
@media (min-width:900px){body.consent-open{padding-bottom:200px}}
|
||||
.consent-page main{max-width:680px;margin-inline:auto;padding:40px 20px}
|
||||
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;margin-top:24px}
|
||||
@media (max-width:620px){.consent-bar{inset-inline:0;bottom:0;border-radius:14px 14px 0 0;padding:16px}.consent-actions .consent-button{flex:1 1 140px}}
|
||||
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;max-height:none;margin:24px 0 0;display:block}
|
||||
@media (max-width:620px){.consent-bar,.consent-layout-ecke{inset-inline:0;bottom:0;max-width:none;border-radius:var(--kant-consent-radius,18px) var(--kant-consent-radius,18px) 0 0;border-inline:0;border-bottom:0;padding:18px 18px 16px}.consent-layout-leiste{padding-inline:18px}}
|
||||
|
||||
/* An embedded third party, and what stands in its place until it may load. */
|
||||
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 8%,var(--paper))}
|
||||
@@ -231,7 +307,7 @@ body.consent-open{padding-bottom:260px}
|
||||
.slider-play[data-playing=true]::before{width:8px;height:11px;border:0;border-left:3px solid currentColor;border-right:3px solid currentColor;margin-left:0}
|
||||
.slider-play:disabled{opacity:.4;cursor:default}
|
||||
.slider-controls[hidden]{display:none}
|
||||
@media(max-width:600px){.slider-controls .slider-prev,.slider-controls .slider-next{display:none}.slider-control-dots{right:54px;bottom:14px}.slider-controls .slider-play{right:14px;bottom:12px}}
|
||||
@media(max-width:767px){.slider-controls .slider-prev,.slider-controls .slider-next{display:none}.slider-control-dots{right:54px;bottom:14px}.slider-controls .slider-play{right:14px;bottom:12px}}
|
||||
|
||||
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
|
||||
@@ -241,7 +317,7 @@ body.consent-open{padding-bottom:260px}
|
||||
.slide-tablet-3{grid-auto-columns:calc(33.333% - 14px)}.slide-tablet-4{grid-auto-columns:calc(25% - 15px)}
|
||||
.slide-tablet-5{grid-auto-columns:calc(20% - 16px)}.slide-tablet-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
}
|
||||
@media (max-width:650px){
|
||||
@media (max-width:767px){
|
||||
.slide-mobile-1{grid-auto-columns:100%}.slide-mobile-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-mobile-3{grid-auto-columns:calc(33.333% - 14px)}.slide-mobile-4{grid-auto-columns:calc(25% - 15px)}
|
||||
.slide-mobile-5{grid-auto-columns:calc(20% - 16px)}.slide-mobile-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
@@ -351,9 +427,11 @@ body.consent-open{padding-bottom:260px}
|
||||
/* Section widths. "wide" only widens the content, "full" removes the container entirely: that is
|
||||
what a picture or a colour field from edge to edge needs. A full width section drops its own
|
||||
padding as well, otherwise "edge to edge" would still have 32 pixels of air on each side. */
|
||||
.width-wide>.section-inner{width:min(1600px,calc(100% - 64px))}
|
||||
.width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:650px){.width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
/* Mit ".kant" davor, weil das Design ".kant .shell{max-width:var(--k-container)}" schreibt: ohne
|
||||
den Zusatz gewann diese Regel und ein breiter oder voller Abschnitt blieb so schmal wie alle. */
|
||||
.kant .width-wide>.section-inner{width:min(1600px,calc(100% - 64px));max-width:none}
|
||||
.kant .width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:767px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
|
||||
/* A section that is a flex container centres its content box; the inner container must then be
|
||||
allowed to grow, or a full height section would show a strip in the middle. */
|
||||
@@ -384,6 +462,10 @@ body.consent-open{padding-bottom:260px}
|
||||
Inline geoeffnet stand es sonst in der schmalen Spalte neben der Marke und schob den
|
||||
ganzen Kopf auseinander - auf dem Telefon sah es aus, als fehle das Menue. */
|
||||
.site-header{position:relative}
|
||||
/* Gibt der Kopfbereich seine Flaeche selbst vor, malt der klebende Kopf keine zweite dahinter:
|
||||
sonst zeigte eine durchsichtige Sektion Weiss statt Seite, und die Schattenlinie liesse sich
|
||||
weder faerben noch abschalten. Linie und Schatten kommen dann aus Rahmen und Wirkung. */
|
||||
.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}
|
||||
.site-header .menu-burger[open]>summary{border-radius:8px}
|
||||
.site-header .menu-burger-body{position:absolute;left:0;right:0;top:100%;z-index:30;
|
||||
padding:15px max(16px,calc((100% - var(--k-container,1180px)) / 2 + 16px)) 25px;
|
||||
@@ -406,8 +488,21 @@ body.consent-open{padding-bottom:260px}
|
||||
.scroll-top:hover{filter:brightness(1.08)}
|
||||
.scroll-top:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
|
||||
@media (max-width:767px){.scroll-top{bottom:16px;right:16px}.scroll-top-links{left:16px;right:auto}}
|
||||
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
|
||||
body.consent-open .scroll-top{display:none}
|
||||
/* Solange die Einwilligungsfrage offen ist, bleibt der Knopf da. Frueher verschwand er ganz -
|
||||
und auf dem Telefon bleibt die Frage oft einfach stehen, der Knopf kam dort also nie.
|
||||
Bis 799 px deckt die Leiste die Ecke ab, in der er steht: dort setzt er sich per
|
||||
Ankerpositionierung auf ihre Oberkante, wie hoch ihr Text auch umbricht. Ab 800 px steht die
|
||||
Leiste zentriert mit Rand, die Ecke bleibt frei. Ein Browser ohne Ankerpositionierung blendet
|
||||
ihn in der schmalen Stufe weiter aus - lieber kein Knopf als einer unter der Leiste. Der Anker
|
||||
muss im Dokument vor dem Knopf stehen; page.html.twig gibt die Leiste deshalb zuerst aus. */
|
||||
.consent-bar{anchor-name:--kant-consent}
|
||||
@media (max-width:799px){
|
||||
@supports (anchor-name:--kant-consent){body.consent-open .scroll-top{position-anchor:--kant-consent;bottom:calc(anchor(top) + 12px)}}
|
||||
@supports not (anchor-name:--kant-consent){body.consent-open .scroll-top{display:none}}
|
||||
}
|
||||
/* Leiste und Ecke decken die Ecke auf jeder Breite ab - dort gilt dasselbe ohne Stufe. */
|
||||
@supports (anchor-name:--kant-consent){body.consent-open:has(.consent-layout-leiste,.consent-layout-ecke) .scroll-top{position-anchor:--kant-consent;bottom:calc(anchor(top) + 12px)}}
|
||||
@supports not (anchor-name:--kant-consent){body.consent-open:has(.consent-layout-leiste,.consent-layout-ecke) .scroll-top{display:none}}
|
||||
/* Sichtbar, sobald der Leser ein Stueck gescrollt hat - gesteuert von scrolled.js ueber
|
||||
`kant-top-armed` und `kant-top-shown`. Ohne Skript bleibt der Knopf einfach sichtbar. */
|
||||
html.kant-top-armed .scroll-top{opacity:0;visibility:hidden;transform:translateY(10px);transition:opacity .25s,visibility .25s,transform .25s}
|
||||
@@ -442,3 +537,17 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
/* Soziale Profile: eine Symbolreihe, Farbe und Abstand vom Block, Größe als Klasse. */
|
||||
.block-social{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin:0;padding:0;list-style:none}.block-social.social-vertical{flex-direction:column;align-items:flex-start}.block-social a{display:inline-flex;align-items:center;gap:10px;color:inherit;text-decoration:none;line-height:1;transition:transform .2s,background-color .2s,opacity .2s}.block-social a:hover{transform:translateY(-2px)}.block-social svg{display:block;flex:none}.block-social.social-labels a{font-size:.875em;font-weight:600}.social-shape-circle a,.social-shape-square a{padding:.5em;background:color-mix(in srgb,currentColor 10%,transparent)}.social-shape-circle a{border-radius:999px}.social-shape-square a{border-radius:.35em}.social-shape-circle a:hover,.social-shape-square a:hover{background:color-mix(in srgb,currentColor 18%,transparent)}.social-labels.social-shape-circle a,.social-labels.social-shape-square a{padding-inline:.8em}.social-size-12 svg{width:12px;height:12px}.social-size-16 svg{width:16px;height:16px}.social-size-20 svg{width:20px;height:20px}.social-size-24 svg{width:24px;height:24px}.social-size-32 svg{width:32px;height:32px}.social-size-40 svg{width:40px;height:40px}.social-size-48 svg{width:48px;height:48px}.social-size-56 svg{width:56px;height:56px}.social-size-64 svg{width:64px;height:64px}.social-size-80 svg{width:80px;height:80px}.social-size-96 svg{width:96px;height:96px}.social-size-120 svg{width:120px;height:120px}.social-size-160 svg{width:160px;height:160px}.social-size-200 svg{width:200px;height:200px}.social-size-240 svg{width:240px;height:240px}
|
||||
@media(prefers-reduced-motion:reduce){html.motion-respect .block-social a{transition:none}html.motion-respect .block-social a:hover{transform:none}}
|
||||
|
||||
/* Druck: eine Seite, die man mitnimmt. Kein Kopf mit Menü, keine Einwilligung, kein Nach-oben,
|
||||
keine Formularknöpfe; Verweise zeigen ihre Adresse, Bilder bleiben auf der Seite. */
|
||||
@media print{
|
||||
.kant{background:#fff;color:#000}
|
||||
.site-header nav,.menu-toggle,.skip-link,.scroll-top,.consent-bar,.consent-banner,.block-form .cms-button,.form-step-nav,.form-progress,.post-pager,.lightbox,.slider-controls,.slider-dots,.tabs-nav,.cf-turnstile{display:none!important}
|
||||
.site-header,.site-footer{border:0}
|
||||
.site-header.is-sticky,.site-header.is-overlay,.site-header.is-overlay.is-sticky{position:static;background:transparent;box-shadow:none}
|
||||
a[href^="http"]::after{content:" (" attr(href) ")";font-size:.85em;color:#444}
|
||||
.block-section{padding:16px 0;break-inside:avoid}
|
||||
img{max-width:100%;break-inside:avoid}
|
||||
.tone-ink,.tone-mint{background:transparent;color:#000}
|
||||
[data-step][hidden],.block-form [hidden]{display:block!important}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ if (root) {
|
||||
const read = () => Object.fromEntries([...root.querySelectorAll('[name^="buttons["]')].map(input => [input.name.slice(8, -1), input.value]));
|
||||
const update = () => {
|
||||
// Unsaved palette edits are reflected immediately in all samples.
|
||||
schema.colors = [...form.querySelectorAll('.theme-table tbody tr')].flatMap(row => {
|
||||
schema.colors = [...form.querySelectorAll('[data-color-card]')].flatMap(row => {
|
||||
const key = row.querySelector('[name$="[key]"]'), value = row.querySelector('[name$="[value]"]');
|
||||
return key?.value && value ? [{ key: key.value, value: value.value }] : [];
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Die Designseite: Palette, Schriften, Grundtypografie.
|
||||
*
|
||||
* Ohne Skript sind es Karten mit Eingaben; eine Farbe ohne Schlüssel und eine Schrift ohne
|
||||
* Schlüssel werden nicht gespeichert, so lassen sie sich auch dann entfernen. Das Skript macht
|
||||
* daraus die eigentliche Werkstatt: Karten hinzufügen und entfernen, fertige Paletten übernehmen,
|
||||
* die Grundfarben aus der Palette wählen statt tippen, jede Schrift in sich selbst zeigen und die
|
||||
* Grundtypografie als lesbare Probe.
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
const form = document.querySelector('[data-theme-editor]');
|
||||
if (form) {
|
||||
const palettes = JSON.parse(form.dataset.palettes), colorNames = JSON.parse(form.dataset.colorNames);
|
||||
const colorList = form.querySelector('[data-color-list]'), colorTemplate = form.querySelector('[data-color-template]');
|
||||
const fontList = form.querySelector('[data-font-list]'), fontTemplate = form.querySelector('[data-font-template]');
|
||||
const colorCards = () => [...colorList.querySelectorAll('[data-color-card]')];
|
||||
const fontCards = () => [...fontList.querySelectorAll('[data-font-card]')];
|
||||
let nextColor = colorCards().length, nextFont = fontCards().length;
|
||||
const value = (card, selector) => card.querySelector(selector).value.trim();
|
||||
|
||||
const palette = () => colorCards().flatMap(card => {
|
||||
const key = value(card, '[data-color-key]').toLowerCase();
|
||||
return key ? [{ key, name: value(card, '[data-color-name]') || key, value: card.querySelector('[data-color-value]').value }] : [];
|
||||
});
|
||||
const fonts = () => fontCards().flatMap(card => {
|
||||
const key = value(card, '[data-font-key]').toLowerCase();
|
||||
return key ? [{ key, name: value(card, '[data-font-name]') || key, family: value(card, '[data-font-family]'), fallback: card.querySelector('[data-font-fallback]').value }] : [];
|
||||
});
|
||||
const stack = font => font.family ? `"${font.family}", ${font.fallback}` : font.fallback;
|
||||
const hex = raw => /^#[0-9a-f]{6}/i.test(raw || '') ? raw.slice(0, 7) : palette().find(color => `@${color.key}` === raw)?.value || '';
|
||||
|
||||
/* ---- Palette ---------------------------------------------------------------------- */
|
||||
|
||||
const refreshColors = () => {
|
||||
colorCards().forEach((card, index) => {
|
||||
const name = value(card, '[data-color-name]') || value(card, '[data-color-key]') || `Farbe ${index + 1}`;
|
||||
card.querySelector('[data-color-hex]').textContent = card.querySelector('[data-color-value]').value;
|
||||
card.querySelector('[data-color-value]').setAttribute('aria-label', `Farbwert von ${name}`);
|
||||
const remove = card.querySelector('[data-remove-color]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
|
||||
});
|
||||
form.querySelector('[data-color-count]').textContent = String(palette().length);
|
||||
};
|
||||
const addColor = (values = {}) => {
|
||||
colorList.insertAdjacentHTML('beforeend', colorTemplate.innerHTML.replaceAll('__n__', String(nextColor++)));
|
||||
const card = colorList.lastElementChild; card.classList.remove('swatch-card-new');
|
||||
for (const [selector, field] of [['[data-color-key]', 'key'], ['[data-color-name]', 'name'], ['[data-color-value]', 'value']]) {
|
||||
if (values[field] !== undefined) card.querySelector(selector).value = values[field];
|
||||
}
|
||||
return card;
|
||||
};
|
||||
// Die leeren Karten braucht es nur ohne Skript: hier kommt jede Farbe über den Knopf.
|
||||
const emptyColors = colorCards().filter(card => card.classList.contains('swatch-card-new'));
|
||||
if (colorCards().length > emptyColors.length) emptyColors.forEach(card => card.remove());
|
||||
const addColorButton = form.querySelector('[data-add-color]'); addColorButton.hidden = false;
|
||||
addColorButton.addEventListener('click', () => {
|
||||
const card = addColor(); refreshColors(); mountBaseColors(); card.querySelector('[data-color-name]').focus();
|
||||
});
|
||||
colorList.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-color]');
|
||||
if (!button) return;
|
||||
button.closest('[data-color-card]').remove(); refreshColors(); mountBaseColors(); renderType(); addColorButton.focus();
|
||||
});
|
||||
colorList.addEventListener('input', () => { refreshColors(); renderType(); });
|
||||
colorList.addEventListener('change', () => { mountBaseColors(); renderType(); });
|
||||
|
||||
const presets = form.querySelector('[data-palette-presets]'), status = form.querySelector('[data-palette-status]');
|
||||
presets.hidden = false;
|
||||
presets.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-palette-preset]');
|
||||
if (!button) return;
|
||||
const preset = palettes[Number(button.dataset.palettePreset)];
|
||||
// Bekannte Schlüssel bekommen den neuen Wert, fehlende kommen dazu, eigene Farben bleiben.
|
||||
for (const [key, color] of Object.entries(preset.colors)) {
|
||||
const card = colorCards().find(candidate => value(candidate, '[data-color-key]').toLowerCase() === key);
|
||||
if (card) card.querySelector('[data-color-value]').value = color;
|
||||
else addColor({ key, name: colorNames[key] || key, value: color });
|
||||
}
|
||||
refreshColors(); mountBaseColors(); renderType();
|
||||
status.textContent = `Palette „${preset.name}“ übernommen – sie gilt, sobald das Design gespeichert ist.`;
|
||||
});
|
||||
|
||||
/* ---- Schriften -------------------------------------------------------------------- */
|
||||
|
||||
const applySample = card => {
|
||||
const family = value(card, '[data-font-family]'), fallback = card.querySelector('[data-font-fallback]').value;
|
||||
card.querySelector('[data-font-sample]').style.fontFamily = stack({ family, fallback });
|
||||
const chosen = new Set([...card.querySelectorAll('[data-font-weight]')].filter(input => input.checked).map(input => input.value));
|
||||
for (const weight of card.querySelectorAll('[data-font-weights] > span')) weight.hidden = !chosen.has(weight.className.replace('font-weight-', ''));
|
||||
};
|
||||
const syncFontSelects = () => {
|
||||
const list = fonts();
|
||||
for (const select of form.querySelectorAll('[data-font-select]')) {
|
||||
const current = select.value, keep = select.querySelector('option[value="inherit"]');
|
||||
select.replaceChildren(...(keep ? [keep] : []), ...list.map(font => new Option(font.name, font.key)));
|
||||
select.value = list.some(font => font.key === current) || (keep && current === 'inherit') ? current : (keep ? 'inherit' : list[0]?.key ?? '');
|
||||
}
|
||||
};
|
||||
const refreshFonts = () => {
|
||||
fontCards().forEach((card, index) => {
|
||||
applySample(card);
|
||||
const name = value(card, '[data-font-name]') || value(card, '[data-font-key]') || `Schrift ${index + 1}`;
|
||||
const remove = card.querySelector('[data-remove-font]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
|
||||
});
|
||||
form.querySelector('[data-font-count]').textContent = String(fonts().length);
|
||||
syncFontSelects();
|
||||
};
|
||||
const emptyFonts = fontCards().filter(card => card.classList.contains('font-card-new'));
|
||||
if (fontCards().length > emptyFonts.length) emptyFonts.forEach(card => card.remove());
|
||||
const addFontButton = form.querySelector('[data-add-font]'); addFontButton.hidden = false;
|
||||
addFontButton.addEventListener('click', () => {
|
||||
fontList.insertAdjacentHTML('beforeend', fontTemplate.innerHTML.replaceAll('__n__', String(nextFont++)));
|
||||
const card = fontList.lastElementChild; card.classList.remove('font-card-new');
|
||||
refreshFonts(); card.querySelector('[data-font-name]').focus();
|
||||
});
|
||||
fontList.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-font]');
|
||||
if (!button) return;
|
||||
button.closest('[data-font-card]').remove(); refreshFonts(); renderType(); addFontButton.focus();
|
||||
});
|
||||
fontList.addEventListener('input', () => { refreshFonts(); renderType(); });
|
||||
fontList.addEventListener('change', () => { refreshFonts(); renderType(); });
|
||||
|
||||
/* ---- Grundtypografie -------------------------------------------------------------- */
|
||||
|
||||
const preview = form.querySelector('[data-type-preview]');
|
||||
const baseInputs = {};
|
||||
const lengthPattern = /^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/;
|
||||
function renderType() {
|
||||
const list = fonts();
|
||||
const family = key => { const font = list.find(entry => entry.key === key); return font ? stack(font) : 'inherit'; };
|
||||
const raw = form.querySelector('#base-size').value.trim(), size = lengthPattern.test(raw) ? raw : '18px';
|
||||
const line = form.querySelector('#base-line').value || '1.6', scale = parseFloat(form.querySelector('#base-scale').value) || 1.25;
|
||||
preview.style.fontFamily = family(form.querySelector('#base-font').value);
|
||||
preview.style.fontSize = size; preview.style.lineHeight = line;
|
||||
const heading = family(form.querySelector('#base-heading-font').value);
|
||||
for (const [selector, step] of [['[data-type-h1]', 4], ['[data-type-h2]', 3]]) {
|
||||
const node = preview.querySelector(selector);
|
||||
node.style.fontFamily = heading; node.style.fontSize = `calc(${size} * ${(scale ** step).toFixed(4)})`;
|
||||
}
|
||||
preview.style.color = hex(baseInputs.color?.value) || '#203b34';
|
||||
preview.style.background = hex(baseInputs.backgroundColor?.value) || '#ffffff';
|
||||
const link = preview.querySelector('[data-type-link]');
|
||||
link.style.color = hex(baseInputs.linkColor?.value) || 'inherit';
|
||||
link.style.textDecoration = form.querySelector('[data-base-underline]').checked ? 'underline' : 'none';
|
||||
}
|
||||
// Die drei Grundfarben als Palettenwahl. Die Palette kann sich unter ihnen ändern, deshalb
|
||||
// werden die Bedienelemente bei jeder Änderung der Palette neu aufgebaut; der Wert bleibt.
|
||||
function mountBaseColors() {
|
||||
const colors = palette();
|
||||
for (const input of form.querySelectorAll('[data-base-color]')) {
|
||||
const key = input.name.slice(5, -1);
|
||||
if (!input.dataset.controlId) {
|
||||
input.dataset.controlId = input.id; input.type = 'hidden'; input.id += '-value'; baseInputs[key] = input;
|
||||
}
|
||||
const id = input.dataset.controlId, label = form.querySelector(`label[for="${id}"]`).textContent;
|
||||
input.nextElementSibling?.classList.contains('color-control') && input.nextElementSibling.remove();
|
||||
const control = createColorControl({
|
||||
id, label, value: input.value, colors,
|
||||
onChange: chosen => { input.value = chosen; renderType(); }, onComplete: () => {},
|
||||
});
|
||||
control.querySelector('.color-inherit').hidden = true;
|
||||
input.after(control);
|
||||
}
|
||||
}
|
||||
|
||||
refreshColors(); refreshFonts(); mountBaseColors(); renderType();
|
||||
form.addEventListener('input', renderType); form.addEventListener('change', renderType);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ try {
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('.field-table tbody tr').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
console.log('Public no-JavaScript baseline and visual captures passed.');
|
||||
} else {
|
||||
if (!process.argv.includes('--admin-only')) {
|
||||
|
||||
@@ -80,10 +80,13 @@ try {
|
||||
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
|
||||
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
|
||||
// Eine öffentliche Seite lädt kein Skript, solange sie keines braucht - und das steht in
|
||||
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider mit Autolauf,
|
||||
// eigener Code), darf überhaupt eines kommen, dann höchstens eines.
|
||||
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider, Einblendungen,
|
||||
// Formular mit Schritten, eigener Code), darf überhaupt eines kommen. Jede Fähigkeit
|
||||
// bringt genau eine kleine Datei mit; drei davon sind die Grenze, und alle vom eigenen Ursprung.
|
||||
const erlaubt = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(erlaubt ? scripts <= 1 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 1 : 0}`);
|
||||
assert.ok(erlaubt ? scripts <= 3 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 3 : 0}`);
|
||||
const fremd = [...transferred.keys()].filter(url => (transferred.get(url).type.includes('javascript')) && !url.startsWith(origin + '/assets/'));
|
||||
assert.deepEqual(fremd, [], `${path}: Skript von außerhalb /assets/`);
|
||||
assert.ok(beiwerk <= 12, `${path}: ${beiwerk} Anfragen ohne Bilder, Obergrenze 12`);
|
||||
assert.ok(gewicht < 1500000, `${path}: ${gewicht} Bytes übertragen, Obergrenze 1500000`);
|
||||
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
|
||||
@@ -113,7 +116,12 @@ try {
|
||||
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
|
||||
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
|
||||
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(darfSkripte ? laufend <= 1 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 1 : 0}`);
|
||||
assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`);
|
||||
// Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein
|
||||
// Block halb durchsichtig oder verschoben. axe rechnet den Kontrast dann gegen die
|
||||
// verwaschene Farbe, und ein von der Seite hereingleitender Block zählt als Überlauf -
|
||||
// beides Befunde, die kein Besucher je sieht.
|
||||
await page.evaluate(() => document.getAnimations().forEach(animation => animation.finish()));
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow');
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { readFile, writeFile } from 'node:fs/promises';
|
||||
*/
|
||||
export function designCss(tokens) {
|
||||
const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `.kant .d-${device}-${key}-${value}{${css}}`)).join('\n');
|
||||
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:650px){\n${rules('mobile')}\n}\n`;
|
||||
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:767px){\n${rules('mobile')}\n}\n`;
|
||||
}
|
||||
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/build-design.mjs')) {
|
||||
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
|
||||
|
||||
@@ -67,7 +67,7 @@ echo "Fassung {$version}\n";
|
||||
// migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim
|
||||
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
|
||||
// dort, wo eine frische Installation ihre Tabellen anlegen will.
|
||||
$include = ['bin', 'config', 'migrations', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', 'LICENSE', '.env.example'];
|
||||
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example'];
|
||||
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
|
||||
|
||||
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
|
||||
|
||||
+139
-4
@@ -37,7 +37,12 @@ try {
|
||||
activePage = page;
|
||||
const errors = [];
|
||||
page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); });
|
||||
page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); });
|
||||
page.on('console', message => {
|
||||
if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text());
|
||||
// Jede Fehlermeldung der Konsole ins Protokoll: ein Editor, der bei „Wird geladen“ stehen
|
||||
// bleibt, sagt sonst nichts darüber, warum.
|
||||
if (message.type() === 'error') console.error('Console error:', message.text());
|
||||
});
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
// Jede angeforderte Skriptdatei mitschreiben. `public/.htaccess` laesst .js ein Jahr lang als
|
||||
// `immutable` zwischenspeichern; eine Datei ohne Fassungskennung waere damit nach einem Update
|
||||
@@ -253,6 +258,39 @@ try {
|
||||
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
// Umut: "bei Rows die Breite ändern, am besten per Drag and Drop im Editor". The border
|
||||
// between the two columns is a handle; dragging it to the right widens the first column.
|
||||
await treeSelect(firstColumnId);
|
||||
await frame.locator(`#${firstColumnId}`).scrollIntoViewIfNeeded();
|
||||
const resizeHandle = page.locator('.ov-resize');
|
||||
await resizeHandle.waitFor();
|
||||
assert.equal(await resizeHandle.count(), 1, 'two columns have one border to drag');
|
||||
const columnTracks = () => frame.locator(`#${newSectionId} .block-row`).first().evaluate(el => getComputedStyle(el).gridTemplateColumns.split(' ').map(Number.parseFloat));
|
||||
const evenTracks = await columnTracks();
|
||||
assert.ok(Math.abs(evenTracks[0] - evenTracks[1]) < 2, `columns start out equal: ${evenTracks}`);
|
||||
const handleBox = await resizeHandle.boundingBox();
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2 + 120, handleBox.y + handleBox.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await previewReady();
|
||||
const widenedTracks = await columnTracks();
|
||||
assert.ok(widenedTracks[0] > widenedTracks[1] * 1.3, `dragging the border widened the first column: ${widenedTracks}`);
|
||||
await page.screenshot({ path: 'reports/editor/column-widths.png', fullPage: true });
|
||||
// The same value is visible as numbers in the design panel of the row.
|
||||
await treeSelect(await frame.locator(`#${newSectionId} .block-row`).first().getAttribute('id'));
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
const layoutGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: 'Layout' }) }).first();
|
||||
if (!(await layoutGroup.evaluate(element => element.open))) await layoutGroup.locator('summary').click();
|
||||
assert.match(await page.locator('#style-columnWidths').inputValue(), /^\d+(\.\d)? \d+(\.\d)?$/, 'shares are stored per column');
|
||||
// On a phone the columns stack, so there is no border to drag - and one undo restores equal columns.
|
||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||
await treeSelect(firstColumnId);
|
||||
await page.waitForFunction(() => document.querySelectorAll('.ov-resize').length === 0);
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
const restoredTracks = await columnTracks();
|
||||
assert.ok(Math.abs(restoredTracks[0] - restoredTracks[1]) < 2, `undo restores equal columns: ${restoredTracks}`);
|
||||
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
|
||||
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
|
||||
const publicBefore = await (await fetch(origin)).text();
|
||||
@@ -344,14 +382,15 @@ try {
|
||||
assert.match((await rowState()).title, /gehört in eine Sektion/);
|
||||
assert.equal((await toolboxState('section')).disabled, false, 'Sektion geht immer');
|
||||
|
||||
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
|
||||
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
||||
// In einer Sektion werden Zeile und Spalte moeglich - eine einzelne Spalte darf seit 1.16.1
|
||||
// direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente). Die Sektion kommt aus
|
||||
// dem Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
||||
// einem anderen Dokument.
|
||||
await tab('Struktur');
|
||||
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
|
||||
assert.equal(await page.locator('#properties-title').textContent(), 'Sektion');
|
||||
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
|
||||
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
|
||||
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Sektion moeglich');
|
||||
|
||||
// Und in der Zeile wird die Spalte moeglich.
|
||||
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
|
||||
@@ -805,6 +844,102 @@ try {
|
||||
await page.waitForFunction(() => document.querySelector('#property-title')?.value === 'Parallel gespeicherter Titel');
|
||||
checks.push({ name: 'conflict retains local edits, JSON export, explicit server reload', passed: true });
|
||||
|
||||
// --- Plugins: einschalten, Baustein einfuegen, Eigenschaften aus dem Schema, Vorschau ------
|
||||
// Das Referenzplugin "Bildvergleich" liegt im Repository. Aus ist es zuerst; eingeschaltet
|
||||
// erscheint sein Baustein im Werkzeugkasten, das Panel entsteht aus dem Manifest, und die
|
||||
// Vorschau laedt sein Stylesheet - das Skript nicht, wie bei eigenem Code.
|
||||
await page.goto(origin + '/admin/plugins');
|
||||
await axe('plugins');
|
||||
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Einschalten', exact: true }).click();
|
||||
await page.locator('.notice.ok').filter({ hasText: 'eingeschaltet' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/plugins.png', fullPage: true });
|
||||
await page.goto(origin + '/admin/pages/test-home');
|
||||
await tab('Struktur');
|
||||
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
||||
await treeSelect('headline');
|
||||
await addFromLibrary('[data-add="vergleich.bildvergleich"]');
|
||||
await page.locator('#properties-title').filter({ hasText: 'Bildvergleich' }).waitFor();
|
||||
await page.getByLabel('Beschriftung vorher', { exact: true }).fill('Alt');
|
||||
await page.getByLabel('Startposition des Reglers (%)', { exact: true }).fill('30');
|
||||
for (const label of ['Bild vorher', 'Bild nachher']) {
|
||||
await page.locator('.plugin-media-field').filter({ hasText: label }).getByRole('button', { name: 'Bild aus Mediathek wählen', exact: true }).click();
|
||||
await page.locator('#media-dialog [data-media-id]').first().click();
|
||||
await page.locator('#media-insert').click();
|
||||
await page.locator('.plugin-media-field').filter({ hasText: label }).getByRole('button', { name: 'Anderes Bild wählen', exact: true }).waitFor();
|
||||
}
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.waitForFunction(() => {
|
||||
const doc = document.querySelector('#preview').contentDocument;
|
||||
return Boolean(doc.querySelector('.block-bildvergleich input[type="range"][value="30"]')) && Boolean(doc.querySelector('link[href^="/plugin-assets/vergleich/assets/bildvergleich.css"]')) && !doc.querySelector('script[src^="/plugin-assets/"]');
|
||||
});
|
||||
await page.screenshot({ path: 'reports/editor/plugin-block.png', fullPage: true });
|
||||
await page.locator('#save').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
{
|
||||
const antwort = await page.request.get(origin + '/');
|
||||
const html = await antwort.text();
|
||||
assert.match(html, /class="block-bildvergleich bildvergleich-rund/, 'Plugin-Baustein auf der Website');
|
||||
assert.match(html, /<script src="\/plugin-assets\/vergleich\/assets\/bildvergleich\.js\?v=1\.1\.0" defer><\/script>/);
|
||||
assert.match(antwort.headers()['content-security-policy'] ?? '', /script-src 'self'/);
|
||||
const css = await page.request.get(origin + '/plugin-assets/vergleich/assets/bildvergleich.css');
|
||||
assert.equal(css.status(), 200);
|
||||
}
|
||||
// Einstellungen des Plugins: aus dem Schema erzeugt, gespeichert, auf der Website sichtbar.
|
||||
await page.goto(origin + '/admin/plugins');
|
||||
await page.getByLabel('Standardbeschriftung vorher', { exact: true }).fill('Alt');
|
||||
await page.getByLabel('Griff des Reglers', { exact: true }).selectOption('punkt');
|
||||
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
||||
await page.locator('.notice.ok').filter({ hasText: 'Einstellungen' }).waitFor();
|
||||
assert.match(await (await page.request.get(origin + '/')).text(), /bildvergleich-griff-punkt/);
|
||||
// Die eigene Adresse des Plugins antwortet als JSON mit den Einstellungen.
|
||||
{
|
||||
const daten = await page.request.get(origin + '/vergleich/daten');
|
||||
assert.equal(daten.status(), 200);
|
||||
assert.equal((await daten.json()).labels.before, 'Alt');
|
||||
}
|
||||
// Die Seitenvorlage des Plugins im Werkzeugkasten, der Container mit seiner Kinderregel.
|
||||
await page.goto(origin + '/admin/pages/test-home');
|
||||
await tab('Struktur');
|
||||
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
||||
await tab('Elemente');
|
||||
await page.locator('#presets [data-preset="plugin:vergleich:abschnitt"]').click();
|
||||
await page.locator('#properties-title').filter({ hasText: 'Sektion' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.waitForFunction(() => document.querySelector('#preview').contentDocument.querySelectorAll('.block-bildvergleich').length >= 2);
|
||||
await treeSelect('headline');
|
||||
await addFromLibrary('[data-add="vergleich.galerie"]');
|
||||
await page.locator('#properties-title').filter({ hasText: 'Vergleichsgalerie' }).waitFor();
|
||||
// Ein Bildvergleich landet in der Galerie; ein Text passt nicht hinein und wird daneben eingefuegt.
|
||||
await addFromLibrary('[data-add="vergleich.bildvergleich"]');
|
||||
await page.locator('#properties-title').filter({ hasText: 'Bildvergleich' }).waitFor();
|
||||
await page.waitForFunction(() => document.querySelector('#preview').contentDocument.querySelector('.block-vergleichsgalerie .block-bildvergleich') !== null);
|
||||
await treeSelect(await page.evaluate(() => document.querySelector('#preview').contentDocument.querySelector('.block-vergleichsgalerie').id));
|
||||
await addFromLibrary('[data-add="text"]');
|
||||
await page.waitForFunction(() => {
|
||||
const doc = document.querySelector('#preview').contentDocument;
|
||||
return doc.querySelector('.block-vergleichsgalerie .block-text') === null && doc.querySelectorAll('.block-text').length > 0;
|
||||
});
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#undo').click(); await page.locator('#undo').click(); await page.locator('#undo').click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/plugin-container.png', fullPage: true });
|
||||
|
||||
// Aus: der Baustein bleibt im Dokument, die Website zeigt den Kommentar, der Editor den Platzhalter.
|
||||
await page.goto(origin + '/admin/plugins');
|
||||
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Ausschalten', exact: true }).click();
|
||||
await page.locator('.notice.ok').filter({ hasText: 'ausgeschaltet' }).waitFor();
|
||||
assert.match(await (await page.request.get(origin + '/')).text(), /<!-- Block [^ ]+ \(vergleich\.bildvergleich\) wird von dieser Installation nicht dargestellt\. -->/);
|
||||
await page.goto(origin + '/admin/pages/test-home');
|
||||
await tab('Struktur');
|
||||
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
||||
await page.locator('#structure').getByText('Baustein vergleich.bildvergleich').first().waitFor();
|
||||
await page.goto(origin + '/admin/plugins');
|
||||
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Einschalten', exact: true }).click();
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
checks.push({ name: 'plugin switched on, block inserted from the toolbox, schema panel, assets on the public page, switched off keeps the block', passed: true });
|
||||
|
||||
// Every management screen, not just the page list: they carry tables, disclosures and now the
|
||||
// media grid, and none of that was ever checked automatically before.
|
||||
for (const [name, path] of [
|
||||
|
||||
+1
-1
@@ -171,7 +171,7 @@ function kant_checks(string $target): array
|
||||
$add('Adresse und Prüfsumme eingetragen', '' !== KANT_ARCHIVE_URL && 64 === strlen(KANT_ARCHIVE_SHA256), 'Diese Datei ist unfertig: sie stammt nicht aus einem Release. Lade install.php von der Releaseseite herunter.');
|
||||
$free = @disk_free_space($target);
|
||||
$add('Freier Platz', false === $free || $free > 80 * 1024 * 1024, 'Es sind weniger als 80 MB frei. Das Archiv braucht Platz zum Herunterladen und zum Entpacken.');
|
||||
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
|
||||
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Wirft die DEEG-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/immobilien-deeg.db"
|
||||
export MEDIA_DIRECTORY="$root/var/immobilien-deeg-media"
|
||||
rm -f "$root/var/immobilien-deeg.db"
|
||||
rm -rf "$root/var/immobilien-deeg-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/immobilien-deeg-assets}"
|
||||
# Die Vorlage nutzt Arial; es gibt keine Schrift zu holen.
|
||||
"$php" -d memory_limit=512M scripts/seed-immobilien-deeg.php "$assets"
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Maler-Drews-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/maler-drews.db"
|
||||
export MEDIA_DIRECTORY="$root/var/maler-drews-media"
|
||||
rm -f "$root/var/maler-drews.db"
|
||||
rm -rf "$root/var/maler-drews-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/maler-drews-assets}"
|
||||
"$php" -d memory_limit=512M scripts/seed-maler-drews.php "$assets"
|
||||
# Lato und Cormorant Garamond vom eigenen Server. Der Abruf schreibt fonts.css nur fuer dieses
|
||||
# Design neu; die Datei teilen sich hier aber alle Installationen (Kant-IT und Elektro Bernhard
|
||||
# brauchen Lato 900). Deshalb nur holen, wenn die Cormorant-Dateien fehlen. Das PHP 8.4 hier kennt
|
||||
# keine Stammzertifikate; mit var/cacert.pem (https://curl.se/ca/cacert.pem) klappt der Abruf.
|
||||
if [ -f "$root/public/assets/fonts/cormorant-garamond-700-latin.woff2" ]; then
|
||||
echo "Schriften liegen bereits unter public/assets/fonts."
|
||||
elif [ -f "$root/var/cacert.pem" ]; then
|
||||
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
@@ -373,7 +373,7 @@ $fuss = [
|
||||
]),
|
||||
$column('fuss-zeiten', [
|
||||
$heading('fuss-zeiten-titel', 'Bürozeiten', 3, $fussTitel),
|
||||
$text('fuss-zeiten-text', "Mo–Do: 07:00–16:30 Uhr\nFr: 07:00–13:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '#c4c8ce']]),
|
||||
$text('fuss-zeiten-text', "Mo–Fr: 07:30–17:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '#c4c8ce']]),
|
||||
$text('fuss-zeiten-notdienst', '<strong>Notdienst: 24/7 erreichbar</strong>', ['desktop' => ['fontSize' => '15px', 'color' => '@papier', 'margin' => ['top' => '10px']]]),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'four', 'gap' => 'large'], 'tablet' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
@@ -405,11 +405,13 @@ $leistungen = [
|
||||
];
|
||||
|
||||
$fragen = [
|
||||
['Wo ist Elektro Bernhard im Einsatz?', 'Unser Standort ist in Baden-Baden. Wir betreuen Projekte in Baden-Baden, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, im Murgtal und in der Umgebung. Fragen Sie uns gerne zu Ihrem konkreten Projektort.'],
|
||||
['Wo ist Elektro Bernhard im Einsatz?', 'Unser Standort ist in Baden-Baden. Wir betreuen Projekte in Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und im Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg – von Karlsruhe bis Offenburg. Fragen Sie uns gerne zu Ihrem konkreten Projektort.'],
|
||||
['Übernehmen Sie Neubauten und Sanierungen?', 'Ja. Elektroinstallation für Neu- und Altbauten gehört zu unseren Leistungen. Wir beraten Sie zu Ihrem Vorhaben und planen gemeinsam die passende Umsetzung.'],
|
||||
['Was lässt sich mit Smart Home steuern?', 'Zum Beispiel Beleuchtung, Heizung und Sicherheitsfunktionen. Welche Steuerung zu Ihrem Gebäude passt, klären wir in einer individuellen Beratung.'],
|
||||
['Wie erreiche ich den 24h-Notdienst?', 'Bei einem elektrischen Notfall erreichen Sie unseren Kundendienst rund um die Uhr unter '.$notdienst.'. Für reguläre Projektanfragen erreichen Sie unser Büro unter '.$buero.'.'],
|
||||
['Bieten Sie auch Solaranlagen an?', 'Solarplanung und Installation übernimmt die eigenständige Elektro Bernhard Regenerative Energie GmbH. Das Angebot richtet sich an private Wohnhäuser und Geschäftsgebäude.'],
|
||||
['Wann ist das Büro erreichbar?', 'Montag bis Freitag von 07:30 bis 17:00 Uhr unter '.$buero.'. Außerhalb dieser Zeiten und am Wochenende hilft bei einem elektrischen Notfall der 24h-Notdienst unter '.$notdienst.'.'],
|
||||
['Wie läuft eine Anfrage ab?', 'Sie schildern uns Ihr Vorhaben und den Ort – per Formular, E-Mail oder Telefon. Wir melden uns innerhalb von 24 Stunden, klären offene Fragen und stimmen mit Ihnen einen Termin vor Ort oder ein Angebot ab.'],
|
||||
];
|
||||
|
||||
// Wortlaut, Name und Datum am 14.09.2026 aus den auf elektro-bernhard.de veröffentlichten
|
||||
@@ -445,7 +447,7 @@ $leistungstexte = [
|
||||
['Welche Technik passt zum Gebäude?', 'Bevor wir eine Lösung empfehlen, betrachten wir Ihre Wünsche und die Gegebenheiten vor Ort. So lässt sich klären, welche Technik zu Ihrem Projekt passt.'],
|
||||
]],
|
||||
'notdienst' => ['24h Elektro-Notdienst in Baden-Baden | Elektro Bernhard', '24h Elektro-Notdienst', 'Bei elektrischen Notfällen ist unser Kundendienst rund um die Uhr unter '.$notdienst.' erreichbar. Schildern Sie uns am Telefon das Problem und Ihren Standort.', [
|
||||
['Für die Region erreichbar', 'Unser Einsatzgebiet umfasst Baden-Baden, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, das Murgtal und die Umgebung. Die konkrete Verfügbarkeit für Ihren Standort klären wir im Gespräch.'],
|
||||
['Für die Region erreichbar', 'Unser Einsatzgebiet umfasst Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und das Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg. Die konkrete Verfügbarkeit für Ihren Standort klären wir im Gespräch.'],
|
||||
['Der direkte Weg zur Hilfe', 'Für einen Notfall nutzen Sie bitte die Notdienstnummer. Das Kontaktformular ist für reguläre Anfragen gedacht und wird nicht durchgehend überwacht.'],
|
||||
]],
|
||||
'beratung-planung' => ['Elektroplanung & Beratung in Baden-Baden | Elektro Bernhard', 'Beratung und Elektroplanung', 'Ob erste Idee oder konkretes Vorhaben: Wir beraten Sie individuell und begleiten die Planung Ihrer Elektrotechnik.', [
|
||||
@@ -514,7 +516,7 @@ $kontaktAbschnitt = static function (string $praefix) use ($section, $row, $colu
|
||||
]),
|
||||
$column($praefix.'-kontakt-zeiten', [
|
||||
$heading($praefix.'-kontakt-zeiten-titel', 'Bürozeiten', 3, ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'margin' => ['bottom' => '8px']]]),
|
||||
$text($praefix.'-kontakt-zeiten-text', "Mo–Do: 07:00–16:30 Uhr\nFr: 07:00–13:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@gedaempft']]),
|
||||
$text($praefix.'-kontakt-zeiten-text', "Mo–Fr: 07:30–17:00 Uhr\nSa–So: geschlossen", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@gedaempft']]),
|
||||
$text($praefix.'-kontakt-zeiten-notdienst', '<strong>Notdienst: 24/7 erreichbar</strong>', ['desktop' => ['fontSize' => '15px', 'margin' => ['top' => '10px']]]),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px'], 'margin' => ['top' => '0px']]]),
|
||||
@@ -542,7 +544,7 @@ $startseite = [
|
||||
$column('hero-text', [
|
||||
$fx($eyebrow('hero-label', 'Elektro Bernhard GmbH · Seit über 35 Jahren', '#ff7d90'), 'fade'),
|
||||
$fx($heading('hero-titel', "Ihr Elektriker in\nBaden-Baden.", 1, ['desktop' => ['fontSize' => '76px', 'lineHeight' => 1.04, 'letterSpacing' => '-0.035em', 'fontWeight' => '900', 'color' => '@papier', 'margin' => ['bottom' => '24px']], 'tablet' => ['fontSize' => '56px'], 'mobile' => ['fontSize' => '40px']]), 'rise', 'short'),
|
||||
$fx($text('hero-intro', 'Ob Neubau, Sanierung oder eine Störung an der Elektrik: Wir kümmern uns darum. Für Privatkunden und Betriebe in Baden-Baden, Rastatt und Umgebung.', ['desktop' => ['color' => '#ffffffd9', 'fontSize' => '20px', 'lineHeight' => 1.65, 'maxWidth' => '600px', 'margin' => ['bottom' => '34px']], 'mobile' => ['fontSize' => '17px']]), 'rise', 'medium'),
|
||||
$fx($text('hero-intro', 'Ob Neubau, Sanierung oder eine Störung an der Elektrik: Wir kümmern uns darum. Für Privatkunden und Betriebe von Karlsruhe bis Offenburg.', ['desktop' => ['color' => '#ffffffd9', 'fontSize' => '20px', 'lineHeight' => 1.65, 'maxWidth' => '600px', 'margin' => ['bottom' => '34px']], 'mobile' => ['fontSize' => '17px']]), 'rise', 'medium'),
|
||||
$fx($row('hero-aktionen', [
|
||||
$column('hero-aktion-anfrage', [$button('hero-anfrage', 'Anfrage stellen', '/kontakt')]),
|
||||
$column('hero-aktion-telefon', [
|
||||
@@ -664,7 +666,7 @@ $startseite = [
|
||||
$fx($kontaktBlock('fragen-telefon', ['phone' => $buero], ['desktop' => ['fontSize' => '18px', 'fontWeight' => '900', 'color' => '@rot', 'margin' => ['top' => '10px']]]), 'rise', 'medium'),
|
||||
]),
|
||||
$column('fragen-liste', [
|
||||
['id' => 'fragen-akkordeon', 'type' => 'accordion', 'children' => array_map(static function (array $f, int $i) use ($alleEcken): array {
|
||||
['id' => 'fragen-akkordeon', 'type' => 'accordion', 'props' => ['faq' => true], 'children' => array_map(static function (array $f, int $i) use ($alleEcken): array {
|
||||
return [
|
||||
'id' => 'frage-'.($i + 1), 'type' => 'accordionitem', 'props' => ['title' => $f[0], 'open' => 0 === $i],
|
||||
'style' => ['desktop' => ['backgroundColor' => '@weich', 'borderStyle' => 'none', 'borderRadius' => $alleEcken('14px'), 'padding' => ['top' => '4px', 'right' => '24px', 'bottom' => '4px', 'left' => '24px']]],
|
||||
@@ -740,7 +742,7 @@ foreach ($leistungen as [$slug, $icon, $name, $kurz]) {
|
||||
'blocks' => [
|
||||
$seitenkopf($slug, $name, $h1, $intro, [$knopf]),
|
||||
$textkoerper($slug, array_merge($absaetze, [
|
||||
['Ihr Elektriker vor Ort', 'Elektro Bernhard GmbH · Ooser Hauptstr. 51 · 76532 Baden-Baden. Für Baden-Baden, Hügelsheim, Iffezheim, Rastatt, Kuppenheim, Gaggenau, Gernsbach, Muggensturm, das Murgtal und die Umgebung.'],
|
||||
['Ihr Elektriker vor Ort', 'Elektro Bernhard GmbH · Ooser Hauptstr. 51 · 76532 Baden-Baden. Für Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach und das Murgtal, Ettlingen und Karlsruhe sowie Achern, Oberkirch und Offenburg.'],
|
||||
]), [], [
|
||||
$text($slug.'-zurueck', '<a href="/leistungen">Alle Leistungen ansehen →</a>', ['desktop' => [...$linkStil['desktop'], 'margin' => ['top' => '30px']]]),
|
||||
$cta($slug.'-cta'),
|
||||
@@ -841,7 +843,7 @@ echo "Menüs angelegt\n";
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => 'Elektro Bernhard GmbH',
|
||||
'description' => 'Elektrofachbetrieb in Baden-Baden, seit über 35 Jahren. Elektroinstallation für Neu- und Altbau, Smart Home, Beratung und Planung sowie ein 24h-Notdienst unter 0151 25245851. Bürozeiten Mo–Do 7:00–16:30 Uhr, Fr 7:00–13:00 Uhr.',
|
||||
'description' => 'Elektrofachbetrieb in Baden-Baden, seit über 35 Jahren. Elektroinstallation für Neu- und Altbau, Smart Home, Beratung und Planung sowie ein 24h-Notdienst unter 0151 25245851. Bürozeiten Mo–Fr 7:30–17:00 Uhr.',
|
||||
'phone' => $buero, 'email' => $mail,
|
||||
'street' => 'Ooser Hauptstr. 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'DE',
|
||||
// Die Profile, die der bestehende Auftritt selbst verlinkt - einmal hier, gezeigt vom Baustein
|
||||
@@ -851,6 +853,8 @@ $settings->save([
|
||||
'instagram' => 'https://www.instagram.com/elektrobernhardgmbh/',
|
||||
'google' => 'https://g.page/ElektroBernhardbadenbaden',
|
||||
],
|
||||
'hours' => array_fill_keys(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], ['opens' => '07:30', 'closes' => '17:00']),
|
||||
'areaServed' => implode("\n", ['Baden-Baden', 'Sinzheim', 'Hügelsheim', 'Iffezheim', 'Bühl', 'Rastatt', 'Kuppenheim', 'Muggensturm', 'Gaggenau', 'Gernsbach', 'Murgtal', 'Ettlingen', 'Karlsruhe', 'Achern', 'Oberkirch', 'Offenburg']),
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
// Der Kopfbereich schwebt über dem dunklen Seitenkopf und bleibt beim Scrollen oben.
|
||||
'stickyHeader' => true, 'headerOverlay' => true,
|
||||
|
||||
@@ -0,0 +1,996 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut die Website „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist das gelieferte Paket „Immobilien-DEEG-Website.zip“ (15.09.2026): ein Onepager aus
|
||||
* index.html, style.css und main.js mit dem unveränderten DEEG-Logo. Wortlaut, Farben (Logo-Blau
|
||||
* #0081c7, Anthrazit #343a40, helle Fläche #f5fafd), Schriftgrößen, Abstände und die Reihenfolge
|
||||
* der Abschnitte sind daraus übernommen. Gebaut ist alles aus gewöhnlichen Bausteinen – kein
|
||||
* eigener Baustein, kein Plugin, kein eigenes Skript, keine externe Schrift (Arial wie die Vorlage).
|
||||
*
|
||||
* Bewusste Abweichungen:
|
||||
* - Kontakt und Bewertungsanfrage sind zwei serverseitige Formulare dieses CMS („kontakt“ und
|
||||
* „bewertung“ – ein Preisfinder in drei Schritten mit Feldern je Objektart und einer
|
||||
* Zusammenfassung). Die Vorlage verlinkt dafür noch auf die bisherigen Seiten unter
|
||||
* www.immobiliendeeg.de; hier landen die Anfragen in der Verwaltung und per E-Mail.
|
||||
* - Nur „Aktuelle Angebote ansehen“ zeigt weiter auf www.immobiliendeeg.de/immobilien/: die
|
||||
* Immobilien-Datenbank ist nicht Teil des Pakets.
|
||||
* - Impressum und Datenschutz sind eigene Seiten. Register und Vertretung stammen vom Impressum
|
||||
* der laufenden Website (15.09.2026); was nur DEEG beantworten kann, steht als „NOCH ZU PRÜFEN“.
|
||||
* - Blau als Fläche mit weißer Schrift trägt #0076b6 statt #0081c7 (4,9:1 statt 4,2:1), kleine
|
||||
* blaue Schrift auf Weiß #006aa3. Das Logo-Blau bleibt für Linien und große Überschriften.
|
||||
*
|
||||
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
|
||||
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-immobilien-deeg.sh).
|
||||
* Erstes Argument: der Ordner mit logo-deeg.png, architecture.jpg und architecture-detail.jpg.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$assets = $argv[1] ?? null;
|
||||
if (null === $assets || !is_dir($assets)) {
|
||||
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
|
||||
exit(1);
|
||||
}
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
|
||||
if (!str_contains($url, 'immobilien-deeg')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
|
||||
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$media = new MediaLibrary($db, $mediaDirectory);
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$partials = new PartialRepository($db, new DocumentValidator());
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
$forms = new FormRepository($db);
|
||||
$theme = new Theme($db);
|
||||
|
||||
$firma = 'Deeg Hausverwaltung und Immobilien GmbH';
|
||||
$mail = 'deeg@immobiliendeeg.de';
|
||||
$telefon = '07441 84542';
|
||||
$telHref = 'tel:+49744184542';
|
||||
$angebote = 'https://www.immobiliendeeg.de/immobilien/';
|
||||
$route = 'https://www.google.com/maps/search/?api=1&query=Ringstra%C3%9Fe+30+72250+Freudenstadt';
|
||||
$SCHEMA = 23;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
|
||||
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
$nurLinks = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $w];
|
||||
$obenUnten = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null): array {
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Der Rhythmus der Vorlage: 108 px am Schreibtisch, 80 auf dem Tablet, 65 auf dem Telefon. */
|
||||
$abschnitt = static function (string $id, array $children, array $style = [], ?string $anchor = null) use ($section): array {
|
||||
$basis = [
|
||||
'desktop' => ['padding' => ['top' => '108px', 'bottom' => '108px']],
|
||||
'tablet' => ['padding' => ['top' => '80px', 'bottom' => '80px']],
|
||||
'mobile' => ['padding' => ['top' => '65px', 'bottom' => '65px']],
|
||||
];
|
||||
foreach ($style as $device => $values) {
|
||||
$basis[$device] = [...($basis[$device] ?? []), ...$values];
|
||||
}
|
||||
|
||||
return $section($id, $children, $basis, $anchor);
|
||||
};
|
||||
|
||||
$row = static function (string $id, array $children, array $design = [], array $style = []): array {
|
||||
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
|
||||
if ([] !== $design) {
|
||||
$block['layout'] = ['design' => $design];
|
||||
}
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Eine Zeile, die nebeneinander stellt statt zu rastern – Kleinzeile, Aktionen, Leiste, Fuß. */
|
||||
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = []) use ($row): array {
|
||||
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, 'margin' => ['top' => '0px'], ...$extra]];
|
||||
if ([] !== $tablet) {
|
||||
$style['tablet'] = $tablet;
|
||||
}
|
||||
if ([] !== $mobil) {
|
||||
$style['mobile'] = $mobil;
|
||||
}
|
||||
|
||||
return $row($id, $children, [], $style);
|
||||
};
|
||||
|
||||
$column = static function (string $id, array $children, array $style = [], string $sichtbarkeit = 'all', ?string $anchor = null, string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('all' !== $sichtbarkeit) {
|
||||
$block['layout'] = ['visibility' => $sichtbarkeit];
|
||||
}
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Eine reine Fläche: der kurze Strich vor der Kleinzeile, der Verlauf über dem Startbild. */
|
||||
$flaeche = static function (string $id, array $style, string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'spacer', 'props' => ['size' => 'small'], 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Ein Button ohne den Pfeil des Bausteins: die Vorlage kommt ausdrücklich ohne Pfeile aus. */
|
||||
$button = static function (string $id, string $text, string $href, array $style = []): array {
|
||||
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style, 'css' => '& span{display:none}'];
|
||||
};
|
||||
|
||||
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array {
|
||||
$gross = end($eintrag['variants']);
|
||||
$props = [
|
||||
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
|
||||
'mediaId' => $eintrag['id'],
|
||||
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
|
||||
];
|
||||
if (null !== $href) {
|
||||
$props['href'] = $href;
|
||||
$props['click'] = 'link';
|
||||
}
|
||||
|
||||
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$symbol = static function (string $id, string $name, int $groesse, array $style = []): array {
|
||||
return ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt die Einblendungen nach einer Liste, statt sie über das ganze Dokument zu streuen.
|
||||
*
|
||||
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
|
||||
*/
|
||||
$einblenden = static function (array $dokument, array $plan): array {
|
||||
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
|
||||
foreach ($bloecke as $i => $block) {
|
||||
if (isset($plan[$block['id'] ?? ''])) {
|
||||
[$art, $verzug] = [...$plan[$block['id']], 'none'];
|
||||
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$bloecke[$i]['children'] = $gehen($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $bloecke;
|
||||
};
|
||||
$dokument['blocks'] = $gehen($dokument['blocks']);
|
||||
|
||||
return $dokument;
|
||||
};
|
||||
|
||||
// Wiederkehrende Gestaltungswerte der Vorlage.
|
||||
$h2Stil = ['desktop' => ['fontWeight' => '400', 'fontSize' => '45px', 'lineHeight' => 1.16, 'letterSpacing' => '-0.045em', 'margin' => ['bottom' => '26px']], 'tablet' => ['fontSize' => '38px'], 'mobile' => ['fontSize' => '35px']];
|
||||
$h3Stil = ['desktop' => ['fontWeight' => '400', 'fontSize' => '24px', 'lineHeight' => 1.3, 'letterSpacing' => '-0.035em', 'margin' => ['bottom' => '18px']], 'tablet' => ['fontSize' => '22px']];
|
||||
$fliess = ['desktop' => ['color' => '@text', 'fontSize' => '16px', 'lineHeight' => 1.85, 'margin' => ['bottom' => '20px']]];
|
||||
$kleinStil = ['desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '25px']], 'mobile' => ['fontSize' => '11px', 'margin' => ['bottom' => '18px']]];
|
||||
$linkCss = "& a{color:#006aa3;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#343a40}";
|
||||
$textLinkCss = "& a{display:inline-flex;align-items:center;font-size:14px;font-weight:600;border-bottom:1px solid #8d9a9f;padding-bottom:4px;text-decoration:none}\n& a:hover{color:#006aa3;border-color:#006aa3}";
|
||||
$formularCss = '& .form-field label{font-size:14px;font-weight:600;color:#343a40}
|
||||
& .form-field input{background:#fff;border:1px solid #c8dce8;padding:12px 13px;font-size:16px;color:#343a40}
|
||||
& .form-field select{background:#fff;border:1px solid #c8dce8;padding:12px 13px;font-size:16px;color:#343a40}
|
||||
& .form-field textarea{background:#fff;border:1px solid #c8dce8;padding:12px 13px;font-size:16px;color:#343a40;min-height:120px}
|
||||
& .field-note{font-size:13px;color:#58666d}
|
||||
& .check-row label{font-weight:400;font-size:14px;line-height:1.6}
|
||||
& .cms-button{width:100%;justify-content:center;margin-top:4px}';
|
||||
|
||||
/** Die Kleinzeile in Versalien; im Hero mit dem kurzen blauen Strich davor. */
|
||||
$kleinzeile = static fn (string $id, string $inhalt, string $farbe = '@anthrazit', string $abstand = '25px'): array => $text($id, $inhalt, [
|
||||
'desktop' => [...$kleinStil['desktop'], 'color' => $farbe, 'margin' => ['bottom' => $abstand]],
|
||||
'mobile' => $kleinStil['mobile'],
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien, Formulare
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'DEEG Immobilien & Hausverwaltung', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'blau', 'name' => 'Logo-Blau', 'value' => '#0081c7'],
|
||||
['key' => 'blau-tief', 'name' => 'Blau als Fläche', 'value' => '#0076b6'],
|
||||
['key' => 'blau-dunkel', 'name' => 'Blau dunkel', 'value' => '#006aa3'],
|
||||
['key' => 'anthrazit', 'name' => 'Anthrazit', 'value' => '#343a40'],
|
||||
['key' => 'text', 'name' => 'Fließtext', 'value' => '#58666d'],
|
||||
['key' => 'meta', 'name' => 'Nebentext', 'value' => '#66747b'],
|
||||
['key' => 'hell', 'name' => 'Helle Fläche', 'value' => '#f5fafd'],
|
||||
['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#dbe1e3'],
|
||||
['key' => 'linie-hell', 'name' => 'Trennlinie hell', 'value' => '#e0ebf1'],
|
||||
['key' => 'kante', 'name' => 'Kopfkante', 'value' => '#e9edef'],
|
||||
['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
// Die Vorlage lädt bewusst keine Webschrift; Arial steht auf jedem Gerät bereit.
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 700], 'fallback' => 'sans-serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'backgroundColor' => '@papier', 'linkColor' => '@anthrazit', 'containerWidth' => '1320px', 'headingScale' => 1.25, 'linkUnderline' => false],
|
||||
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 14, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau-tief', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo-deeg.png', 'DEEG Hausverwaltung und Immobilien'],
|
||||
'hero' => ['architecture.jpg', 'Moderne Wohnarchitektur mit klaren Linien und großzügigen Fenstern'],
|
||||
'detail' => ['architecture-detail.jpg', 'Wohngebäude mit charaktervoller Architektur'],
|
||||
// Die Bildauswahl der Objektart im Preisfinder: einfache Piktogramme als Platzhalter,
|
||||
// in der Mediathek jederzeit gegen Fotos tauschbar.
|
||||
'opt-wohnung' => ['option-wohnung.png', 'Piktogramm: Wohnung in einem Gebäude'],
|
||||
'opt-haus' => ['option-haus.png', 'Piktogramm: Haus'],
|
||||
'opt-mfh' => ['option-mehrfamilienhaus.png', 'Piktogramm: Mehrfamilienhaus'],
|
||||
'opt-grund' => ['option-grundstueck.png', 'Piktogramm: Grundstück mit Baum'],
|
||||
] as $schluessel => [$datei, $alt]) {
|
||||
$pfad = rtrim($assets, '/\\').'/'.$datei;
|
||||
if (!is_file($pfad)) {
|
||||
fwrite(STDERR, "Bild fehlt: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$eintrag = $media->upload(new UploadedFile($pfad, $datei, null, null, true));
|
||||
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
|
||||
$medien[$schluessel] = $media->find($eintrag['id']);
|
||||
echo "Bild übernommen: $datei\n";
|
||||
}
|
||||
|
||||
$einwilligung = 'Ich habe die Datenschutzhinweise gelesen. Meine Angaben werden verwendet, um meine Anfrage zu beantworten.';
|
||||
|
||||
$forms->save('kontakt', 'Kontaktanfrage', [
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Für einen persönlichen Rückruf'],
|
||||
['name' => 'anliegen', 'type' => 'select', 'label' => 'Worum geht es?', 'required' => false, 'options' => ['Verkauf', 'Vermietung', 'Verwaltung', 'Etwas anderes']],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => $einwilligung],
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Nachricht über immobiliendeeg.de', 'success' => 'Vielen Dank. Ihre Nachricht ist angekommen – wir melden uns bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Nachricht senden'], 0);
|
||||
echo "Formular angelegt: kontakt\n";
|
||||
|
||||
// Die Bewertungsanfrage als Preisfinder in drei Schritten, nach dem Vorbild der laufenden
|
||||
// Website: Objektart und Anlass, dann die Eckdaten je Objektart (Felder hinter Bedingungen), dann
|
||||
// Lage und Kontakt, zum Schluss die Zusammenfassung. Bewusst kein Plugin - ein Formular mit
|
||||
// Schritten und Bedingungen reicht, und die Anfrage liegt wie jede andere in der Verwaltung.
|
||||
$wohnen = ['Wohnung', 'Haus', 'Mehrfamilienhaus'];
|
||||
$bedingt = static fn (array $feld, array $werte): array => [...$feld, 'showIf' => ['field' => 'immobilienart', 'values' => $werte]];
|
||||
$forms->save('bewertung', 'Bewertungsanfrage', [
|
||||
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Immobilie', 'help' => 'Womit können wir Ihnen helfen?'],
|
||||
['name' => 'immobilienart', 'type' => 'radio', 'label' => 'Um welche Immobilie geht es?', 'required' => true, 'options' => ['Wohnung', 'Haus', 'Mehrfamilienhaus', 'Grundstück'],
|
||||
'images' => ['Wohnung' => $medien['opt-wohnung']['id'], 'Haus' => $medien['opt-haus']['id'], 'Mehrfamilienhaus' => $medien['opt-mfh']['id'], 'Grundstück' => $medien['opt-grund']['id']]],
|
||||
['name' => 'anlass', 'type' => 'radio', 'label' => 'Was haben Sie vor?', 'required' => true, 'options' => ['Verkaufen', 'Vermieten']],
|
||||
|
||||
['name' => 'schritt_eckdaten', 'type' => 'step', 'label' => 'Eckdaten', 'help' => 'Was wir über die Immobilie wissen sollten. Schätzwerte genügen.'],
|
||||
$bedingt(['name' => 'wohnflaeche', 'type' => 'number', 'label' => 'Wohnfläche', 'required' => true, 'min' => 10, 'max' => 2000, 'unit' => 'm²'], $wohnen),
|
||||
$bedingt(['name' => 'grundstueck', 'type' => 'number', 'label' => 'Grundstücksfläche', 'required' => false, 'min' => 10, 'max' => 100000, 'unit' => 'm²'], ['Haus', 'Mehrfamilienhaus']),
|
||||
$bedingt(['name' => 'flaeche_grundstueck', 'type' => 'number', 'label' => 'Grundstücksfläche', 'required' => true, 'min' => 10, 'max' => 100000, 'unit' => 'm²'], ['Grundstück']),
|
||||
$bedingt(['name' => 'zimmer', 'type' => 'select', 'label' => 'Zimmer', 'required' => false, 'options' => ['1', '2', '3', '4', '5', '6', '7', '8 oder mehr']], ['Wohnung', 'Haus']),
|
||||
$bedingt(['name' => 'einheiten', 'type' => 'number', 'label' => 'Wohneinheiten', 'required' => true, 'min' => 2, 'max' => 200], ['Mehrfamilienhaus']),
|
||||
$bedingt(['name' => 'baujahr', 'type' => 'number', 'label' => 'Baujahr', 'required' => false, 'min' => 1800, 'max' => 2026], $wohnen),
|
||||
$bedingt(['name' => 'zustand', 'type' => 'select', 'label' => 'Zustand', 'required' => false, 'options' => ['Neuwertig', 'Gepflegt', 'Renovierungsbedürftig', 'Sanierungsbedürftig']], $wohnen),
|
||||
$bedingt(['name' => 'einbaukueche', 'type' => 'select', 'label' => 'Einbauküche', 'required' => false, 'options' => ['ja (neuwertig)', 'ja (gebraucht)', 'keine vorhanden']], ['Wohnung']),
|
||||
$bedingt(['name' => 'aufzug', 'type' => 'select', 'label' => 'Aufzug', 'required' => false, 'options' => ['ja', 'nein']], ['Wohnung']),
|
||||
$bedingt(['name' => 'parken', 'type' => 'select', 'label' => 'Parkmöglichkeit', 'required' => false, 'options' => ['Tiefgarage', 'Garage', 'Doppelgarage', 'Carport', 'Stellplatz', 'mehrere', 'keine']], $wohnen),
|
||||
$bedingt(['name' => 'bebaubar', 'type' => 'select', 'label' => 'Ist das Grundstück bebaubar?', 'required' => false, 'options' => ['ja', 'nein', 'unklar']], ['Grundstück']),
|
||||
|
||||
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Lage und Kontakt', 'help' => 'Damit wir die Lage einordnen und Sie erreichen können.'],
|
||||
['name' => 'strasse', 'type' => 'text', 'label' => 'Straße und Hausnummer der Immobilie', 'required' => true],
|
||||
['name' => 'ort', 'type' => 'text', 'label' => 'PLZ und Ort', 'required' => true],
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Für Rückfragen zur Immobilie'],
|
||||
['name' => 'hinweise', 'type' => 'textarea', 'label' => 'Was sollten wir noch wissen?', 'required' => false],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => $einwilligung],
|
||||
], ['recipient' => $mail, 'subject' => 'Bewertungsanfrage über immobiliendeeg.de', 'success' => 'Vielen Dank. Ihre Bewertungsanfrage ist angekommen – wir melden uns persönlich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Bewertung anfragen', 'summary' => true], 0);
|
||||
echo "Formular angelegt: bewertung\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopfbereich: helle Leiste, darunter der weiße Kopf, der beim Scrollen oben bleibt
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$logo = static fn (string $id, string $breite, string $mobil) => $bild($id, $medien['logo'], 'DEEG Hausverwaltung und Immobilien', [
|
||||
'desktop' => ['maxWidth' => $breite, 'margin' => $seiten('0px')],
|
||||
'mobile' => ['maxWidth' => $mobil],
|
||||
], '/');
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf-leiste', [
|
||||
$zeile('kopf-leiste-zeile', [
|
||||
$column('kopf-leiste-ort', [
|
||||
$text('kopf-leiste-ort-text', 'In Freudenstadt zuhause. Seit 1994.', ['desktop' => ['fontSize' => '13px', 'letterSpacing' => '0.02em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]]),
|
||||
], [], 'hide-mobile'),
|
||||
$zeile('kopf-leiste-kontakt', [
|
||||
$text('kopf-leiste-telefon', '<a href="'.$telHref.'">'.$telefon.'</a>', [
|
||||
'desktop' => ['fontSize' => '13px', 'letterSpacing' => '0.02em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '12px'],
|
||||
], '& a:hover{text-decoration:underline}'),
|
||||
$column('kopf-leiste-label', [
|
||||
$text('kopf-leiste-label-text', '· Wir sind für Sie da', ['desktop' => ['fontSize' => '13px', 'letterSpacing' => '0.02em', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
|
||||
], [], 'hide-mobile'),
|
||||
], '5px'),
|
||||
], '20px', ['justifyContent' => 'space-between'], ['justifyContent' => 'center']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@hell', 'color' => '@anthrazit', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => ['top' => '9px', 'bottom' => '9px']],
|
||||
]),
|
||||
$section('kopf-haupt', [
|
||||
$row('kopf-haupt-zeile', [
|
||||
$column('kopf-marke', [$logo('kopf-logo', '113px', '100px')], ['desktop' => ['flexShrink' => 0]]),
|
||||
$column('kopf-navigation', [
|
||||
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'mobile', 'burgerLabel' => 'both'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '14px', 'fontWeight' => '600', 'justifyContent' => 'flex-end', 'display' => 'flex',
|
||||
// Der Menü-Knopf im Zuschnitt der Vorlage: feiner Rahmen, keine Rundung.
|
||||
'burgerColor' => '@anthrazit', 'burgerBorderColor' => '#cbd4d6', 'burgerBorderRadius' => '0px', 'burgerFontSize' => '14px',
|
||||
]],
|
||||
// Der letzte Eintrag ist der blaue Knopf „Immobilie bewerten“.
|
||||
'css' => "& .menu-list{gap:4px 30px;justify-content:flex-end;align-items:center}\n& .menu-list a{transition:color .2s,background .2s}\n& .menu-list a:hover{color:#006aa3;text-decoration:none}\n& .menu-list>li:last-child>a{background:#0076b6;color:#fff;padding:13px 18px}\n& .menu-list>li:last-child>a:hover{background:#006aa3;color:#fff}",
|
||||
],
|
||||
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'center']]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'space-between', 'gap' => '30px', 'minHeight' => '99px', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['minHeight' => '86px'],
|
||||
'mobile' => ['minHeight' => '80px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '#fffffff5', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@kante', 'padding' => ['top' => '0px', 'bottom' => '0px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Fußbereich
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$zeile('fuss-oben', [
|
||||
$column('fuss-marke', [$logo('fuss-logo', '113px', '100px')], ['desktop' => ['flexShrink' => 0]]),
|
||||
$column('fuss-spruch', [
|
||||
$text('fuss-spruch-text', 'Gut beraten. Persönlich begleitet.', [
|
||||
'desktop' => ['fontSize' => '15px', 'color' => '@text', 'textAlign' => 'center', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '13px', 'textAlign' => 'left'],
|
||||
]),
|
||||
], ['mobile' => ['order' => 3, 'flexBasis' => '100%']]),
|
||||
$column('fuss-nachoben', [
|
||||
$text('fuss-nachoben-text', '<a href="#start">Nach oben</a>', [
|
||||
'desktop' => ['fontSize' => '14px', 'color' => '@blau-dunkel', 'textAlign' => 'right', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['margin' => ['left' => 'auto']],
|
||||
], "& a{color:#006aa3}\n& a:hover{text-decoration:underline}"),
|
||||
]),
|
||||
], '30px', ['justifyContent' => 'space-between', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#dce7ed', 'padding' => ['top' => '45px', 'bottom' => '36px']], ['flexWrap' => 'wrap', 'gap' => '25px', 'padding' => ['top' => '30px', 'bottom' => '25px']]),
|
||||
$zeile('fuss-unten', [
|
||||
$column('fuss-recht', [
|
||||
// Die Farbe steht am Block selbst: der Fuß hellt kleinen Text sonst auf 3,8:1 auf.
|
||||
$text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '11px', 'color' => '@text', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '10px']]),
|
||||
]),
|
||||
$zeile('fuss-links', [
|
||||
// Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü.
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
|
||||
'style' => ['desktop' => ['fontSize' => '11px', 'gap' => '4px']],
|
||||
'css' => '& a:hover{color:#006aa3}'],
|
||||
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '11px', 'margin' => ['bottom' => '0px']]], '& a:hover{color:#006aa3}'),
|
||||
], '25px', ['flexWrap' => 'wrap'], ['rowGap' => '12px', 'columnGap' => '18px', 'justifyContent' => 'flex-start']),
|
||||
], '30px', ['justifyContent' => 'space-between', 'alignItems' => 'center', 'padding' => ['top' => '23px', 'bottom' => '23px'], 'color' => '@text'], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px', 'padding' => ['top' => '22px', 'bottom' => '28px']]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#dce7ed', 'padding' => ['top' => '0px', 'bottom' => '0px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
/**
|
||||
* Eine Rechtsseite: Kleinzeile, Titel, Vorspann auf heller Fläche, dann Absätze mit
|
||||
* Zwischenüberschriften. Was nur DEEG beantworten kann, steht als „NOCH ZU PRÜFEN“ im Text.
|
||||
*/
|
||||
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $abschnitt, $row, $column, $kleinzeile, $heading, $text, $nurOben, $nurUnten, $linkCss): array {
|
||||
$kopf = $section($praefix.'-kopf', [
|
||||
[...$kleinzeile($praefix.'-label', $label), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
[...$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => ['fontWeight' => '400', 'fontSize' => '56px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.05em', 'margin' => ['bottom' => '22px']],
|
||||
'tablet' => ['fontSize' => '44px'],
|
||||
'mobile' => ['fontSize' => '38px', 'margin' => ['bottom' => '16px']],
|
||||
]), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
|
||||
[...$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
]), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'medium']]],
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => ['top' => '84px', 'bottom' => '72px']],
|
||||
'tablet' => ['padding' => ['top' => '64px', 'bottom' => '56px']],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '40px']],
|
||||
]);
|
||||
|
||||
$punkte = [];
|
||||
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
|
||||
$punkte[] = $row($praefix.'-punkt-'.$i, [
|
||||
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
|
||||
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
|
||||
'desktop' => ['fontSize' => '17px', 'fontWeight' => '700', 'letterSpacing' => '-0.01em', 'lineHeight' => 1.5, 'color' => '@anthrazit', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['flexBasis' => '280px', 'flexShrink' => 0],
|
||||
'tablet' => ['flexBasis' => '220px'],
|
||||
'mobile' => ['flexBasis' => 'auto'],
|
||||
]),
|
||||
$column($praefix.'-punkt-'.$i.'-text', [
|
||||
$text($praefix.'-p-'.$i, $inhalt, [
|
||||
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
], $linkCss),
|
||||
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0']]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '48px', 'margin' => ['top' => '0px'],
|
||||
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '28px', 'bottom' => '28px']],
|
||||
'tablet' => ['gap' => '32px'],
|
||||
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '22px', 'bottom' => '22px']],
|
||||
]);
|
||||
}
|
||||
|
||||
$inhalt = $abschnitt($praefix, [
|
||||
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '960px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '56px', 'bottom' => '108px']], 'mobile' => ['padding' => ['top' => '28px', 'bottom' => '65px']]]);
|
||||
|
||||
return [$kopf, $inhalt];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// Nummer, Sprungziel, Symbol, Titel, Text, Titel des Aufklappers, dessen Inhalt – die drei Karten.
|
||||
$leistungen = [
|
||||
['01 / VERKAUF', 'verkauf', 'home', 'Ein guter Verkauf<br>beginnt mit Vertrauen.',
|
||||
'Wir kennen die Region und beobachten den Markt. Gemeinsam finden wir einen realistischen Angebotspreis und bringen Ihre Immobilie überzeugend zur Geltung.',
|
||||
'So begleiten wir Ihren Verkauf',
|
||||
'<ol><li><strong>Wert einschätzen.</strong> Lage, Zustand und Marktsituation bilden die Grundlage.</li><li><strong>Passend präsentieren.</strong> Wir stellen Ihre Immobilie in den passenden Angebotsmedien vor.</li><li><strong>Persönlich begleiten.</strong> Sie bleiben über die Schritte informiert – bis zum Notartermin und zur Übergabe.</li></ol><p><a href="#kontakt">Verkauf besprechen</a></p>'],
|
||||
['02 / VERMIETUNG', 'vermietung', 'people', 'Die richtigen Menschen.<br>Für Ihre Immobilie.',
|
||||
'Eine gute Vermietung braucht Zeit und Erfahrung. Wir unterstützen Sie dabei, passende Mietinteressenten zu finden und nehmen Ihnen Arbeit ab.',
|
||||
'Mehr zur Vermietung',
|
||||
'<p>Wir vermitteln seit vielen Jahren Wohnungen und kennen die Fragen, die Eigentümer beschäftigen. Wir begleiten die Suche nach geeigneten Mietern und stimmen das Vorgehen mit Ihnen ab.</p><p><a href="#kontakt">Vermietung anfragen</a></p>'],
|
||||
['03 / VERWALTUNG', 'verwaltung', 'shield', 'Werte erhalten.<br>Mit Weitblick verwalten.',
|
||||
'Ihr Eigentum verdient Aufmerksamkeit. Wir betreuen Wohn- und Gewerbeimmobilien zuverlässig – mit Sachkenntnis, Transparenz und persönlichem Einsatz.',
|
||||
'Unsere Verwaltungsleistungen',
|
||||
'<ul><li>WEG-Verwaltung für Gemeinschaften unterschiedlicher Größe</li><li>Sondereigentumsverwaltung einzelner Wohnungen und Gewerbeeinheiten</li><li>Mietverwaltung für Ihre vermieteten Immobilien</li></ul><p><a href="#kontakt">Verwaltungsangebot anfragen</a></p>'],
|
||||
];
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'DEEG Immobilien & Hausverwaltung',
|
||||
'description' => 'Immobilien verkaufen, vermieten und verwalten in Freudenstadt. DEEG begleitet Eigentümer persönlich – seit 1994. Jetzt Kontakt aufnehmen.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Text links, das Foto rechts mit Schild, Bildzeile und blauem Aufruf.
|
||||
$section('hero', [
|
||||
$row('hero-zeile', [
|
||||
$column('hero-text', [
|
||||
$zeile('hero-label', [
|
||||
$flaeche('hero-label-strich', ['desktop' => ['width' => '29px', 'height' => '1px', 'backgroundColor' => '@blau', 'flexShrink' => 0]], 'min-height:0'),
|
||||
$text('hero-label-text', 'IMMOBILIEN. VERTRAUEN. DEEG.', ['desktop' => [...$kleinStil['desktop'], 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '11px']]),
|
||||
], '11px', ['margin' => ['top' => '0px', 'bottom' => '25px']], ['margin' => ['top' => '0px', 'bottom' => '22px']]),
|
||||
$heading('hero-titel', 'Für Ihr Eigentum.<br>Für Ihr Zuhause.<br><em>Für Sie.</em>', 1, [
|
||||
'desktop' => ['fontWeight' => '400', 'fontSize' => '66px', 'lineHeight' => 1.075, 'letterSpacing' => '-0.057em', 'margin' => ['bottom' => '25px']],
|
||||
'tablet' => ['fontSize' => '52px'],
|
||||
'mobile' => ['fontSize' => '46px', 'margin' => ['bottom' => '23px']],
|
||||
], '& em{color:#0081c7;font-style:normal}'),
|
||||
$text('hero-intro', 'Eine Immobilie ist mehr als ein Gebäude. Wir begleiten Sie beim Verkauf, bei der Vermietung und in der Verwaltung – persönlich und mit einem klaren Blick für Ihre Ziele.', [
|
||||
'desktop' => ['maxWidth' => '440px', 'color' => '@text', 'fontSize' => '16px', 'lineHeight' => 1.85, 'margin' => ['bottom' => '30px']],
|
||||
'mobile' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'maxWidth' => '450px', 'margin' => ['bottom' => '24px']],
|
||||
]),
|
||||
$zeile('hero-aktionen', [
|
||||
$column('hero-aktion-knopf', [
|
||||
$button('hero-knopf', 'Lassen Sie uns sprechen', '#kontakt', ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['flexShrink' => 0]]),
|
||||
$column('hero-aktion-link', [
|
||||
$text('hero-textlink', '<a href="#leistungen">DEEG kennenlernen</a>', ['desktop' => ['margin' => ['bottom' => '0px']]], $textLinkCss),
|
||||
], ['desktop' => ['flexShrink' => 0]]),
|
||||
], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '14px', 'columnGap' => '22px']),
|
||||
$zeile('hero-notiz', [
|
||||
$text('hero-notiz-zahl', '30<em>+</em>', [
|
||||
'desktop' => ['fontSize' => '46px', 'fontWeight' => '400', 'lineHeight' => 1, 'letterSpacing' => '-0.055em', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '38px'],
|
||||
], '& em{font-size:26px;vertical-align:top;line-height:1.4;color:#0081c7;font-style:normal}'),
|
||||
$text('hero-notiz-text', 'Jahre Erfahrung.<br><strong>Und immer persönlich für Sie da.</strong>', [
|
||||
'desktop' => ['fontSize' => '12px', 'lineHeight' => 1.6, 'color' => '@text', 'borderWidth' => $nurLinks('1px'), 'borderStyle' => 'solid', 'borderColor' => '#d1d8da', 'padding' => ['left' => '20px'], 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '11px', 'padding' => ['left' => '12px']],
|
||||
], '& strong{font-weight:400;color:#343a40}'),
|
||||
], '20px', ['margin' => ['top' => 'auto'], 'padding' => ['top' => '38px']], ['gap' => '12px', 'padding' => ['top' => '29px']]),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'padding' => ['top' => '41px', 'bottom' => '12px']],
|
||||
'tablet' => ['padding' => ['top' => '14px', 'bottom' => '0px']],
|
||||
'mobile' => ['padding' => ['top' => '10px', 'bottom' => '0px']],
|
||||
]),
|
||||
$column('hero-visual', [
|
||||
$bild('hero-bild', $medien['hero'], 'Moderne Wohnarchitektur mit klaren Linien und großzügigen Fenstern', [
|
||||
'desktop' => ['imageHeight' => '580px', 'objectFit' => 'cover', 'objectPosition' => 'center', 'margin' => $seiten('0px')],
|
||||
'tablet' => ['imageHeight' => '565px'],
|
||||
'mobile' => ['imageHeight' => '420px'],
|
||||
], null, '& img{display:block;width:100%}'),
|
||||
// Der dunkle Verlauf über der unteren Bildhälfte, damit die Bildzeile lesbar bleibt.
|
||||
$flaeche('hero-verlauf', ['desktop' => ['position' => 'absolute', 'left' => '0px', 'right' => '0px', 'bottom' => '0px', 'width' => 'auto', 'height' => 'auto', 'zIndex' => 1]], 'top:50%;min-height:0;background:linear-gradient(transparent,rgba(5,23,32,.6));pointer-events:none'),
|
||||
$text('hero-schild', 'RAUM FÜR NEUE PERSPEKTIVEN', [
|
||||
'desktop' => ['position' => 'absolute', 'top' => '24px', 'left' => '24px', 'zIndex' => 2, 'padding' => ['top' => '8px', 'right' => '12px', 'bottom' => '8px', 'left' => '12px'], 'fontSize' => '10px', 'fontWeight' => '700', 'letterSpacing' => '0.12em', 'color' => '@papier', 'backgroundColor' => '#12272d8c', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['top' => '20px', 'left' => '20px', 'fontSize' => '9px'],
|
||||
], 'backdrop-filter:blur(8px)'),
|
||||
$zeile('hero-bildzeile', [
|
||||
$text('hero-bildzeile-ort', 'Freudenstadt & Umgebung', ['desktop' => ['fontSize' => '11px', 'letterSpacing' => '0.04em', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('hero-bildzeile-lage', '48° N · 8° O', ['desktop' => ['fontSize' => '11px', 'letterSpacing' => '0.04em', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
|
||||
], '20px', ['position' => 'absolute', 'left' => '27px', 'right' => '27px', 'bottom' => '27px', 'zIndex' => 2, 'justifyContent' => 'space-between'], ['left' => '20px', 'right' => '20px', 'bottom' => '20px']),
|
||||
$text('hero-aufruf', '<a href="#bewertung">Was ist Ihre<br><strong>Immobilie wert?</strong></a>', [
|
||||
'desktop' => ['position' => 'absolute', 'right' => '0px', 'bottom' => '83px', 'zIndex' => 3, 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['bottom' => '64px'],
|
||||
], "& a{display:flex;align-items:center;gap:36px;background:#0076b6;color:#fff;padding:24px 28px;font-size:15px;line-height:1.5;text-decoration:none;transition:background .2s}\n& a:hover{background:#006aa3}\n& strong{font-weight:600}"),
|
||||
], [
|
||||
'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'backgroundColor' => '#d7dee0', 'minHeight' => '580px'],
|
||||
'tablet' => ['minHeight' => '565px'],
|
||||
'mobile' => ['minHeight' => '420px'],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '42px', 'alignItems' => 'stretch', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '22px'],
|
||||
'mobile' => ['gap' => '27px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['padding' => ['top' => '36px', 'bottom' => '42px']],
|
||||
'tablet' => ['padding' => ['top' => '24px', 'bottom' => '42px']],
|
||||
'mobile' => ['padding' => ['top' => '14px', 'bottom' => '27px']],
|
||||
], 'start'),
|
||||
|
||||
// 2 – Die Leiste mit den drei Leistungen.
|
||||
$section('leiste', [
|
||||
$zeile('leiste-zeile', [
|
||||
$column('leiste-satz', [
|
||||
$text('leiste-satz-text', 'Ihr Eigentum in guten Händen.', ['desktop' => ['fontSize' => '13px', 'color' => '@meta', 'margin' => ['bottom' => '0px']]]),
|
||||
], ['desktop' => ['flexGrow' => 1.5, 'flexBasis' => '0']], 'hide-mobile'),
|
||||
...array_map(
|
||||
static fn (array $l, int $i): array => $column('leiste-'.($i + 1), [
|
||||
$text('leiste-'.($i + 1).'-link', '<a href="#'.$l[1].'">'.$l[0].' <em>0'.($i + 1).'</em></a>', [
|
||||
'desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '13px'],
|
||||
], "& a{display:flex;justify-content:space-between;align-items:center;gap:10px;font-weight:600;text-decoration:none;transition:color .2s}\n& a:hover{color:#0076b6}\n& em{font-style:normal;font-size:11px;font-weight:400;color:#66747b}"),
|
||||
], [
|
||||
'desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'borderWidth' => $nurLinks('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '4px', 'right' => '27px', 'bottom' => '4px', 'left' => '27px']],
|
||||
'tablet' => ['padding' => ['top' => '4px', 'right' => '17px', 'bottom' => '4px', 'left' => '17px']],
|
||||
'mobile' => ['padding' => ['top' => '5px', 'right' => '12px', 'bottom' => '5px', 'left' => '12px']],
|
||||
]),
|
||||
[['Verkaufen', 'verkauf'], ['Vermieten', 'vermietung'], ['Verwalten', 'verwaltung']], [0, 1, 2],
|
||||
),
|
||||
], '0px', ['alignItems' => 'stretch']),
|
||||
], [
|
||||
'desktop' => ['borderWidth' => $obenUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '21px', 'bottom' => '21px']],
|
||||
'mobile' => ['padding' => ['top' => '16px', 'bottom' => '16px']],
|
||||
]),
|
||||
|
||||
// 3 – Leistungen: Kopfzeile, darunter drei Karten mit Aufklapper.
|
||||
$abschnitt('leistungen-abschnitt', [
|
||||
$zeile('leistungen-kopf', [
|
||||
$column('leistungen-kopf-titel', [
|
||||
$kleinzeile('leistungen-label', 'WAS WIR FÜR SIE TUN'),
|
||||
$heading('leistungen-h2', 'Drei Leistungen.<br>Ein verlässlicher Partner.', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
$column('leistungen-kopf-text', [
|
||||
$text('leistungen-intro', 'Ob neue Pläne oder bestehendes Eigentum: Wir kümmern uns um Ihre Immobilie, damit Sie sich auf das Wesentliche konzentrieren können.', [
|
||||
'desktop' => ['maxWidth' => '360px', 'fontSize' => '16px', 'lineHeight' => 1.85, 'color' => '@text', 'margin' => ['bottom' => '4px']],
|
||||
'tablet' => ['maxWidth' => '280px'],
|
||||
'mobile' => ['maxWidth' => '100%', 'margin' => ['top' => '23px', 'bottom' => '0px']],
|
||||
]),
|
||||
]),
|
||||
], '45px', ['alignItems' => 'flex-end', 'justifyContent' => 'space-between', 'margin' => ['top' => '0px', 'bottom' => '44px']], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '0px', 'margin' => ['top' => '0px', 'bottom' => '30px']], ['gap' => '28px']),
|
||||
$row('leistungen-raster', array_map(
|
||||
static function (array $l, int $i) use ($column, $zeile, $text, $symbol, $heading, $h3Stil, $linkCss, $nurOben): array {
|
||||
$n = $i + 1;
|
||||
|
||||
return $column('leistung-'.$n, [
|
||||
$zeile('leistung-'.$n.'-kopf', [
|
||||
$text('leistung-'.$n.'-nummer', $l[0], ['desktop' => ['fontSize' => '11px', 'fontWeight' => '700', 'letterSpacing' => '0.11em', 'margin' => ['bottom' => '0px']]]),
|
||||
$symbol('leistung-'.$n.'-symbol', $l[2], 40, ['desktop' => ['color' => '@anthrazit', 'flexShrink' => 0], 'mobile' => ['width' => '34px', 'height' => '34px']]),
|
||||
], '12px', ['justifyContent' => 'space-between', 'margin' => ['top' => '0px', 'bottom' => '34px']], ['margin' => ['top' => '0px', 'bottom' => '24px']]),
|
||||
$heading('leistung-'.$n.'-titel', $l[3], 3, $h3Stil),
|
||||
$text('leistung-'.$n.'-text', $l[4], [
|
||||
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.85, 'color' => '@text', 'minHeight' => '155px', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['minHeight' => '0px'],
|
||||
'mobile' => ['minHeight' => '0px'],
|
||||
]),
|
||||
['id' => 'leistung-'.$n.'-mehr', 'type' => 'accordion', 'props' => ['faq' => false],
|
||||
'style' => ['desktop' => ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#dce7ed', 'margin' => ['top' => '22px']]],
|
||||
'children' => [
|
||||
['id' => 'leistung-'.$n.'-punkt', 'type' => 'accordionitem', 'props' => ['title' => $l[5], 'open' => false],
|
||||
'css' => "& summary{border:0;border-radius:0;background:transparent;padding:17px 0;font-size:14px;font-weight:600}\n& .accordion-body{padding:0 0 12px}",
|
||||
'children' => [
|
||||
$text('leistung-'.$n.'-details', $l[6], ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], "& li{margin:12px 0}\n& ol{padding-left:18px}\n& ul{padding-left:18px}\n& strong{color:#343a40}\n".$linkCss),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurOben('2px'), 'borderStyle' => 'solid', 'borderColor' => '#dce7ed', 'padding' => ['top' => '32px', 'right' => '29px', 'bottom' => '18px', 'left' => '29px']],
|
||||
'tablet' => ['padding' => ['top' => '26px', 'right' => '26px', 'bottom' => '12px', 'left' => '26px']],
|
||||
'mobile' => ['padding' => ['top' => '28px', 'right' => '25px', 'bottom' => '12px', 'left' => '25px']],
|
||||
], 'all', $l[1], "transition:border-color .3s\n&:hover{border-color:#0081c7}");
|
||||
},
|
||||
$leistungen, array_keys($leistungen),
|
||||
), ['desktop' => ['columns' => 'three'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '24px', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '12px'],
|
||||
'mobile' => ['gap' => '17px'],
|
||||
]),
|
||||
], [], 'leistungen'),
|
||||
|
||||
// 4 – Bewertung: blaue Fläche, rechts die weiße Karte mit der Bewertungsanfrage.
|
||||
$section('bewertung-abschnitt', [
|
||||
$row('bewertung-zeile', [
|
||||
$column('bewertung-text', [
|
||||
$kleinzeile('bewertung-label', 'EINE GUTE ENTSCHEIDUNG BEGINNT MIT KLARHEIT', '@papier'),
|
||||
$heading('bewertung-h2', 'Was steckt in<br>Ihrer Immobilie?', 2, [
|
||||
'desktop' => ['fontWeight' => '400', 'fontSize' => '53px', 'lineHeight' => 1.16, 'letterSpacing' => '-0.045em', 'color' => '@papier', 'margin' => ['bottom' => '26px']],
|
||||
'tablet' => ['fontSize' => '42px'],
|
||||
'mobile' => ['fontSize' => '40px'],
|
||||
]),
|
||||
$text('bewertung-intro', 'Lage, Zustand und Ausstattung machen den Unterschied. Geben Sie uns die Eckdaten – wir helfen Ihnen, den Wert Ihrer Immobilie einzuschätzen.', [
|
||||
'desktop' => ['maxWidth' => '425px', 'color' => '@papier', 'fontSize' => '16px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
$zeile('bewertung-punkte', [
|
||||
$text('bewertung-punkt-1', '✓ Kostenlose Ersteinschätzung', ['desktop' => ['fontSize' => '12px', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('bewertung-punkt-2', '✓ Persönliche Beratung', ['desktop' => ['fontSize' => '12px', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
|
||||
], '24px', ['flexWrap' => 'wrap', 'margin' => ['top' => '28px']], ['rowGap' => '8px', 'columnGap' => '20px']),
|
||||
]),
|
||||
$column('bewertung-karte', [
|
||||
$text('bewertung-karte-label', 'IHRE BEWERTUNGSANFRAGE IN DREI SCHRITTEN', ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.12em', 'color' => '@blau-dunkel', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '12px']]),
|
||||
$heading('bewertung-karte-titel', 'Um welche Immobilie geht es?', 3, [
|
||||
'desktop' => ['fontWeight' => '400', 'fontSize' => '22px', 'letterSpacing' => '-0.02em', 'lineHeight' => 1.3, 'margin' => ['top' => '18px', 'bottom' => '24px']],
|
||||
]),
|
||||
['id' => 'bewertung-formular', 'type' => 'form', 'props' => ['form' => 'bewertung'], 'css' => $formularCss],
|
||||
$text('bewertung-karte-hinweis', 'Kostenlos und unverbindlich. Vor dem Absenden sehen Sie alle Angaben noch einmal – mehr zum Umgang damit in den <a href="/datenschutz">Datenschutzhinweisen</a>.', [
|
||||
'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '20px', 'bottom' => '0px']],
|
||||
], $linkCss),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'color' => '@anthrazit', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => $seiten('36px')],
|
||||
'tablet' => ['padding' => ['top' => '25px', 'right' => '20px', 'bottom' => '25px', 'left' => '20px']],
|
||||
'mobile' => ['padding' => ['top' => '27px', 'right' => '22px', 'bottom' => '27px', 'left' => '22px']],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '100px', 'alignItems' => 'center', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '30px'],
|
||||
'mobile' => ['gap' => '25px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@blau-tief', 'color' => '@papier', 'padding' => ['top' => '76px', 'bottom' => '76px']],
|
||||
'mobile' => ['padding' => ['top' => '55px', 'bottom' => '55px']],
|
||||
], 'bewertung'),
|
||||
|
||||
// 5 – Über uns: Foto mit Jahreszahl links, Text rechts.
|
||||
$abschnitt('ueber-uns-abschnitt', [
|
||||
$row('ueber-uns-zeile', [
|
||||
$column('ueber-uns-visual', [
|
||||
$bild('ueber-uns-bild', $medien['detail'], 'Wohngebäude mit charaktervoller Architektur', [
|
||||
'desktop' => ['imageHeight' => '500px', 'objectFit' => 'cover', 'objectPosition' => 'center', 'margin' => $seiten('0px')],
|
||||
'tablet' => ['imageHeight' => '480px'],
|
||||
'mobile' => ['imageHeight' => '390px'],
|
||||
], null, '& img{display:block;width:100%}'),
|
||||
$column('ueber-uns-seit', [
|
||||
$text('ueber-uns-seit-oben', 'SEIT', ['desktop' => ['fontSize' => '10px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]]),
|
||||
$text('ueber-uns-seit-jahr', '1994', ['desktop' => ['fontSize' => '61px', 'fontWeight' => '400', 'letterSpacing' => '-0.06em', 'lineHeight' => 1.3, 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '48px']]),
|
||||
$text('ueber-uns-seit-ort', 'IN FREUDENSTADT', ['desktop' => ['fontSize' => '10px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['position' => 'absolute', 'right' => '-24px', 'bottom' => '-23px', 'zIndex' => 1, 'backgroundColor' => '@blau-tief', 'color' => '@papier', 'display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'center', 'padding' => ['top' => '29px', 'right' => '37px', 'bottom' => '29px', 'left' => '37px']],
|
||||
'tablet' => ['right' => '-12px', 'padding' => ['top' => '20px', 'right' => '27px', 'bottom' => '20px', 'left' => '27px']],
|
||||
'mobile' => ['right' => '-13px', 'bottom' => '-25px', 'padding' => ['top' => '20px', 'right' => '27px', 'bottom' => '20px', 'left' => '27px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['position' => 'relative', 'backgroundColor' => '#eef1f1', 'margin' => ['right' => '24px', 'bottom' => '23px']],
|
||||
'tablet' => ['margin' => ['right' => '12px', 'bottom' => '23px']],
|
||||
'mobile' => ['margin' => ['right' => '13px', 'bottom' => '25px']],
|
||||
]),
|
||||
$column('ueber-uns-text', [
|
||||
$kleinzeile('ueber-uns-label', 'IN DER REGION VERWURZELT'),
|
||||
$heading('ueber-uns-h2', 'Immobilien verstehen.<br>Menschen kennen.', 2, $h2Stil),
|
||||
$text('ueber-uns-lead', 'Hinter jeder Immobilie stehen Menschen und persönliche Pläne. Genau dort beginnt unsere Arbeit.', [
|
||||
'desktop' => ['fontSize' => '18px', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'margin' => ['bottom' => '20px']],
|
||||
]),
|
||||
$text('ueber-uns-p1', 'Seit 1994 ist DEEG in Freudenstadt für Eigentümer von Wohn- und Gewerbeimmobilien da. Von der einzelnen Wohnung bis zur Eigentümergemeinschaft: Sie erhalten die gleiche Aufmerksamkeit und einen Ansprechpartner, der sich kümmert.', $fliess),
|
||||
$text('ueber-uns-p2', 'Unser Ziel ist, den Wert Ihrer Immobilie zu erhalten und weiterzuentwickeln. Dabei zählen für uns nachvollziehbare Entscheidungen, ein gutes Miteinander und eine Zusammenarbeit auf Augenhöhe.', $fliess),
|
||||
$text('ueber-uns-link', '<a href="#kontakt">Lernen wir uns kennen</a>', ['desktop' => ['margin' => ['top' => '10px', 'bottom' => '0px']]], $textLinkCss),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '95px', 'alignItems' => 'center', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '40px'],
|
||||
'mobile' => ['gap' => '60px'],
|
||||
]),
|
||||
], [], 'ueber-uns'),
|
||||
|
||||
// 6 – Angebote: ein helles Band mit Aufruf zu den aktuellen Angeboten.
|
||||
$section('angebote-abschnitt', [
|
||||
$column('angebote-band', [
|
||||
$zeile('angebote-zeile', [
|
||||
$column('angebote-text', [
|
||||
$kleinzeile('angebote-label', 'IMMOBILIENANGEBOTE', '@anthrazit', '20px'),
|
||||
$heading('angebote-h2', 'Vielleicht beginnt hier<br>Ihr nächstes Kapitel.', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'fontSize' => '40px'], 'tablet' => ['fontSize' => '34px'], 'mobile' => ['fontSize' => '32px']]),
|
||||
$text('angebote-intro', 'Entdecken Sie unsere aktuellen Immobilienangebote.<br>Wir helfen Ihnen gerne bei der Suche.', [
|
||||
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '15px'],
|
||||
]),
|
||||
]),
|
||||
$column('angebote-aktion', [
|
||||
// Die Angebotsliste liegt weiter auf der laufenden Website; ein Textlink im Zuschnitt
|
||||
// des hellen Buttons der Vorlage.
|
||||
$text('angebote-knopf', '<a href="'.$angebote.'">Aktuelle Angebote ansehen</a>', ['desktop' => ['margin' => ['bottom' => '0px']], 'mobile' => ['width' => '100%']],
|
||||
"& a{display:inline-flex;justify-content:center;align-items:center;width:100%;border:1px solid #006aa3;color:#006aa3;padding:18px 24px;font-size:14px;font-weight:600;line-height:1.4;text-decoration:none;transition:background .2s,color .2s,transform .2s}\n& a:hover{background:#0076b6;color:#fff;border-color:#0076b6;transform:translateY(-2px)}"),
|
||||
], ['desktop' => ['flexShrink' => 0], 'mobile' => ['width' => '100%']]),
|
||||
], '40px', ['justifyContent' => 'space-between'], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '28px'], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '26px']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@hell', 'padding' => ['top' => '55px', 'right' => '60px', 'bottom' => '55px', 'left' => '60px']],
|
||||
'tablet' => ['padding' => $seiten('40px')],
|
||||
'mobile' => ['padding' => ['top' => '32px', 'right' => '25px', 'bottom' => '32px', 'left' => '25px']],
|
||||
]),
|
||||
], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '0px']]], 'angebote'),
|
||||
|
||||
// 7 – Kontakt: Gespräch links mit Adresse und Bürozeiten, rechts die Formularkarte.
|
||||
$abschnitt('kontakt-abschnitt', [
|
||||
$row('kontakt-zeile', [
|
||||
$column('kontakt-text', [
|
||||
$kleinzeile('kontakt-label', 'WIR FREUEN UNS AUF SIE'),
|
||||
$heading('kontakt-h2', 'Ihre Immobilie.<br>Unser nächstes Gespräch.', 2, $h2Stil),
|
||||
$text('kontakt-intro', 'Verkaufen, vermieten oder verwalten?<br>Erzählen Sie uns, was Sie vorhaben.', [
|
||||
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
$text('kontakt-telefon', '<a href="'.$telHref.'">'.$telefon.'</a>', [
|
||||
'desktop' => ['fontSize' => '43px', 'letterSpacing' => '-0.04em', 'lineHeight' => 1.4, 'margin' => ['top' => '26px', 'bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '36px'],
|
||||
'mobile' => ['fontSize' => '36px'],
|
||||
], "& a{text-decoration:none}\n& a:hover{color:#006aa3}"),
|
||||
$text('kontakt-mail', '<a href="mailto:'.$mail.'">'.$mail.'</a>', [
|
||||
'desktop' => ['fontSize' => '17px', 'color' => '#61717a', 'margin' => ['top' => '6px', 'bottom' => '0px']],
|
||||
], "& a{overflow-wrap:anywhere;text-decoration:none}\n& a:hover{color:#006aa3;text-decoration:underline}"),
|
||||
$zeile('kontakt-info', [
|
||||
$column('kontakt-adresse', [
|
||||
$text('kontakt-adresse-label', 'BESUCHEN SIE UNS', ['desktop' => [...$kleinStil['desktop'], 'margin' => ['bottom' => '12px']]]),
|
||||
$text('kontakt-adresse-text', $firma.'<br>Ringstraße 30<br>72250 Freudenstadt', ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.85, 'margin' => ['bottom' => '14px']]]),
|
||||
$text('kontakt-route', '<a href="'.$route.'">Route planen</a>', ['desktop' => ['margin' => ['bottom' => '0px']]], $textLinkCss),
|
||||
], ['desktop' => ['flexBasis' => '0', 'flexGrow' => 1, 'minWidth' => '220px']]),
|
||||
$column('kontakt-zeiten', [
|
||||
$text('kontakt-zeiten-label', 'UNSERE BÜROZEITEN', ['desktop' => [...$kleinStil['desktop'], 'margin' => ['bottom' => '12px']]]),
|
||||
$text('kontakt-zeiten-1', 'Montag – Freitag<br><strong>08:00 – 12:00</strong>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '12px']]], '& strong{font-weight:600;color:#343a40}'),
|
||||
$text('kontakt-zeiten-2', 'Montag, Dienstag & Donnerstag<br><strong>14:00 – 17:00</strong>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']]], '& strong{font-weight:600;color:#343a40}'),
|
||||
], ['desktop' => ['flexBasis' => '0', 'flexGrow' => 1, 'minWidth' => '220px']]),
|
||||
], '40px', ['alignItems' => 'flex-start', 'flexWrap' => 'wrap', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '30px'], 'margin' => ['top' => '40px']], ['gap' => '28px', 'margin' => ['top' => '30px']]),
|
||||
]),
|
||||
$column('kontakt-karte', [
|
||||
$text('kontakt-karte-label', 'SCHREIBEN SIE UNS', ['desktop' => ['fontSize' => '11px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'color' => '@meta', 'margin' => ['bottom' => '18px']]]),
|
||||
$heading('kontakt-karte-titel', 'Was können wir<br>für Sie tun?', 3, [
|
||||
'desktop' => ['fontWeight' => '400', 'fontSize' => '28px', 'lineHeight' => 1.25, 'letterSpacing' => '-0.03em', 'margin' => ['bottom' => '25px']],
|
||||
]),
|
||||
['id' => 'kontakt-formular', 'type' => 'form', 'props' => ['form' => 'kontakt'], 'css' => $formularCss],
|
||||
$text('kontakt-formular-hinweis', 'Ihre Nachricht geht direkt an DEEG. Wir verwenden Ihre Angaben ausschließlich, um Ihre Anfrage zu beantworten – mehr dazu in den <a href="/datenschutz">Datenschutzhinweisen</a>.', [
|
||||
'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '19px', 'bottom' => '0px']],
|
||||
], $linkCss),
|
||||
], [
|
||||
'desktop' => ['alignSelf' => 'flex-start', 'backgroundColor' => '@papier', 'borderWidth' => ['top' => '3px', 'right' => '1px', 'bottom' => '1px', 'left' => '1px'], 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => ['top' => '32px', 'right' => '35px', 'bottom' => '32px', 'left' => '35px'], 'shadowY' => '10px', 'shadowBlur' => '40px', 'shadowColor' => '#343a4012'],
|
||||
'tablet' => ['padding' => ['top' => '28px', 'right' => '25px', 'bottom' => '28px', 'left' => '25px']],
|
||||
'mobile' => ['padding' => ['top' => '28px', 'right' => '22px', 'bottom' => '28px', 'left' => '22px']],
|
||||
], 'all', null, 'border-top-color:#0081c7'),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '100px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '35px'],
|
||||
'mobile' => ['gap' => '35px'],
|
||||
]),
|
||||
], [], 'kontakt'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$erstellt = [];
|
||||
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db, &$erstellt): string {
|
||||
$id = $pages->createPage($slug, null, 'page', $dokument);
|
||||
if ('' !== $seoTitel) {
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
|
||||
}
|
||||
if ('' !== $teilbild) {
|
||||
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
|
||||
}
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
$erstellt[$slug] = $id;
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Der Hero spielt beim Laden, alles Weitere beim Hereinscrollen – wie die Vorlage, die die
|
||||
// Abschnittsköpfe, Bewertung, Über-uns-Text und Kontaktkarte einblendet.
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-label' => ['fade'],
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-intro' => ['rise', 'medium'],
|
||||
'hero-aktionen' => ['rise', 'long'],
|
||||
'hero-notiz' => ['fade', 'long'],
|
||||
// Kein Hereinschieben von rechts: das ragt für einen Moment über den Rand hinaus.
|
||||
'hero-visual' => ['fade', 'short'],
|
||||
'leistungen-kopf' => ['rise'],
|
||||
'leistung-1' => ['rise'],
|
||||
'leistung-2' => ['rise', 'short'],
|
||||
'leistung-3' => ['rise', 'medium'],
|
||||
'bewertung-text' => ['rise'],
|
||||
'bewertung-karte' => ['rise', 'short'],
|
||||
'ueber-uns-visual' => ['fade'],
|
||||
'ueber-uns-text' => ['rise', 'short'],
|
||||
'angebote-band' => ['rise'],
|
||||
'kontakt-text' => ['rise'],
|
||||
'kontakt-karte' => ['rise', 'short'],
|
||||
]);
|
||||
|
||||
$anlegen('home', $startseite, 'DEEG · Immobilien & Hausverwaltung in Freudenstadt', $medien['hero']['id']);
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben der Deeg Hausverwaltung und Immobilien GmbH in Freudenstadt.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
|
||||
['Anbieterin', $firma.'<br>Ringstraße 30<br>72250 Freudenstadt'],
|
||||
['Vertreten durch', 'Furkan Kurt, Geschäftsführer'],
|
||||
['Registereintrag', 'Handelsregister: HRB 430747<br>Registergericht: Amtsgericht Stuttgart'],
|
||||
['Kontakt', "Telefon: <a href=\"$telHref\">$telefon</a><br>E-Mail: <a href=\"mailto:$mail\">$mail</a>"],
|
||||
['Umsatzsteuer', 'NOCH ZU PRÜFEN: Umsatzsteuer-Identifikationsnummer nach § 27a UStG eintragen.'],
|
||||
['Aufsichtsbehörde', 'NOCH ZU PRÜFEN: Die Vermittlung und Verwaltung von Immobilien braucht eine Erlaubnis nach § 34c GewO. Erlaubnisbehörde und Aufsicht (in der Regel die Stadt Freudenstadt oder das Landratsamt Freudenstadt) hier eintragen.'],
|
||||
['Verantwortlich für den Inhalt', 'Furkan Kurt, Anschrift wie oben.'],
|
||||
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
|
||||
['Haftung für Inhalte', 'Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Wir sind jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Bei Bekanntwerden von Rechtsverletzungen entfernen wir diese Inhalte umgehend.'],
|
||||
['Haftung für Links', 'Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Für diese Inhalte ist stets die jeweilige Anbieterin oder der jeweilige Anbieter verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Bei Bekanntwerden von Rechtsverletzungen entfernen wir solche Links umgehend.'],
|
||||
['Urheberrecht', 'Die von uns erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.'],
|
||||
['Bildnachweis', 'Titelbild: Unsplash (Unsplash-Lizenz), illustrative Architekturaufnahme, kein Objekt aus unserem Angebot. Zweites Gebäudefoto: aus der bisherigen Website übernommen – NOCH ZU PRÜFEN: Urheber und Lizenz eintragen. Logo: '.$firma.'.'],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | '.$firma);
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Wie die Deeg Hausverwaltung und Immobilien GmbH Daten beim Besuch der Website und bei einer Anfrage verarbeitet.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Anfrage verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortliche', "$firma, Ringstraße 30, 72250 Freudenstadt, vertreten durch Furkan Kurt. Telefon <a href=\"$telHref\">$telefon</a>, E-Mail <a href=\"mailto:$mail\">$mail</a>.<br><br>NOCH ZU PRÜFEN: Ob eine Datenschutzbeauftragte oder ein Datenschutzbeauftragter zu benennen ist, hängt von der Zahl der ständig mit der Verarbeitung beschäftigten Personen ab; gegebenenfalls hier die Kontaktdaten ergänzen."],
|
||||
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.<br><br>NOCH ZU PRÜFEN: Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen, Verarbeitungsländer und die konkrete Speicherdauer der Serverprotokolle eintragen.'],
|
||||
['3. Cookies, Schriften, externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog. Der Link „Route planen“ führt zu Google Maps; erst mit dem Klick verlassen Sie unsere Website, und es gelten die Datenschutzbestimmungen von Google. Unsere aktuellen Immobilienangebote sind auf unserer bisherigen Website unter www.immobiliendeeg.de verlinkt.'],
|
||||
['4. Kontaktanfrage und Bewertungsanfrage', 'Wenn Sie uns über eines der Formulare schreiben, verarbeiten wir die von Ihnen angegebenen Kontakt- und Inhaltsdaten – bei der Bewertungsanfrage auch die Angaben zur Immobilie –, um Ihre Anfrage zu beantworten und Ihnen ein Angebot zu unterbreiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Vertrags), sonst Art. 6 Abs. 1 lit. f DSGVO. Die Formulare speichern den Zeitpunkt Ihrer Einwilligung und schützen sich gegen automatisierte Eingaben, ohne dafür Dienste Dritter zu laden.'],
|
||||
['5. Wie lange wir speichern', 'Anfragedaten löschen wir, sobald sie für die Beantwortung nicht mehr nötig sind und keine gesetzliche Aufbewahrungspflicht entgegensteht. Entsteht aus Ihrer Anfrage ein Auftrag, gelten für die dann anfallenden Unterlagen die handels- und steuerrechtlichen Fristen.'],
|
||||
['6. Empfänger', 'Ihre Angaben erreichen uns per E-Mail und liegen in der Verwaltung dieser Website. Darüber hinaus geben wir sie nicht weiter, außer wir sind dazu gesetzlich verpflichtet oder Sie wünschen es, etwa bei der Weitergabe eines Angebots an Interessenten.<br><br>NOCH ZU PRÜFEN: E-Mail-Anbieter und Hostingdienst als Auftragsverarbeiter benennen.'],
|
||||
['7. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen. Eine erteilte Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen.'],
|
||||
['8. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
|
||||
['', "Fragen zum Datenschutz beantworten wir gern unter <a href=\"mailto:$mail\">$mail</a>."],
|
||||
]),
|
||||
],
|
||||
], 'Datenschutz | '.$firma);
|
||||
|
||||
$fehlerId = $anlegen('404', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
|
||||
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
|
||||
'blocks' => [
|
||||
$abschnitt('fehler', [
|
||||
[...$kleinzeile('fehler-label', 'FEHLER 404'), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
$heading('fehler-titel', 'Diese Seite<br><em>haben wir nicht.</em>', 1, [
|
||||
'desktop' => ['fontWeight' => '400', 'fontSize' => '56px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.05em', 'margin' => ['bottom' => '25px']],
|
||||
'mobile' => ['fontSize' => '38px'],
|
||||
], '& em{color:#0081c7;font-style:normal}'),
|
||||
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
|
||||
'desktop' => ['maxWidth' => '465px', 'color' => '@text', 'fontSize' => '17px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], ['desktop' => ['padding' => ['top' => '120px', 'bottom' => '140px']]]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Menü, Einstellungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$sprung = static fn (string $label, string $anchor): array => ['label' => $label, 'type' => 'page', 'pageId' => $erstellt['home'], 'anchor' => $anchor, 'children' => []];
|
||||
|
||||
$menus->save('header', 'Hauptnavigation', [
|
||||
$sprung('Leistungen', 'leistungen'),
|
||||
$sprung('Über uns', 'ueber-uns'),
|
||||
$sprung('Immobilien', 'angebote'),
|
||||
$sprung('Kontakt', 'kontakt'),
|
||||
$sprung('Immobilie bewerten', 'bewertung'),
|
||||
], 0);
|
||||
echo "Menü angelegt\n";
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => $firma,
|
||||
'description' => 'Immobilien verkaufen, vermieten und verwalten in Freudenstadt. DEEG begleitet Eigentümer von Wohn- und Gewerbeimmobilien persönlich – seit 1994.',
|
||||
'phone' => $telefon, 'email' => $mail,
|
||||
'street' => 'Ringstraße 30', 'postalCode' => '72250', 'city' => 'Freudenstadt', 'country' => 'DE',
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
'stickyHeader' => true, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'faviconMediaId' => $medien['logo']['id'],
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
'consentBanner' => false, 'scrollTop' => false, 'postMetaHeader' => true,
|
||||
]);
|
||||
echo "Einstellungen gespeichert\n";
|
||||
|
||||
$passwort = bin2hex(random_bytes(12));
|
||||
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
|
||||
|
||||
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Inhalte der Website „Ihr Malerservice Drews“, am 15.09.2026 von maler-drews.com übernommen.
|
||||
* Erzeugt aus dem HTML der laufenden Website; Wortlaut unverändert, nur Auszeichnung bereinigt.
|
||||
* Wird von seed-maler-drews.php eingelesen.
|
||||
*/
|
||||
|
||||
return [
|
||||
'impressum' => '<p>Axel Drews<br>Ihr Malerservice Drews<br>Am Bachgarten 1<br>76593 Gernsbach</p> <p>Telefon: +49 7224 40821<br>Telefax: +49 7224 990503<br>E-Mail: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a><br></p> <p><strong>Umsatzsteuer-ID:</strong><br>Umsatzsteuer-Identifikationsnummer nach §27a Umsatzsteuergesetz: DE205838338</p> <p><strong>Hinweis gemäß Online-Streitbeilegungs-Verordnung</strong></p> <p>Nach geltendem Recht sind wir verpflichtet, Verbraucher auf die Existenz der Europäischen Online-Streitbeilegungs-Plattform hinzuweisen, die für die Beilegung von Streitigkeiten genutzt werden kann, ohne dass ein Gericht eingeschaltet werden muss. Für die Einrichtung der Plattform ist die Europäische Kommission zuständig. Die Europäische Online-Streitbeilegungs-Plattform ist hier zu finden: <a href="http://ec.europa.eu/odr">http://ec.europa.eu/odr</a>. Unsere E-Mail lautet: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a></p> <p>Wir weisen aber darauf hin, dass wir nicht bereit sind, uns am Streitbeilegungsverfahren im Rahmen der Europäischen Online-Streitbeilegungs-Plattform zu beteiligen. Nutzen Sie zur Kontaktaufnahme bitte unsere obige E-Mail und Telefonnummer.</p> <p><strong>Disclaimer – rechtliche Hinweise</strong></p> <p><strong>§ 1 Warnhinweis zu Inhalten</strong></p> <p>Die kostenlosen und frei zugänglichen Inhalte dieser Webseite wurden mit größtmöglicher Sorgfalt erstellt. Der Anbieter dieser Webseite übernimmt jedoch keine Gewähr für die Richtigkeit und Aktualität der bereitgestellten kostenlosen und frei zugänglichen journalistischen Ratgeber und Nachrichten. Namentlich gekennzeichnete Beiträge geben die Meinung des jeweiligen Autors und nicht immer die Meinung des Anbieters wieder. Allein durch den Aufruf der kostenlosen und frei zugänglichen Inhalte kommt keinerlei Vertragsverhältnis zwischen dem Nutzer und dem Anbieter zustande, insoweit fehlt es am Rechtsbindungswillen des Anbieters.</p> <p><strong>§ 2 Externe Links</strong></p> <p>Diese Website enthält Verknüpfungen zu Websites Dritter („externe Links“). Diese Websites unterliegen der Haftung der jeweiligen Betreiber. Der Anbieter hat bei der erstmaligen Verknüpfung der externen Links die fremden Inhalte daraufhin überprüft, ob etwaige Rechtsverstöße bestehen. Zu dem Zeitpunkt waren keine Rechtsverstöße ersichtlich. Der Anbieter hat keinerlei Einfluss auf die aktuelle und zukünftige Gestaltung und auf die Inhalte der verknüpften Seiten. Das Setzen von externen Links bedeutet nicht, dass sich der Anbieter die hinter dem Verweis oder Link liegenden Inhalte zu Eigen macht. Eine ständige Kontrolle der externen Links ist für den Anbieter ohne konkrete Hinweise auf Rechtsverstöße nicht zumutbar. Bei Kenntnis von Rechtsverstößen werden jedoch derartige externe Links unverzüglich gelöscht.<br><br><strong>§ 3 Urheber- und Leistungsschutzrechte<br></strong><br>Die auf dieser Website veröffentlichten Inhalte unterliegen dem deutschen Urheber- und Leistungsschutzrecht. Jede vom deutschen Urheber- und Leistungsschutzrecht nicht zugelassene Verwertung bedarf der vorherigen schriftlichen Zustimmung des Anbieters oder jeweiligen Rechteinhabers. Dies gilt insbesondere für Vervielfältigung, Bearbeitung, Übersetzung, Einspeicherung, Verarbeitung bzw. Wiedergabe von Inhalten in Datenbanken oder anderen elektronischen Medien und Systemen. Inhalte und Rechte Dritter sind dabei als solche gekennzeichnet. Die unerlaubte Vervielfältigung oder Weitergabe einzelner Inhalte oder kompletter Seiten ist nicht gestattet und strafbar. Lediglich die Herstellung von Kopien und Downloads für den persönlichen, privaten und nicht kommerziellen Gebrauch ist erlaubt. Die Darstellung dieser Website in fremden Frames ist nur mit schriftlicher Erlaubnis zulässig.<br><br><strong>§ 4 Besondere Nutzungsbedingungen<br></strong><br>Soweit besondere Bedingungen für einzelne Nutzungen dieser Website von den vorgenannten Paragraphen abweichen, wird an entsprechender Stelle ausdrücklich darauf hingewiesen. In diesem Falle gelten im jeweiligen Einzelfall die besonderen Nutzungsbedingungen. </p>',
|
||||
'datenschutz' => [
|
||||
'<p>Wir informieren Sie nachfolgend gemäß den gesetzlichen Vorgaben des Datenschutzrechts (insb. gemäß BDSG n.F. und der europäischen Datenschutz-Grundverordnung ‚DS-GVO‘) über die Art, den Umfang und Zweck der Verarbeitung personenbezogener Daten durch unser Unternehmen. Diese Datenschutzerklärung gilt auch für unsere Websites und Sozial-Media-Profile. Bezüglich der Definition von Begriffen wie etwa „personenbezogene Daten“ oder „Verarbeitung“ verweisen wir auf Art. 4 DS-GVO.</p> <strong>Name und Kontaktdaten des / der Verantwortlichen</strong><br> Unser/e Verantwortliche/r (nachfolgend „Verantwortlicher“) i.S.d. Art. 4 Zif. 7 DS-GVO ist:<br> <br>Ihr Malerservice Drews<br>Am Bachgarten 1<br>76593 Gernsbach<br>Geschäftsführer: Axel Drews <br>Tel: +49 7224 40821 <br>Fax: +49 7224 990503<br>E-Mail-Adresse: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a> <br><br> <strong>Datenarten, Zwecke der Verarbeitung und Kategorien betroffener Personen</strong><br> <p>Nachfolgend informieren wir Sie über Art, Umfang und Zweck der Erhebung, Verarbeitung und Nutzung personenbezogener Daten.</p> <strong>1. Arten der Daten, die wir verarbeiten</strong><br>Nutzungsdaten (Zugriffszeiten, besuchte Websites etc.), Kontaktdaten (Telefonnummer, E-Mail, Fax etc.), Kommunikationsdaten (IP-Adresse etc.), <br> <br> <strong>2. Zwecke der Verarbeitung nach Art. 13 Abs. 1 c) DS-GVO</strong><br>Website technisch und wirtschaftlich optimieren, Leichten Zugang zur Website ermöglichen, Erfüllung gesetzlicher Aufbewahrungspflichten, Optimierung und statistische Auswertung unserer Dienste, Vermeidung von SPAM und Missbrauch, Kontaktanfragen abwickeln, <br> <br> <strong>3. Kategorien der betroffenen Personen nach Art. 13 Abs. 1 e) DS-GVO</strong><br>Besucher/Nutzer der Website, Kunden, Bewerber, <br> <br> <p>Die betroffenen Personen werden zusammenfassend als „Nutzer“ bezeichnet.</p> <br> <strong>Rechtsgrundlagen der Verarbeitung personenbezogener Daten</strong> <p>Nachfolgend Informieren wir Sie über die Rechtsgrundlagen der Verarbeitung personenbezogener Daten:</p> <ol> <li>Wenn wir Ihre Einwilligung für die Verarbeitung personenbezogenen Daten eingeholt haben, ist Art. 6 Abs. 1 S. 1 lit. a) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Erfüllung eines Vertrags oder zur Durchführung vorvertraglicher Maßnahmen erforderlich, die auf Ihre Anfrage hin erfolgen, so ist Art. 6 Abs. 1 S. 1 lit. b) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Erfüllung einer rechtlichen Verpflichtung erforderlich, der wir unterliegen (z.B. gesetzliche Aufbewahrungspflichten), so ist Art. 6 Abs. 1 S. 1 lit. c) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung erforderlich, um lebenswichtige Interessen der betroffenen Person oder einer anderen natürlichen Person zu schützen, so ist Art. 6 Abs. 1 S. 1 lit. d) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Wahrung unserer oder der berechtigten Interessen eines Dritten erforderlich und überwiegen diesbezüglich Ihre Interessen oder Grundrechte und Grundfreiheiten nicht, so ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO Rechtsgrundlage.</li> </ol> <br> <strong>Weitergabe personenbezogener Daten an Dritte und Auftragsverarbeiter</strong> <p>Ohne Ihre Einwilligung geben wir grundsätzlich keine Daten an Dritte weiter. Sollte dies doch der Fall sein, dann erfolgt die Weitergabe auf der Grundlage der zuvor genannten Rechtsgrundlagen z.B. bei der Weitergabe von Daten an Online-Paymentanbieter zur Vertragserfüllung oder aufgrund gerichtlicher Anordnung oder wegen einer gesetzlichen Verpflichtung zur Herausgabe der Daten zum Zwecke der Strafverfolgung, zur Gefahrenabwehr oder zur Durchsetzung der Rechte am geistigen Eigentum.<br> Wir setzen zudem Auftragsverarbeiter (externe Dienstleister z.B. zum Webhosting unserer Websites und Datenbanken) zur Verarbeitung Ihrer Daten ein. Wenn im Rahmen einer Vereinbarung zur Auftragsverarbeitung an die Auftragsverarbeiter Daten weitergegeben werden, erfolgt dies immer nach Art. 28 DS-GVO. Wir wählen dabei unsere Auftragsverarbeiter sorgfältig aus, kontrollieren diese regelmäßig und haben uns ein Weisungsrecht hinsichtlich der Daten einräumen lassen. Zudem müssen die Auftragsverarbeiter geeignete technische und organisatorische Maßnahmen getroffen haben und die Datenschutzvorschriften gem. BDSG n.F. und DS-GVO einhalten</p> <br> <strong>Datenübermittlung in Drittstaaten</strong> <p>Durch die Verabschiedung der europäischen Datenschutz-Grundverordnung (DS-GVO) wurde eine einheitliche Grundlage für den Datenschutz in Europa geschaffen. Ihre Daten werden daher vorwiegend durch Unternehmen verarbeitet, für die DS-GVO Anwendung findet. Sollte doch die Verarbeitung durch Dienste Dritter außerhalb der Europäischen Union oder des Europäischen Wirtschaftsraums stattfinden, so müssen diese die besonderen Voraussetzungen der Art. 44 ff. DS-GVO erfüllen. Das bedeutet, die Verarbeitung erfolgtLine truncated
|
||||
'<strong>Kontaktaufnahme per Kontaktformular / E-Mail / Fax / Post</strong> <ol> <li>Bei der Kontaktaufnahme mit uns per Kontaktformular, Fax, Post oder E-Mail werden Ihre Angaben zum Zwecke der Abwicklung der Kontaktanfrage verarbeitet.<br><br></li> <li>Rechtsgrundlage für die Verarbeitung der Daten ist bei Vorliegen einer Einwilligung von Ihnen Art. 6 Abs. 1 S. 1 lit. a) DS-GVO. Rechtsgrundlage für die Verarbeitung der Daten, die im Zuge einer Kontaktanfrage oder E-Mail, eines Briefes oder Faxes übermittelt werden, ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO. Der Verantwortliche hat ein berechtigtes Interesse an der Verarbeitung und Speicherung der Daten, um Anfragen der Nutzer beantworten zu können, zur Beweissicherung aus Haftungsgründen und um ggf. seiner gesetzlichen Aufbewahrungspflichten bei Geschäftsbriefen nachkommen zu können. Zielt der Kontakt auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 S. 1 lit. b) DS-GVO.<br><br></li> <li>Wir können Ihre Angaben und Kontaktanfrage in unserem Customer-Relationship-Management System („CRM System“) oder einem vergleichbaren System speichern.<br><br></li> <li>Die Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind. Für die personenbezogenen Daten aus der Eingabemaske des Kontaktformulars und diejenigen, die per E-Mail übersandt wurden, ist dies dann der Fall, wenn die jeweilige Konversation mit Ihnen beendet ist. Beendet ist die Konversation dann, wenn sich aus den Umständen entnehmen lässt, dass der betroffene Sachverhalt abschließend geklärt ist. Anfragen von Nutzern, die über einen Account bzw. Vertrag mit uns verfügen, speichern wir bis zum Ablauf von zwei Jahren nach Vertragsbeendigung. Im Fall von gesetzlichen Archivierungspflichten erfolgt die Löschung nach deren Ablauf: Ende handelsrechtlicher (6 Jahre) und steuerrechtlicher (10 Jahre) Aufbewahrungspflicht.<br><br></li> <li>Sie haben jederzeit die Möglichkeit, die Einwilligung nach Art. 6 Abs. 1 S. 1 lit. a) DS-GVO zur Verarbeitung der personenbezogenen Daten zu widerrufen. Nehmen Sie per E-Mail Kontakt mit uns auf, so können Sie der Speicherung der personenbezogenen Daten jederzeit widersprechen.<br><br></li> </ol><br> <strong>Kontaktaufnahme per Telefon</strong> <ol> <li>Bei der Kontaktaufnahme mit uns per Telefon wird Ihre Telefonnummer zur Bearbeitung der Kontaktanfrage und deren Abwicklung verarbeitet und temporär im RAM / Cache des Telefongerätes / Displays gespeichert bzw. angezeigt. Die Speicherung erfolgt aus Haftungs- und Sicherheitsgründen, um den Beweis des Anrufs führen zu können sowie aus wirtschaftlichen Gründen, um einen Rückruf zu ermöglichen. Im Falle von unberechtigten Werbeanrufen, sperren wir die Rufnummern.<br><br></li> <li>Rechtsgrundlage für die Verarbeitung der Telefonnummer ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO. Zielt der Kontakt auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 lit. b) DS-GVO.<br><br></li> <li>Der Gerätecache speichert die Anrufe Tage und überschreibt bzw. löscht sukzessiv alte Daten, bei Entsorgung des Gerätes werden alle Daten gelöscht und der Speicher ggf. zerstört. Gesperrte Telefonnummer werden jährlich auf die Notwendigkeit der Sperrung geprüft.<br><br></li> <li>Sie können die Anzeige der Telefonnummer verhindern, indem Sie mit unterdrückter Telefonnummer anrufen.<br><br></li> </ol><br> <strong>Google Analytics</strong> <ol> <li>Wir haben das Webseitenanalyse-Tool „Google Analytics“ (<strong>Dienstanbieter:</strong> Google Ireland Limited, Registernr.: 368047, Gordon House, Barrow Street, Dublin 4, Irland) auf unserer Website integriert.<br><br></li> <li><strong>Datenkategorien und Beschreibung der Datenverarbeitung:</strong> User-ID, IP-Adresse (anonymisiert). Beim Besuch unserer Website setzt Google einen Cookie auf Ihren Computer, um die Benutzung unserer Website durch Sie analysieren zu können. Wir haben die IP-Anonymisierung „anonymizeIP“ aktiviert, wodurch die IP-Adressen nur gekürzt weiterverarbeitet werden. Auf dieser Webseite wird Ihre IP-Adresse von Google daher innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum zuvor gekürzt. Nur in Ausnahmefällen wird die volle IP-Adresse an einen Server von Google in den USA übertragen und dort gekürzt. Im Auftrag des Betreibers dieser Webseite wird Google diese Informationen benutzen, um Ihre Nutzung der Webseite auszuwerten, um Reports über die Webseitenaktivitäten zusammenzustellen und um weitere, mit der Websitenutzung und der Internetnutzung verbundene, Dienstleistungen gegenüber dem Verantwortlichen zu erbringen. Wir haben darüber hinaus die geräteübergreifende Analyse von Website-Besuchern aktiviert, die über eine sog. User-ID durchgeführt wird. Die im Rahmen von GoogleLine truncated
|
||||
'<strong>Google Maps</strong> <ol> <li>Wir haben auf unserer Website Karten von „Google Maps“ (<strong>Anbieter</strong>: Google Ireland Limited, Registernr.: 368047, Gordon House, Barrow Street, Dublin 4, Irland) integriert.<br><br></li> <li><strong>Datenkategorie und Beschreibung der Datenverarbeitung:</strong> Nutzungsdaten (z.B. IP, Standort, aufgerufene Seite). Mit Google Maps können wir den Standort von Adressen und eine Anfahrtsbeschreibung direkt auf unserer Website in interaktiven Karten anzeigen und Ihnen die Nutzung dieses Tools ermöglichen. Bei dem Abruf unserer Website, wo Google Maps integriert ist, wird eine Verbindung zu den Servern von Google in den USA aufgebaut. Hierbei können Ihre IP und Standort an Google übertragen werden. Zudem erhält Google die Information, dass Sie die entsprechende Seite aufgerufen haben. Dies erfolgt auch ohne Nutzerkonto bei Google. Sollten Sie in Ihren Google-Account eingeloggt sein, kann Google die obigen Daten Ihrem Account zuordnen. Wenn Sie dies nicht wünschen, müssen Sie sich bei Ihrem Google-Account ausloggen. Google erstellt aus solchen Daten Nutzerprofile und nutzt diese Daten zum Zwecke der Werbung, Marktforschung oder Optimierung seiner Websites.<br><br></li> <li><strong>Zweck der Verarbeitung:</strong> Bereitstellung einer nutzerfreundlichen, wirtschaftlichen und optimierten Webseite.<br><br></li> <li><strong>Rechtsgrundlagen:</strong> Haben Sie für Verarbeitung Ihrer personenbezogenen Daten mittels „Google Maps“ vom Drittanbieter Ihre Einwilligung erteilt („Opt-in“), dann ist Art. 6 Abs. 1 S. 1 lit. a) DS-GVO die Rechtsgrundlage. Rechtsgrundlage ist zudem unser in den obigen Zwecken liegendes berechtigtes Interesse an der Datenverarbeitung nach Art. 6 Abs. 1 S.1 lit. f) DS-GVO.<br><br></li> <li><strong>Datenübermittlung/Empfängerkategorie:</strong> Drittanbieter in den USA.<br><br></li> <li><strong>Speicherdauer:</strong> Cookies bis zu 6 Monate oder bis zur Löschung durch Sie. Ansonsten sobald sie nicht mehr für die Verarbeitungszwecke benötigt werden.<br><br></li> <li><strong>Widerspruchs- und Beseitigungsmöglichkeit:</strong> Sie haben gegenüber Google ein Widerspruchsrecht gegen die Bildung von Nutzerprofilen. Bitte richten Sie sich deswegen direkt an Google über die unten genannte Datenschutzerklärung. Ein Opt-Out-Widerspruch hinsichtlich der Werbe-Cookies können Sie hier in Ihrem Google-Account vornehmen:<br><a href="https://adssettings.google.com/authenticated">https://adssettings.google.com/authenticated</a>.<br><br></li> <li>In den Nutzungsbedingungen von Google Maps unter <a href="https://www.google.com/intl/de_de/help/terms_maps.html">https://www.google.com/intl/de_de/help/terms_maps.html</a> und in der Datenschutzerklärung für Werbung von Google unter <a href="https://policies.google.com/technologies/ads">https://policies.google.com/technologies/ads</a> finden Sie weitere Informationen zur Verwendung von Google Cookies und deren Werbetechnologien, Speicherdauer, Anonymisierung, Standortdaten, Funktionsweise und Ihre Rechte. Allgemeine Datenschutzerklärung von Google: <a href="https://policies.google.com/privacy">https://policies.google.com/privacy</a>.<br><br></li> </ol><br> <strong>Datenschutz bei Bewerbungen und im Bewerberverfahren</strong> <ol> <li>Bewerbungen, die auf elektronischem Wege oder per Post an den Verantwortlichen gesendet werden, werden zum Zwecke der Abwicklung des Bewerberverfahrens elektronisch oder manuell verarbeitet.<br><br></li> <li>Wir weisen ausdrücklich darauf hin, dass Bewerbungsunterlagen mit „besonderen Kategorien personenbezogener Daten“ nach Art. 9 DS-GVO (z.B. ein Foto, welches Rückschlüsse auf Ihre ethnische Herkunft, Religion oder Ihren Familienstand gibt), mit Ausnahme einer eventuellen Schwerbehinderung, welche Sie aus freier Entscheidung offenlegen möchten, unerwünscht sind. Sie sollen Ihre Bewerbung ohne diese Daten einreichen. Dies hat keine Auswirkungen auf Ihre Bewerberchancen.<br><br></li> <li>Rechtsgrundlagen für die Verarbeitung sind Art. 6 Abs. 1 S.1 lit. b) DS-GVO sowie § 26 BDSG n.F.<br><br></li> <li>Wird nach Abschluss des Bewerberverfahrens, ein Arbeitsverhältnis mit dem Bewerber / der Bewerberin eingegangen, werden die Bewerberdaten unter Beachtung einschlägiger Datenschutzvorschriften gespeichert. Wird Ihnen nach Abschluss des Bewerberverfahrens keine Stelle angeboten, so wird Ihr eingereichtes Bewerbungsschreiben samt Unterlagen 6 Monate nach Versand der Absage gelöscht, um etwaigen Ansprüchen und Nachweispflichten nach AGG genügen zu können.<br><br></li> </ol><br> <b>Rechte der betroffenen Person</b> <ol> <li><strong>Widerspruch oder Widerruf gegen die Verarbeitung Ihrer Daten<br><br> Soweit die Verarbeitung auf Ihrer Einwilligung gemäß Art. 6 Abs. 1 S. 1 lit. a), Art. 7 DS-GVO beruht, haben Sie das Recht, die Einwilligung jederzeit zu widerrufen. Die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten VLine truncated
|
||||
],
|
||||
'beitraege' => [
|
||||
[
|
||||
'slug' => '60-jahre-ihr-malerservice-drews-ein-jubilaeum-mit-tradition-und-zukunft',
|
||||
'kind' => 'news',
|
||||
'title' => '60 Jahre Ihr Malerservice Drews – Ein Jubiläum mit Tradition und Zukunft',
|
||||
'date' => '2025-10-17 09:36',
|
||||
'excerpt' => 'In diesem Jahr feiern wir ein ganz besonderes Ereignis: 60 Jahre Ihr Malerservice Drews!Seit unserer …',
|
||||
'description' => 'In diesem Jahr feiern wir ein ganz besonderes Ereignis: 60 Jahre Ihr Malerservice Drews!Seit unserer …',
|
||||
'content' => '<p>In diesem Jahr feiern wir ein ganz besonderes Ereignis: <strong>60 Jahre Ihr Malerservice Drews</strong>!<br>Seit unserer Gründung im Jahr 1965 steht unser Familienbetrieb für Qualität, Zuverlässigkeit und Kreativität rund um alle Maler- und Lackierarbeiten. Was einst im Loffenauer Milchhäusle begann, hat sich über sechs Jahrzehnte zu einem modernen Handwerksunternehmen mit einem starken Team und zufriedenen Kundinnen und Kunden in der gesamten Region entwickelt.</p> <p>Wir freuen uns sehr, dass unser Jubiläum auch in der regionalen Presse aufgegriffen wurde. Der Artikel gibt spannende Einblicke in unsere Geschichte, unsere Philosophie und die Leidenschaft, mit der wir täglich an die Arbeit gehen.</p> <p><strong>Ein herzliches Dankeschön</strong> gilt all unseren Kundinnen, Kunden, Geschäftspartnern und unserem großartigen Team – ohne Sie wäre dieser Erfolg nicht möglich gewesen.<br>Mit Stolz blicken wir auf 60 erfolgreiche Jahre zurück und mit Tatkraft und Freude in die Zukunft!</p>',
|
||||
'images' => ['bnn.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-2',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2020-10-19 14:44',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['img-0008-scaled.jpg', 'img-4658.jpg', 'img-0007-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-3',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2021-12-14 12:12',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2021-12-14-13-02-09.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-44',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade 44',
|
||||
'date' => '2020-08-03 12:29',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-31-21-2-1.jpg', 'photo-2020-08-03-11-31-21-2.jpg', 'photo-2020-08-03-11-31-21-3.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-fachwerkhaus',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Aussenfassade Fachwerkhaus',
|
||||
'date' => '2020-08-03 12:33',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-31-21.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade Sanierung',
|
||||
'date' => '2021-04-08 06:28',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['gse-buetthof-bilder.jpg', 'img-0103-scaled.jpg', 'img-0104-scaled.jpg', 'img-0118-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2020-09-14 06:18',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-12-09-50-01-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'bad',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Bad',
|
||||
'date' => '2020-10-19 14:42',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['a934b725-d7dc-4d23-a967-a8ceab1a7268.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'betriebsferien-vom-11-08-bis-22-08',
|
||||
'kind' => 'news',
|
||||
'title' => 'Betriebsferien vom 11.08. bis 22.08.',
|
||||
'date' => '2025-07-23 05:25',
|
||||
'excerpt' => 'Liebe Kundinnen und Kunden,bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August …',
|
||||
'description' => 'Liebe Kundinnen und Kunden,bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August …',
|
||||
'content' => '<p>Liebe Kundinnen und Kunden,<br>bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August Betriebsferien machen. In dieser Zeit ist unser Betrieb geschlossen. Ab dem 25. August sind wir wieder wie gewohnt für Sie da.<br><br>Vielen Dank für Ihr Verständnis und eine schöne Sommerzeit!<br>Ihr Malerservice Drews<br><br>Danke!</p>',
|
||||
'images' => ['betriebsferien.jpg', 'betriebsferien-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'decke-zierleisten',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Rekonstruktion Decken Zierleiste',
|
||||
'date' => '2021-02-24 06:52',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2021-02-24-06-47-34-3.jpg', 'photo-2021-02-24-06-47-34.jpg', 'photo-2021-02-24-06-47-34-1.jpg', 'photo-2021-02-24-06-47-34-2.jpg', 'photo-2021-02-24-06-48-19.jpg', 'photo-2021-02-24-06-48-191.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'dekoration',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Dekoration',
|
||||
'date' => '2020-08-03 12:20',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-29-15.jpg', 'photo-2020-08-03-11-31-21-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'exklusiv-in-mittelbaden-wir-sind-offizieller-little-greene-partner',
|
||||
'kind' => 'news',
|
||||
'title' => 'Exklusiv in Mittelbaden: Wir sind offizieller Little Greene Partner',
|
||||
'date' => '2025-04-11 17:44',
|
||||
'excerpt' => 'Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner …',
|
||||
'description' => 'Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner …',
|
||||
'content' => '<p>Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner im Raum Mittelbaden für den traditionsreichen Farben- und Tapetenhersteller Little Greene ist.<br>Little Greene steht seit jeher für hochwertige sowie umweltfreundliche Farben mit beeindruckender Farbtiefe, historische Farbkollektionen mit britischem Flair und handverlesene Tapetendesigns mit Stil und Charakter.</p> <p>Als exklusiver Partner in der Region Mittelbaden bieten wir Ihnen nicht nur Zugang zu den Produkten von Little Greene, sondern auch eine umfassende Beratung und professionelle Verarbeitung direkt vom Fachbetrieb. Ob Neubau, Altbausanierung oder Innenraumgestaltung – wir setzen Ihre Wünsche mit hochwertigen Materialien und einem Sinn für Ästhetik um.<br>Auf unserem Instagram-Account erhalten Sie regelmäßig neue Inspiration für Ihre Raumgestaltung mit Farben und Tapeten von Little Greene.</p>',
|
||||
'images' => ['photo-2025-04-11-14-47-35.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fensterladen-verzierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fensterladen Verzierung',
|
||||
'date' => '2021-12-01 14:02',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['img-5893-scaled.jpg', 'img-5869.jpg', 'img-0136-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'freilegen-einer-backsteinwand',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Freilegen einer Backsteinwand',
|
||||
'date' => '2023-02-16 07:37',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['1676472540643-scaled.jpg', '1676472540620-scaled.jpg', '1676472540632-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'friedrichsbad-baden-baden',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Glanzvoll renoviert: Malerarbeiten im historischen Friedrichsbad, Baden‑Baden',
|
||||
'date' => '2025-07-30 09:48',
|
||||
'excerpt' => 'Im ehrwürdigen Friedrichsbad in Baden‑Baden, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, …',
|
||||
'description' => 'Im ehrwürdigen Friedrichsbad in Baden‑Baden, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, …',
|
||||
'content' => '<p>Im ehrwürdigen <strong>Friedrichsbad in Baden‑Baden</strong>, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, wurden die Wände und Decken behutsam restauriert und neu gestaltet. Mit fein abgestimmten Farbtönen und hochwertigen Techniken hat Malermeister Drews die prachtvollen Majolika-Kacheln, die eleganten Stuckelemente und die beeindruckende Kuppelarchitektur gekonnt in Szene gesetzt – ohne den historischen Charakter zu verfälschen.</p> <p>Das Ergebnis: ein stimmiger Mix aus eleganter Modernisierung und historischer Ästhetik, der das Bad nicht nur technisch aufwertet, sondern auch den Besuchern eine visuelle Reise durch vergangene Epochen ermöglicht. Gerade in Bereichen wie Eingangsbereich, Kuppelsaal und Umkleiden wurde mit viel Feingefühl gearbeitet, um die subtile Balance zwischen Restaurierung und zeitgemäßem Farbkonzept zu wahren.</p> <p>Ob renovierte Wandflächen, frische Texturen oder veredelte Holz- und Metallbereiche – das Team hat dem Friedrichsbad ein innenarchitektonisches Gesicht verliehen, das Geschichte atmet und gleichzeitig einladend wirkt.</p>',
|
||||
'images' => ['kopie-von-2024-06-24-2.jpg', 'kopie-von-2024-06-24-4.jpg', 'kopie-von-2024-06-24-3.jpg', 'kopie-von-2024-06-24-5.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'frohe-weihnachten-und-einen-guten-start-ins-neue-jahr',
|
||||
'kind' => 'news',
|
||||
'title' => 'Frohe Weihnachten und einen guten Start ins neue Jahr!',
|
||||
'date' => '2025-11-23 19:33',
|
||||
'excerpt' => 'Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr. Der …',
|
||||
'description' => 'Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr. Der …',
|
||||
'content' => '<p>Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr.</p> <p>Der gesamte Malerbetrieb wünscht Ihnen und Ihren Liebsten besinnliche Feiertage, erholsame Stunden und einen farbenfrohen Start in das Jahr 2026.</p> <p>**Betriebsferien**</p> <p>Unser Betrieb bleibt vom **22.12.2025 bis einschließlich 06.01.2026** geschlossen.</p> <p>Ab dem **07.01.2026** sind wir wie gewohnt wieder für Sie erreichbar und freuen uns darauf, Ihre Projekte mit frischen Ideen und handwerklicher Qualität umzusetzen.</p> <p>Wir wünschen Ihnen eine wundervolle Weihnachtszeit! 🎄</p>',
|
||||
'images' => ['image0.jpeg', 'image0-1.jpeg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fugenloses-bad-und-boden',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fugenloses Bad und Boden',
|
||||
'date' => '2020-08-04 08:32',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-16-36-12.jpg', 'photo-2020-08-03-16-36-12-2.jpg', 'photo-2020-08-03-16-36-12-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fugenloses-bad',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fugenloses Bad',
|
||||
'date' => '2020-12-01 22:14',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['44d46f3a-255b-441d-8d80-e4810a150368-e1606860739106-1.jpg', 'img-20201130-155129-resized-20201130-035353784.jpg', 'img-20201130-155118-resized-20201130-035352490.jpg', 'img-20201130-155109-resized-20201130-035353150.jpg', '44d46f3a-255b-441d-8d80-e4810a150368-e1606860739106.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'grund-und-hauptschule',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Grund- und Hauptschule',
|
||||
'date' => '2020-08-06 08:46',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['binarydata-big-256998.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'kirche',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Kirche',
|
||||
'date' => '2020-07-20 12:56',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['malerservice-drews-112-von-116.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'modernisierung-neugestaltung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Modernisierung & Neugestaltung',
|
||||
'date' => '2021-11-29 13:55',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['img-5922-scaled.jpg', 'img-5921-scaled.jpg', 'img-5923-scaled.jpg', 'img-5924-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sanierung-fachwerkhaus',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Sanierung Fachwerkhaus',
|
||||
'date' => '2020-09-08 04:54',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-07-10-06-38-5-1.jpg', 'photo-2020-09-07-10-06-49.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sanierung-wohnzimmer-mit-kaminofen',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Sanierung Wohnzimmer mit Kaminofen',
|
||||
'date' => '2020-11-20 13:06',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-11-19-18-30-50.jpg', 'photo-2020-11-19-18-30-46-1.jpg', 'photo-2020-11-19-18-30-46.jpg', 'photo-2020-11-19-18-30-47.jpg', 'photo-2020-11-19-18-30-48-1.jpg', 'photo-2020-11-19-18-30-48.jpg', 'photo-2020-11-19-18-30-49-1.jpg', 'photo-2020-11-19-18-30-49.jpg', 'photo-2020-11-19-18-30-50-1.jpg', 'photo-2020-11-19-18-30-51-1.jpg', 'photo-2020-11-19-18-30-51.jpg', 'photo-2020-11-19-18-30-52.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'schulkindbetreuung-hilpertsau',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Unterwasserzauber – Malerarbeiten in der Schulkindbetreuung Hilpertsau',
|
||||
'date' => '2025-07-30 09:38',
|
||||
'excerpt' => 'Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll …',
|
||||
'description' => 'Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll …',
|
||||
'content' => '<p>Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll gestalteten Wandmalereien in eine fantasievolle Unterwasserwelt verwandelt. Seepferdchen, bunte Fische und wellenförmige Elemente erwecken die Wände und Decken zum Leben und laden Kinder zum Staunen und Entdecken ein.</p> <p>Die kreative Gestaltung stammt vom renommierten Malermeisterbetrieb Drews aus Gernsbach – sie schafft nicht nur optische Freude, sondern fördert auch die kindliche Vorstellungskraft und Spielmotivationen</p>',
|
||||
'images' => ['kopie-von-2024-04-19-4.jpg', 'kopie-von-2024-04-19-3.jpg', 'kopie-von-2024-04-19-5.jpg', 'kopie-von-2024-04-19-6.jpg', 'kopie-von-img-1398.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sommerfest-2024',
|
||||
'kind' => 'news',
|
||||
'title' => 'Sommerfest 2024',
|
||||
'date' => '2024-09-28 15:37',
|
||||
'excerpt' => 'Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.Zu Beginn …',
|
||||
'description' => 'Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.Zu Beginn …',
|
||||
'content' => '<p>Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.<br>Zu Beginn des Festes richtete unser Geschäftsführer Axel Drews einige Worte an die Anwesenden. In seiner Rede bedankte er sich bei allen für ihren unermüdlichen Einsatz und die stetige Bereitschaft, das Unternehmen mit vollem Engagement zu unterstützen. Danach folgte der Höhepunkt des Tages: Die Ehrung langjähriger Mitarbeiter.</p> <p>Drei unserer geschätzten Kollegen wurden für ihre beeindruckende Betriebszugehörigkeit geehrt und mit einer Urkunde sowie einem liebevoll zusammengestellten Präsentkorb bedacht. Eine besonders bemerkenswerte Auszeichnung erhielt Dennis Bähring, der bereits seit unglaublichen 30 Jahren Teil unseres Unternehmens ist. Dennis begann seine Laufbahn bei uns mit seiner Ausbildung und ist seitdem ein fester Bestandteil unseres Teams. In seiner langen Zeit bei uns hat er zahlreiche Projekte und Veränderungen miterlebt. Sein Engagement, seine Loyalität und sein Fachwissen sind für uns von unschätzbarem Wert.</p> <p>Andreas Vetter und Jan Kassner wurden ebenfalls für ihre langjährige Treue zum Unternehmen gewürdigt. Beide blicken auf stolze 20 Jahre Betriebszugehörigkeit zurück. In dieser Zeit haben sie durch ihre tägliche Arbeit maßgeblich zum Erfolg des Betriebs beigetragen. Sie haben sich stets als verlässliche und engagierte Mitarbeiter erwiesen, und es war uns eine Ehre, ihnen für ihre Loyalität und ihren Einsatz zu danken.</p> <p>Das Sommerfest bot den idealen Rahmen, um diese besonderen Jubiläen zu feiern und den Teamgeist noch weiter zu stärken. Bei leckerem Essen, erfrischenden Getränken und geselligen Gesprächen genossen alle Anwesenden den restlichen Abend. Es war ein gelungenes Fest, das nicht nur als Dankeschön für die Mitarbeit der vergangenen Monate diente, sondern auch als Erinnerung daran, wie wichtig das gemeinsame Miteinander in unserem Unternehmen ist.</p> <p>Noch einmal herzlichen Glückwunsch an unsere Jubilare und ein großes Dankeschön an alle Mitarbeiterinnen und Mitarbeiter für euer tägliches Engagement und eure wertvolle Arbeit! Wir freuen uns darauf, gemeinsam weiterhin erfolgreich in die Zukunft zu gehen.</p>',
|
||||
'images' => ['photo-2024-09-27-15-22-38-1.jpg', 'photo-2024-09-27-15-22-38.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'treppenhaus-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Treppenhaus Sanierung',
|
||||
'date' => '2022-02-24 13:27',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['image-50379521-scaled.jpg', 'image-50438401-scaled.jpg', 'image-50424065-scaled.jpg', 'image-50386433-scaled.jpg', 'image-50382337-scaled.jpg', 'treppenhaus-axel-2-scaled.jpg', 'treppenhaus-axel-3-scaled.jpg', 'treppenhaus-axel-4-scaled.jpg', 'treppenhaus-axel-1-scaled.jpg', 'treppenhaus-axel-5-scaled.jpg', 'treppenhaus-axel-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'verkleidung-einer-fassade-aus-nf-brettern',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Verkleidung einer Fassade aus N+F Brettern',
|
||||
'date' => '2023-12-19 06:23',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['img-0594-scaled.jpg', 'screenshot-2023-12-18-084035.jpg', 'pfarramt-fassade.png', 'img-0597-scaled.jpg', 'img-0595-scaled.jpg', 'img-0596-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'von-abgenutzt-zu-atemberaubend-die-verwandlung-einer-tuer-durch-restauration-und-malerei',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Von Abgenutzt zu Atemberaubend: Die Verwandlung einer Tür durch Restauration und Malerei!',
|
||||
'date' => '2023-08-17 09:26',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '<p><a href="https://www.facebook.com/photo/?fbid=308837308169469&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837158169484&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837301502803&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837224836144&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837141502819&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p>',
|
||||
'images' => ['1692193874729-scaled.jpg', '1692193874743-scaled.jpg', 'img-9452-scaled.jpg', '1692193874974-scaled.jpg', '1692193874955-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wand-beton-optik',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wand Beton Optik',
|
||||
'date' => '2022-03-09 07:42',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['4e4c6616-c9bb-4660-a3f5-d3617f2f9ddb-1.jpg', '41bad44a-d3b5-4859-a6ae-447dd5de5fc6.jpg', '64c6dc8d-8425-441c-8838-fe9dda248c4e.jpg', 'img-1047-scaled.jpg', 'img-1045-scaled.jpg', 'img-1046-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wappen-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wappen Sanierung',
|
||||
'date' => '2020-10-01 11:11',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-29-09-15-34-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wohnzimmer-gestaltung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wohnzimmer Gestaltung',
|
||||
'date' => '2022-02-28 15:18',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['wohnzimmergestaltung-2.jpg'],
|
||||
],
|
||||
],
|
||||
];
|
||||
File diff suppressed because it is too large.
Load diff
@@ -196,6 +196,34 @@ $symbol = static function (string $id, string $name, int $groesse, array $style
|
||||
return ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt die Einblendungen nach einer Liste, statt sie über das ganze Dokument zu streuen.
|
||||
*
|
||||
* Ein Block mit Einblendung wartet unsichtbar, bis er ins Bild scrollt; was beim Laden schon
|
||||
* sichtbar ist, spielt sofort. Ohne JavaScript steht alles einfach da – die Seite bleibt
|
||||
* vollständig, nur ohne Bewegung.
|
||||
*
|
||||
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
|
||||
*/
|
||||
$einblenden = static function (array $dokument, array $plan): array {
|
||||
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
|
||||
foreach ($bloecke as $i => $block) {
|
||||
if (isset($plan[$block['id'] ?? ''])) {
|
||||
[$art, $verzug] = [...$plan[$block['id']], 'none'];
|
||||
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$bloecke[$i]['children'] = $gehen($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $bloecke;
|
||||
};
|
||||
$dokument['blocks'] = $gehen($dokument['blocks']);
|
||||
|
||||
return $dokument;
|
||||
};
|
||||
|
||||
// Wiederkehrende Gestaltungswerte der Vorlage.
|
||||
$serif = ['fontFamily' => 'heading', 'fontWeight' => '400', 'letterSpacing' => '-0.045em', 'lineHeight' => 1.1];
|
||||
$h2Stil = ['desktop' => [...$serif, 'fontSize' => '53px', 'margin' => ['bottom' => '0px']], 'tablet' => ['fontSize' => '42px'], 'mobile' => ['fontSize' => '38px']];
|
||||
@@ -269,7 +297,8 @@ $forms->save('kontakt', 'Kontaktanfrage', [
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Für einen persönlichen Rückruf'],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true, 'help' => 'Erzählen Sie uns, was Ihnen wichtig ist.'],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true, 'help' => 'Erzählen Sie uns, was Ihnen wichtig ist. Bitte keine Angaben zu Gesundheit oder Pflegegrad – das besprechen wir lieber persönlich.'],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich habe die Datenschutzhinweise gelesen. Meine Angaben werden verwendet, um meine Anfrage zu beantworten.'],
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Nachricht über tagespflege-moldovan.com', 'success' => 'Vielen Dank. Ihre Nachricht ist angekommen – wir melden uns bei Ihnen.', 'replyToField' => 'email'], 0);
|
||||
echo "Formular angelegt: kontakt\n";
|
||||
|
||||
@@ -363,7 +392,10 @@ $fuss = [
|
||||
$text('fuss-copyright', '© 2026 Tagespflege Jacqueline Moldovan', ['desktop' => ['fontSize' => '12px', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '11px']]),
|
||||
]),
|
||||
$column('fuss-hinweis', [
|
||||
$text('fuss-hinweis-text', 'Gestaltungsentwurf · Beispieltexte & illustratives KI-Bild', ['desktop' => ['fontSize' => '12px', 'textAlign' => 'right', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '11px', 'textAlign' => 'left']]),
|
||||
// Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü.
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
|
||||
'style' => ['desktop' => ['fontSize' => '12px', 'justifyContent' => 'flex-end', 'gap' => '4px'], 'mobile' => ['justifyContent' => 'flex-start']],
|
||||
'css' => "& a:hover{text-decoration:underline}"],
|
||||
]),
|
||||
], '20px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-start', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e7ebe4', 'padding' => ['top' => '18px', 'bottom' => '18px'], 'color' => '#78817b'], ['flexDirection' => 'column', 'gap' => '7px']),
|
||||
], [
|
||||
@@ -376,6 +408,67 @@ $partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
/**
|
||||
* Eine Rechtsseite: Kleinzeile, Titel, Vorspann, dann Absätze mit Zwischenüberschriften.
|
||||
*
|
||||
* Stellen, die nur die Tagespflege selbst beantworten kann, stehen als „NOCH ZU PRÜFEN“ im Text,
|
||||
* statt erfunden zu werden – eine falsche Registerangabe ist schlimmer als eine fehlende.
|
||||
*/
|
||||
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $abschnitt, $row, $column, $kleinzeile, $heading, $text, $serif, $emGruen, $nurOben, $nurUnten): array {
|
||||
// Oben ein ruhiges Band in der Farbe des Angebotsbereichs: Kleinzeile, Titel, Vorspann.
|
||||
// Die Sektion bleibt voll breit; die Breite begrenzt erst der Inhalt darin. So steht der
|
||||
// Text auf derselben Kante wie Marke und Menü im Kopf, statt am Fensterrand zu kleben.
|
||||
$kopf = $section($praefix.'-kopf', [
|
||||
[...$kleinzeile($praefix.'-label', $label), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
[...$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => [...$serif, 'fontSize' => '59px', 'margin' => ['bottom' => '22px']],
|
||||
'tablet' => ['fontSize' => '46px'],
|
||||
'mobile' => ['fontSize' => '40px', 'margin' => ['bottom' => '16px']],
|
||||
], $emGruen), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
|
||||
[...$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '19px', 'lineHeight' => 1.7, 'color' => '@gedaempft', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '17px'],
|
||||
]), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'medium']]],
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@blass', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '84px', 'bottom' => '72px']],
|
||||
'tablet' => ['padding' => ['top' => '64px', 'bottom' => '56px']],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '40px']],
|
||||
]);
|
||||
|
||||
// Darunter jeder Punkt als eigene Zeile: Zwischentitel links, Text rechts, getrennt durch
|
||||
// eine feine Linie. Auf dem Telefon untereinander.
|
||||
$punkte = [];
|
||||
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
|
||||
$punkte[] = $row($praefix.'-punkt-'.$i, [
|
||||
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
|
||||
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
|
||||
'desktop' => ['fontFamily' => 'body', 'fontSize' => '17px', 'fontWeight' => '700', 'letterSpacing' => '-0.01em', 'lineHeight' => 1.5, 'color' => '@tinte', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['flexBasis' => '280px', 'flexShrink' => 0],
|
||||
'tablet' => ['flexBasis' => '220px'],
|
||||
'mobile' => ['flexBasis' => 'auto'],
|
||||
]),
|
||||
$column($praefix.'-punkt-'.$i.'-text', [
|
||||
$text($praefix.'-p-'.$i, $inhalt, [
|
||||
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@gedaempft', 'margin' => ['bottom' => '0px']],
|
||||
], "& a{color:#2e7595;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#245e79}"),
|
||||
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0']]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '48px', 'margin' => ['top' => '0px'],
|
||||
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '28px', 'bottom' => '28px']],
|
||||
'tablet' => ['gap' => '32px'],
|
||||
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '22px', 'bottom' => '22px']],
|
||||
]);
|
||||
}
|
||||
|
||||
$inhalt = $abschnitt($praefix, [
|
||||
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '960px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '56px', 'bottom' => '100px']], 'mobile' => ['padding' => ['top' => '28px', 'bottom' => '61px']]]);
|
||||
|
||||
return [$kopf, $inhalt];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -658,7 +751,7 @@ $startseite = [
|
||||
& .field-note{font-size:12px;color:#748078}
|
||||
& .cms-button{width:100%;justify-content:center;border-radius:4px;margin-top:4px}",
|
||||
],
|
||||
$text('kontakt-formular-hinweis', 'Ihre Nachricht geht direkt an die Tagespflege. Wir verwenden Ihre Angaben ausschließlich, um Ihre Anfrage zu beantworten.', [
|
||||
$text('kontakt-formular-hinweis', 'Ihre Nachricht geht direkt an die Tagespflege. Wir verwenden Ihre Angaben ausschließlich, um Ihre Anfrage zu beantworten – mehr dazu in den <a href="/datenschutz">Datenschutzhinweisen</a>.', [
|
||||
'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.65, 'color' => '#64736b', 'margin' => ['top' => '19px', 'bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
@@ -699,14 +792,75 @@ $anlegen = static function (string $slug, array $dokument, string $seoTitel = ''
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Der Hero spielt beim Laden, alles Weitere beim Hereinscrollen. Gestaffelt, damit eine Zeile
|
||||
// nacheinander erscheint statt auf einen Schlag.
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-label' => ['fade'],
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-intro' => ['rise', 'medium'],
|
||||
'hero-aktionen' => ['rise', 'long'],
|
||||
'hero-notiz' => ['fade', 'long'],
|
||||
'hero-visual' => ['right', 'short'],
|
||||
'werte-zeile' => ['fade'],
|
||||
'vorstellung-titel' => ['rise'],
|
||||
'vorstellung-text' => ['rise', 'short'],
|
||||
'angebot-kopf' => ['rise'],
|
||||
'angebot-1' => ['rise'],
|
||||
'angebot-2' => ['rise', 'short'],
|
||||
'angebot-3' => ['rise', 'medium'],
|
||||
'tag-intro' => ['rise'],
|
||||
'tag-schritt-1' => ['rise'],
|
||||
'tag-schritt-2' => ['rise', 'short'],
|
||||
'tag-schritt-3' => ['rise', 'medium'],
|
||||
'tag-schritt-4' => ['rise', 'long'],
|
||||
'kontakt-text' => ['rise'],
|
||||
'kontakt-karte' => ['rise', 'short'],
|
||||
]);
|
||||
|
||||
$anlegen('home', $startseite, 'Tagespflege Moldovan · Gemeinsam den Tag genießen', $medien['gemeinsam']['id']);
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben der Tagespflege Jacqueline Moldovan in Baden-Baden.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
|
||||
['Anbieterin', "Tagespflege Jacqueline Moldovan<br>Fremersbergstraße 75<br>76530 Baden-Baden<br><br>Vertreten durch Jacqueline Moldovan."],
|
||||
['Kontakt', "Telefon: <a href=\"$telHref\">$telefon</a><br>E-Mail: <a href=\"mailto:$mail\">$mail</a>"],
|
||||
['Rechtsform und Register', 'NOCH ZU PRÜFEN: Rechtsform, Registergericht und Registernummer ergänzen, sofern eine Eintragung besteht.'],
|
||||
['Umsatzsteuer', 'NOCH ZU PRÜFEN: Umsatzsteuer-Identifikationsnummer eintragen oder auf die Steuerbefreiung für Pflegeleistungen nach § 4 Nr. 16 UStG hinweisen.'],
|
||||
['Aufsichtsbehörde', 'NOCH ZU PRÜFEN: zuständige Aufsicht für die Tagespflege eintragen, in Baden-Baden üblicherweise die Heimaufsicht der Stadt. Ebenfalls zu ergänzen: Berufsbezeichnung und zuständige Kammer, sofern einschlägig, sowie ein bestehender Versorgungsvertrag nach § 72 SGB XI.'],
|
||||
['Verantwortlich für den Inhalt', "Jacqueline Moldovan, Anschrift wie oben."],
|
||||
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
|
||||
['Bildnachweis', 'Porträt: Jacqueline Moldovan. Einstiegsbild: illustrative KI-Aufnahme, keine Abbildung tatsächlicher Gäste. Markenzeichen: eigene Gestaltung.'],
|
||||
['Haftung für Links', 'Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Für diese Inhalte ist stets die jeweilige Anbieterin oder der jeweilige Anbieter verantwortlich. Bei Bekanntwerden von Rechtsverletzungen entfernen wir solche Links umgehend.'],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | Tagespflege Jacqueline Moldovan Baden-Baden');
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Wie die Tagespflege Jacqueline Moldovan Daten beim Besuch der Website und bei einer Kontaktaufnahme verarbeitet.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Kontaktaufnahme verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortliche', "Tagespflege Jacqueline Moldovan, Fremersbergstraße 75, 76530 Baden-Baden. Telefon <a href=\"$telHref\">$telefon</a>, E-Mail <a href=\"mailto:$mail\">$mail</a>.<br><br>NOCH ZU PRÜFEN: Ob eine Datenschutzbeauftragte oder ein Datenschutzbeauftragter zu benennen ist, hängt von der Zahl der ständig mit der Verarbeitung beschäftigten Personen ab; gegebenenfalls hier die Kontaktdaten ergänzen."],
|
||||
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.<br><br>NOCH ZU PRÜFEN: Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen, Verarbeitungsländer und die konkrete Speicherdauer der Serverprotokolle eintragen.'],
|
||||
['3. Cookies, Schriften, externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
|
||||
['4. Ihre Nachricht über das Kontaktformular', 'Wenn Sie uns schreiben, verarbeiten wir die von Ihnen angegebenen Kontakt- und Inhaltsdaten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn es um die Anbahnung einer Betreuung geht, sonst Art. 6 Abs. 1 lit. f DSGVO.<br><br>Bitte schildern Sie im Formular keine Gesundheitsangaben, keinen Pflegegrad und keine Diagnosen. Solche Angaben sind besonders geschützt (Art. 9 DSGVO); wir besprechen sie lieber persönlich oder am Telefon.'],
|
||||
['5. Wie lange wir speichern', 'Anfragedaten löschen wir, sobald sie für die Beantwortung nicht mehr nötig sind und keine gesetzliche Aufbewahrungspflicht entgegensteht. Entsteht aus Ihrer Anfrage ein Betreuungsverhältnis, gelten für die dann anfallenden Unterlagen die gesetzlichen Fristen.'],
|
||||
['6. Empfänger', 'Ihre Angaben erreichen die Tagespflege per E-Mail. Darüber hinaus geben wir sie nicht weiter, außer wir sind dazu gesetzlich verpflichtet.<br><br>NOCH ZU PRÜFEN: E-Mail-Anbieter und Hostingdienst als Auftragsverarbeiter benennen.'],
|
||||
['7. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen. Eine erteilte Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen.'],
|
||||
['8. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
|
||||
['', "Fragen zum Datenschutz beantworten wir gern unter <a href=\"mailto:$mail\">$mail</a>."],
|
||||
]),
|
||||
],
|
||||
], 'Datenschutz | Tagespflege Jacqueline Moldovan');
|
||||
|
||||
$fehlerId = $anlegen('404', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
|
||||
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
|
||||
'blocks' => [
|
||||
$abschnitt('fehler', [
|
||||
$kleinzeile('fehler-label', 'Fehler 404'),
|
||||
[...$kleinzeile('fehler-label', 'Fehler 404'), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
$heading('fehler-titel', 'Diese Seite<br><em>haben wir nicht.</em>', 1, [
|
||||
'desktop' => [...$serif, 'fontSize' => '59px', 'margin' => ['bottom' => '25px']],
|
||||
'mobile' => ['fontSize' => '40px'],
|
||||
@@ -744,7 +898,7 @@ $settings->save([
|
||||
'stickyHeader' => true, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'faviconMediaId' => $medien['marke']['id'],
|
||||
'homePath' => 'home',
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
'consentBanner' => false, 'scrollTop' => false, 'postMetaHeader' => true,
|
||||
]);
|
||||
echo "Einstellungen gespeichert\n";
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Environment;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Zeigt die öffentliche Adresse der Website oder ändert sie.
|
||||
*
|
||||
* Die Adresse (`SITE_ORIGIN`) steht in `.env`, weil sie zur Installation gehört und nicht zum
|
||||
* Inhalt: Der Installer schreibt sie einmal, danach gab es nur den Editor für die Datei. Dieser
|
||||
* Befehl prüft, was hineinkommt - mit Schema, ohne Pfad -, und schreibt es an dieselbe Stelle.
|
||||
*
|
||||
* Mit oder ohne www ist damit eine Entscheidung an einem Ort: Canonical, Sitemap, Feed und die
|
||||
* Umleitung der anderen Schreibweise folgen dieser Einstellung.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:site:origin', description: 'Zeigt die öffentliche Adresse der Website (SITE_ORIGIN) oder ändert sie.')]
|
||||
final class SiteOriginCommand extends Command
|
||||
{
|
||||
public function __construct(private string $projectDir, private string $siteOrigin)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('adresse', InputArgument::OPTIONAL, 'Die neue Adresse mit Schema, z. B. https://www.meine-firma.de. Ohne Angabe wird die aktuelle gezeigt.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$neu = trim((string) $input->getArgument('adresse'));
|
||||
if ('' === $neu) {
|
||||
$io->writeln(sprintf('Die Website ist eingetragen als <info>%s</info>.', '' === $this->siteOrigin ? '(leer)' : $this->siteOrigin));
|
||||
$io->writeln('Ändern: <comment>php bin/console cms:site:origin https://www.meine-firma.de</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$neu = self::normalize($neu);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
try {
|
||||
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
|
||||
} catch (\RuntimeException $e) {
|
||||
$io->error($e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Die Website ist jetzt eingetragen als %s.', $neu));
|
||||
$io->writeln('Canonical, Sitemap, Feed und strukturierte Daten nennen diese Adresse; die Schreibweise '.(str_starts_with(parse_url($neu, PHP_URL_HOST) ?: '', 'www.') ? 'ohne' : 'mit').' www leitet dorthin um.');
|
||||
$io->writeln('Falls die Website den alten Wert noch kennt: <comment>php bin/console cache:clear --env=prod</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema und Host, kleingeschrieben, ohne Pfad, Abfrage oder abschließenden Schrägstrich.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public static function normalize(string $adresse): string
|
||||
{
|
||||
$adresse = rtrim(trim($adresse), '/');
|
||||
$teile = parse_url($adresse);
|
||||
$schema = strtolower((string) ($teile['scheme'] ?? ''));
|
||||
if (!\in_array($schema, ['http', 'https'], true)) {
|
||||
throw new \InvalidArgumentException('Die Adresse beginnt mit https:// – zum Beispiel https://www.meine-firma.de');
|
||||
}
|
||||
$host = strtolower((string) ($teile['host'] ?? ''));
|
||||
if ('' === $host || false === filter_var($schema.'://'.$host, FILTER_VALIDATE_URL) || isset($teile['user'], $teile['pass'])) {
|
||||
throw new \InvalidArgumentException('Diese Adresse ist keine gültige URL.');
|
||||
}
|
||||
if (isset($teile['path']) && '' !== $teile['path'] || isset($teile['query']) || isset($teile['fragment'])) {
|
||||
throw new \InvalidArgumentException('Nur Schema und Domain, ohne Pfad: https://www.meine-firma.de statt https://www.meine-firma.de/start');
|
||||
}
|
||||
|
||||
return $schema.'://'.$host.(isset($teile['port']) ? ':'.$teile['port'] : '');
|
||||
}
|
||||
}
|
||||
@@ -172,8 +172,39 @@ final class Consent
|
||||
'decided' => null !== $decision,
|
||||
'decidedAt' => $decision['at'] ?? null,
|
||||
'text' => $settings['consentText'],
|
||||
'title' => $settings['consentTitle'],
|
||||
'layout' => $settings['consentLayout'],
|
||||
'privacyPath' => $settings['privacyPath'],
|
||||
'imprintPath' => $settings['imprintPath'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Gestaltung des Hinweises als CSS-Variablen für den Kopf der Seite.
|
||||
*
|
||||
* Das Stylesheet kennt die Standardwerte; hier stehen nur die Abweichungen, die jemand in den
|
||||
* Einstellungen gewählt hat. Die Form bestimmt zwei Radien auf einmal - Fläche und Knöpfe
|
||||
* gehören zusammen, und zwei Regler dafür wären zwei Regler, die man gegeneinander verstellt.
|
||||
*/
|
||||
public function styles(array $theme): string
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
if (!$settings['consentBanner']) {
|
||||
return '';
|
||||
}
|
||||
[$box, $button] = match ($settings['consentShape']) {
|
||||
'rund' => ['28px', '999px'],
|
||||
'eckig' => ['0', '0'],
|
||||
default => ['18px', '10px'],
|
||||
};
|
||||
$values = ['--kant-consent-radius:'.$box, '--kant-consent-button-radius:'.$button];
|
||||
foreach (['consentColor' => '--kant-consent-bg', 'consentInk' => '--kant-consent-ink', 'consentAccent' => '--kant-consent-accent', 'consentAccentInk' => '--kant-consent-accent-ink'] as $field => $variable) {
|
||||
$value = Theme::cssColor((string) $settings[$field], $theme);
|
||||
if (null !== $value) {
|
||||
$values[] = $variable.':'.$value;
|
||||
}
|
||||
}
|
||||
|
||||
return ':root{'.implode(';', $values).'}';
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,8 @@ final class CustomCode
|
||||
// so "& .block-text" must stay "#id .block-text" instead of becoming
|
||||
// "#id.block-text", which would target the element itself.
|
||||
$suffix = preg_replace('/\s+/', ' ', rtrim($match[1][0])) ?? '';
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 ,.:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
// Kein Komma: `&,body{...}` wuerde zu `#id,body{...}` und verliesse das Element.
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
|
||||
}
|
||||
$rules[$suffix] = $this->declarations($match[2][0]);
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Plugin\PluginRegistry;
|
||||
|
||||
final class DocumentValidator
|
||||
{
|
||||
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode())
|
||||
private bool $writing = false;
|
||||
|
||||
public function __construct(private StyleCompiler $styles = new StyleCompiler(), private CustomCode $code = new CustomCode(), private ?PluginRegistry $plugins = null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,16 +38,24 @@ final class DocumentValidator
|
||||
public const V15_TYPES = ['icon', 'progress', 'slider'];
|
||||
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
|
||||
public const V23_TYPES = ['social'];
|
||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||
public const SCHEMA_VERSION = 23;
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
* reusable section that appears on many pages must not bring one
|
||||
* of its own, or every page carrying it would have two.
|
||||
* @param bool $writing Beim Schreiben werden die Eigenschaften von Plugin-Bausteinen
|
||||
* gegen ihr Schema geprüft. Beim Lesen nicht: ein Plugin, das
|
||||
* nach dem Speichern eingeschaltet oder geändert wurde, darf
|
||||
* eine gespeicherte Seite nicht unlesbar machen - der Renderer
|
||||
* lässt unpassende Eigenschaften ohnehin nicht durch.
|
||||
*/
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
public function validate(array $document, bool $requireHeading = true, bool $writing = false): void
|
||||
{
|
||||
$this->writing = $writing;
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], true), 'Unsupported document schema.');
|
||||
$this->require(is_int($schemaVersion) && $schemaVersion >= 1 && $schemaVersion <= self::SCHEMA_VERSION, 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -112,6 +123,21 @@ final class DocumentValidator
|
||||
$props = $block['props'] ?? [];
|
||||
$layout = $block['layout'] ?? [];
|
||||
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
|
||||
// Ein aktives Plugin kennt das Schema seines Bausteins - dann wird es auch geprüft, so
|
||||
// streng wie bei einem Kernbaustein. Ohne Plugin bleibt der Erhaltungsvertrag: aufbewahren,
|
||||
// nicht prüfen, nicht rendern.
|
||||
if ($plugin && $this->writing && null !== $this->plugins && null !== ($definition = $this->plugins->block($type))) {
|
||||
$this->plugins->validateProps($type, $props);
|
||||
// Ein Container nimmt Kinder auf, ein gewöhnlicher Baustein nicht; eine Liste
|
||||
// erlaubter Kinder im Manifest gilt wie ALLOW bei Galerie oder Akkordeon.
|
||||
$kinder = is_array($block['children'] ?? null) ? $block['children'] : [];
|
||||
$this->require($definition['container'] || [] === $kinder, sprintf('„%s“ nimmt keine Bausteine auf.', $definition['label']));
|
||||
if (null !== $definition['children']) {
|
||||
foreach ($kinder as $kind) {
|
||||
$this->require(in_array($kind['type'] ?? '', $definition['children'], true), sprintf('In „%s“ passt nur: %s.', $definition['label'], implode(', ', $definition['children'])));
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($layout as $key => $value) {
|
||||
if ('visibility' === $key) {
|
||||
$this->require($schemaVersion >= 22 && in_array($value, ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'], true), 'Unbekannte Sichtbarkeit oder Dokumentschema vor v22.');
|
||||
@@ -240,7 +266,9 @@ final class DocumentValidator
|
||||
foreach (['phone', 'email', 'address'] as $field) {
|
||||
$this->text($props[$field] ?? null, 'address' === $field ? 200 : 80, true);
|
||||
}
|
||||
$this->require('' !== trim($props['phone'].$props['email'].$props['address']), 'A contact block needs at least one detail.');
|
||||
// Leer ist erlaubt: der Baustein steht nach dem Einfuegen erst einmal ohne Angaben da,
|
||||
// und ein Dokument mit einem leeren Kontaktblock darf sich speichern lassen. Oeffentlich
|
||||
// rendert er dann nichts, im Editor einen Hinweis.
|
||||
if ('' !== $props['phone']) {
|
||||
$this->require(1 === preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $props['phone']), 'Invalid phone number.');
|
||||
}
|
||||
@@ -251,6 +279,11 @@ final class DocumentValidator
|
||||
if ('spacer' === $type) {
|
||||
$this->require(in_array($props['size'] ?? null, self::SPACER_SIZES, true), 'Unknown spacer size.');
|
||||
}
|
||||
if ('accordion' === $type && array_key_exists('faq', $props)) {
|
||||
// Ein Akkordeon ist nicht automatisch eine FAQ; erst die Kennzeichnung macht aus den
|
||||
// Einträgen Frage-Antwort-Paare in den strukturierten Daten.
|
||||
$this->require($schemaVersion >= 23 && is_bool($props['faq']), 'Die FAQ-Kennzeichnung ist an oder aus und braucht Schema v23.');
|
||||
}
|
||||
if ('accordionitem' === $type) {
|
||||
$this->text($props['title'] ?? null, 160);
|
||||
$this->require(is_bool($props['open'] ?? false), 'The open state is a boolean.');
|
||||
@@ -258,6 +291,10 @@ final class DocumentValidator
|
||||
if ('tabitem' === $type) {
|
||||
$this->text($props['title'] ?? null, 160);
|
||||
}
|
||||
if ('legal' === $type) {
|
||||
// Die Beschriftung der Rechtszeile ist der Name der Landmarke fuer Vorlesesoftware.
|
||||
$this->text($props['label'] ?? 'Rechtliches', 60, true);
|
||||
}
|
||||
if ('menu' === $type) {
|
||||
// The entries live in the menu administration; the block only says which slot to show
|
||||
// and how to lay it out.
|
||||
@@ -278,7 +315,7 @@ final class DocumentValidator
|
||||
if ('partialref' === $type) {
|
||||
// Only the address of the section is content. Its blocks live in the partial, so one
|
||||
// change reaches every page that links it and a page cannot alter a shared section.
|
||||
$this->require(is_string($props['partial'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80, 'Invalid section address.');
|
||||
$this->require(is_string($props['partial'] ?? null) && ('' === $props['partial'] || (1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['partial']) && strlen($props['partial']) <= 80)), 'Invalid section address.');
|
||||
}
|
||||
if ('icon' === $type) {
|
||||
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
||||
@@ -333,7 +370,9 @@ final class DocumentValidator
|
||||
$this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.');
|
||||
}
|
||||
}
|
||||
if ('embed' === $type) {
|
||||
if ('embed' === $type && '' !== ($props['url'] ?? '')) {
|
||||
// Ohne Adresse ist der Baustein ein leerer Rahmen, den der Editor benennt - erst mit
|
||||
// Adresse gelten Titel, Anbieter und Hoehe.
|
||||
// Everything about the embed is validated here, because the address ends up in an
|
||||
// iframe and in the content security policy of the page.
|
||||
$this->require(isset(Consent::GROUPS[$props['group'] ?? '']), 'Unbekannte Einwilligungsgruppe.');
|
||||
@@ -353,7 +392,8 @@ final class DocumentValidator
|
||||
if ('form' === $type) {
|
||||
// Only the address is content. Fields, recipient and consent text stay in the database,
|
||||
// so a stored document can never redirect a submission or change what is asked.
|
||||
$this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.');
|
||||
// Leer heisst: noch kein Formular gewaehlt. Der Editor zeigt das, die Website nichts.
|
||||
$this->require(is_string($props['form'] ?? null) && ('' === $props['form'] || (1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80)), 'Invalid form address.');
|
||||
}
|
||||
if ('gallery' === $type && isset($props['lightbox'])) {
|
||||
$this->require($schemaVersion >= 19, 'Der Bildbetrachter braucht Schema v19.');
|
||||
@@ -427,7 +467,9 @@ final class DocumentValidator
|
||||
foreach ($design as $device => $settings) {
|
||||
$this->require(in_array($device, ['desktop', 'tablet', 'mobile'], true) && is_array($settings), 'Invalid responsive device.');
|
||||
foreach ($settings as $key => $value) {
|
||||
$this->require(isset($tokens[$key]) && in_array($type, $tokens[$key]['types'], true), 'Design setting does not apply to this block.');
|
||||
// Plugin-Bausteine dürfen jedes Token tragen: die Klassen dafür sind allgemein, und
|
||||
// das Manifest kann nicht in dieser Datei stehen.
|
||||
$this->require(isset($tokens[$key]) && (in_array($type, $tokens[$key]['types'], true) || str_contains($type, '.')), 'Design setting does not apply to this block.');
|
||||
$this->require(is_string($value) && isset($tokens[$key]['values'][$value]), 'Invalid responsive design token.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ final class FontLibrary
|
||||
{
|
||||
}
|
||||
|
||||
/** Gibt es ein Stylesheet mit lokalen Schriften - auch wenn nicht jede Schrift des Themes dabei ist? */
|
||||
public function hasStylesheet(): bool
|
||||
{
|
||||
return is_file($this->directory.'/fonts.css');
|
||||
}
|
||||
|
||||
/** Liegen für dieses Theme fertige Dateien bereit? */
|
||||
public function hasLocal(array $theme): bool
|
||||
{
|
||||
@@ -108,9 +114,11 @@ final class FontLibrary
|
||||
/**
|
||||
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
|
||||
*
|
||||
* Nur der lateinische Grundbereich und nur die Schnitte, die im Fließtext und in
|
||||
* Überschriften stehen - ein Vorabholen, das alles holt, verzögert genau das, was es
|
||||
* beschleunigen soll.
|
||||
* Jeder Schnitt, den das Theme nennt, im lateinischen Grundbereich. Die früheren „ersten
|
||||
* zwei je Familie“ ließen genau den Schnitt aus, der die Überschriften trägt: Lato 900 lud
|
||||
* erst nach dem Stylesheet, und mit `font-display:swap` sprang die Überschrift sichtbar von
|
||||
* der Ersatzschrift auf die richtige. Die erweiterten Bereiche bleiben draußen - sie werden
|
||||
* nur für Zeichen gebraucht, die auf den meisten Seiten nicht vorkommen.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -118,7 +126,7 @@ final class FontLibrary
|
||||
{
|
||||
$preload = [];
|
||||
foreach ($this->wanted($theme) as $family => $weights) {
|
||||
foreach (array_slice($weights, 0, 2) as $weight) {
|
||||
foreach ($weights as $weight) {
|
||||
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
|
||||
if (is_file($this->directory.'/'.$name)) {
|
||||
$preload[] = '/assets/fonts/'.$name;
|
||||
|
||||
+299
-21
@@ -9,6 +9,12 @@ use Doctrine\DBAL\Connection;
|
||||
/**
|
||||
* Form definitions and their submissions. A definition is a validated list of fields; a submission
|
||||
* stores the answers, the moment consent was given and a keyed hash of the sender address.
|
||||
*
|
||||
* Ein Formular kann in Schritten fragen: ein Feld vom Typ „Schritt“ eröffnet einen Abschnitt, alle
|
||||
* Felder bis zum nächsten Schritt gehören dazu. Und ein Feld oder ein Schritt kann von einer
|
||||
* früheren Auswahl abhängen („nur zeigen, wenn Immobilienart = Wohnung“). Beides steht in der
|
||||
* Definition, nicht im Skript: der Server entscheidet beim Absenden selbst, welche Felder galten,
|
||||
* und verlangt nichts, was der Besucher gar nicht sehen sollte.
|
||||
*/
|
||||
final class FormRepository
|
||||
{
|
||||
@@ -16,11 +22,25 @@ final class FormRepository
|
||||
'text' => 'Einzeiliger Text',
|
||||
'email' => 'E-Mail-Adresse',
|
||||
'phone' => 'Telefonnummer',
|
||||
'number' => 'Zahl',
|
||||
'textarea' => 'Mehrzeiliger Text',
|
||||
'select' => 'Auswahl',
|
||||
'select' => 'Auswahl (Liste)',
|
||||
'radio' => 'Auswahl (Optionsfelder)',
|
||||
'checkboxes' => 'Mehrfachauswahl',
|
||||
'consent' => 'Einwilligung',
|
||||
'step' => 'Neuer Schritt',
|
||||
];
|
||||
/** Die Feldtypen mit Optionen; nur von ihnen kann ein anderes Feld abhängen. */
|
||||
public const CHOICE_TYPES = ['select', 'radio', 'checkboxes'];
|
||||
/** Ein Preisfinder mit vier Objektarten braucht mehr als ein Kontaktformular. */
|
||||
public const MAX_FIELDS = 40;
|
||||
/** Die gestaltbaren Farben eines Formulars, mit ihrer Bezeichnung in der Verwaltung. */
|
||||
public const COLORS = [
|
||||
'fieldBorderColor' => 'den Rahmen der Felder',
|
||||
'fieldBackgroundColor' => 'den Hintergrund der Felder',
|
||||
'buttonColor' => 'den Button',
|
||||
'buttonInk' => 'die Buttonschrift',
|
||||
];
|
||||
public const MAX_FIELDS = 25;
|
||||
/** Submissions per sender per hour. Deliberately low: a contact form is not a chat. */
|
||||
public const RATE_LIMIT = 5;
|
||||
/** A human needs longer than this to fill in a form; anything faster is a script. */
|
||||
@@ -49,10 +69,14 @@ final class FormRepository
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
// Ältere Definitionen kennen weder Grenzen noch Bedingungen; die Vorlage und die Prüfung
|
||||
// verlassen sich darauf, dass jeder Schlüssel da ist.
|
||||
$fields = array_map(static fn (array $field): array => $field + ['help' => '', 'options' => [], 'images' => [], 'min' => null, 'max' => null, 'unit' => '', 'showIf' => null], json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR));
|
||||
|
||||
return [
|
||||
'id' => $row['id'], 'slug' => $row['slug'], 'name' => $row['name'],
|
||||
'fields' => json_decode($row['fields'], true, 512, JSON_THROW_ON_ERROR),
|
||||
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR),
|
||||
'fields' => $fields,
|
||||
'settings' => json_decode($row['settings'], true, 512, JSON_THROW_ON_ERROR) + ['summary' => false],
|
||||
'version' => (int) $row['version'],
|
||||
];
|
||||
}
|
||||
@@ -107,6 +131,8 @@ final class FormRepository
|
||||
throw new InvalidForm(sprintf('Ein Formular fasst höchstens %d Felder.', self::MAX_FIELDS));
|
||||
}
|
||||
$names = [];
|
||||
// Die Optionen der bisherigen Auswahlfelder: nur davon darf ein späteres Feld abhängen.
|
||||
$choices = [];
|
||||
$clean = [];
|
||||
foreach ($fields as $field) {
|
||||
$name = strtolower(trim((string) ($field['name'] ?? '')));
|
||||
@@ -123,40 +149,160 @@ final class FormRepository
|
||||
throw new InvalidForm('Jedes Feld braucht eine Beschriftung mit höchstens 120 Zeichen.');
|
||||
}
|
||||
$options = [];
|
||||
if ('select' === $type) {
|
||||
if (in_array($type, self::CHOICE_TYPES, true)) {
|
||||
foreach ((array) ($field['options'] ?? []) as $option) {
|
||||
$option = trim((string) $option);
|
||||
if ('' !== $option && mb_strlen($option) <= 120) {
|
||||
// Kein Komma in einer Option: die Verwaltung trennt Optionen daran, und eine
|
||||
// Mehrfachauswahl wird mit Komma gespeichert.
|
||||
if ('' !== $option && mb_strlen($option) <= 120 && !str_contains($option, ',') && !in_array($option, $options, true)) {
|
||||
$options[] = $option;
|
||||
}
|
||||
}
|
||||
if (count($options) < 2) {
|
||||
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen.', $label));
|
||||
throw new InvalidForm(sprintf('Die Auswahl „%s“ braucht mindestens zwei Optionen ohne Komma.', $label));
|
||||
}
|
||||
}
|
||||
$images = in_array($type, self::CHOICE_TYPES, true) ? $this->validateImages($field['images'] ?? [], $options, $label) : [];
|
||||
$min = $max = null;
|
||||
$unit = '';
|
||||
if ('number' === $type) {
|
||||
foreach (['min', 'max'] as $grenze) {
|
||||
$wert = str_replace(',', '.', trim((string) ($field[$grenze] ?? '')));
|
||||
if ('' === $wert) {
|
||||
continue;
|
||||
}
|
||||
if (!is_numeric($wert)) {
|
||||
throw new InvalidForm(sprintf('Die Grenze „%s“ von „%s“ ist keine Zahl.', $wert, $label));
|
||||
}
|
||||
$$grenze = (float) $wert;
|
||||
}
|
||||
if (null !== $min && null !== $max && $min >= $max) {
|
||||
throw new InvalidForm(sprintf('Bei „%s“ muss die Untergrenze unter der Obergrenze liegen.', $label));
|
||||
}
|
||||
$unit = mb_substr(trim((string) ($field['unit'] ?? '')), 0, 12);
|
||||
}
|
||||
$showIf = $this->validateCondition($field, $label, $choices);
|
||||
$clean[] = [
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'label' => $label,
|
||||
// A consent field is only meaningful when it has to be ticked.
|
||||
'required' => 'consent' === $type ? true : (bool) ($field['required'] ?? false),
|
||||
// A consent field is only meaningful when it has to be ticked; a step asks nothing.
|
||||
'required' => 'consent' === $type ? true : ('step' !== $type && (bool) ($field['required'] ?? false)),
|
||||
'help' => mb_substr(trim((string) ($field['help'] ?? '')), 0, 200),
|
||||
'options' => $options,
|
||||
'images' => $images,
|
||||
'min' => $min,
|
||||
'max' => $max,
|
||||
'unit' => $unit,
|
||||
'showIf' => $showIf,
|
||||
];
|
||||
if (in_array($type, self::CHOICE_TYPES, true)) {
|
||||
$choices[$name] = $options;
|
||||
}
|
||||
}
|
||||
if (count(array_filter($clean, static fn (array $f): bool => 'consent' === $f['type'])) > 1) {
|
||||
throw new InvalidForm('Ein Formular hat höchstens ein Einwilligungsfeld.');
|
||||
}
|
||||
if ([] === array_filter($clean, static fn (array $f): bool => 'step' !== $f['type'])) {
|
||||
throw new InvalidForm('Ein Formular braucht mindestens ein Feld, das etwas fragt – Schritte allein reichen nicht.');
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bedingung eines Feldes: es gilt nur, wenn ein früheres Auswahlfeld einen der genannten
|
||||
* Werte hat. Angenommen wird die gespeicherte Form (showIf: {field, values}) und die aus der
|
||||
* Verwaltung (showIfField, showIfValues als Kommaliste).
|
||||
*
|
||||
* @param array<string, list<string>> $choices Optionen der Auswahlfelder vor diesem Feld
|
||||
*
|
||||
* @return array{field: string, values: list<string>}|null
|
||||
*/
|
||||
private function validateCondition(array $field, string $label, array $choices): ?array
|
||||
{
|
||||
$ref = strtolower(trim((string) ($field['showIfField'] ?? ($field['showIf']['field'] ?? ''))));
|
||||
if ('' === $ref) {
|
||||
return null;
|
||||
}
|
||||
if (!isset($choices[$ref])) {
|
||||
throw new InvalidForm(sprintf('„%s“ soll von „%s“ abhängen, aber davor steht kein Auswahlfeld mit diesem Namen.', $label, $ref));
|
||||
}
|
||||
$wanted = $field['showIfValues'] ?? ($field['showIf']['values'] ?? []);
|
||||
if (is_string($wanted)) {
|
||||
$wanted = explode(',', $wanted);
|
||||
}
|
||||
$values = [];
|
||||
foreach ((array) $wanted as $wert) {
|
||||
$wert = trim((string) $wert);
|
||||
if ('' === $wert || in_array($wert, $values, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($wert, $choices[$ref], true)) {
|
||||
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ – die Bedingung von „%s“ könnte nie zutreffen.', $wert, $ref, $label));
|
||||
}
|
||||
$values[] = $wert;
|
||||
}
|
||||
if ([] === $values) {
|
||||
throw new InvalidForm(sprintf('Für die Bedingung von „%s“ fehlt der Wert, bei dem das Feld gilt.', $label));
|
||||
}
|
||||
|
||||
return ['field' => $ref, 'values' => $values];
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bilder einer Auswahl: je Option höchstens ein Bild aus der Mediathek. Sobald eine Option
|
||||
* eines hat, zeigt die Website die Auswahl als anklickbare Bilder statt als Kästchen.
|
||||
* Angenommen wird die gespeicherte Form (Option => Kennung) und die aus der Verwaltung
|
||||
* (eine Zeile je Option: „Option = Kennung“).
|
||||
*
|
||||
* @param list<string> $options
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function validateImages(mixed $input, array $options, string $label): array
|
||||
{
|
||||
if (is_string($input)) {
|
||||
$paare = [];
|
||||
foreach (preg_split('/\\r?\\n/', $input) ?: [] as $zeile) {
|
||||
if (!str_contains($zeile, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$option, $kennung] = array_map(trim(...), explode('=', $zeile, 2));
|
||||
$paare[$option] = $kennung;
|
||||
}
|
||||
$input = $paare;
|
||||
}
|
||||
$images = [];
|
||||
foreach ((array) $input as $option => $kennung) {
|
||||
$option = trim((string) $option);
|
||||
$kennung = strtolower(trim((string) $kennung));
|
||||
if ('' === $kennung) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($option, $options, true)) {
|
||||
throw new InvalidForm(sprintf('„%s“ ist keine Option von „%s“ und kann kein Bild bekommen.', $option, $label));
|
||||
}
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $kennung)) {
|
||||
throw new InvalidForm(sprintf('Das Bild für „%s“ ist keine Kennung aus der Mediathek.', $option));
|
||||
}
|
||||
$images[$option] = $kennung;
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
private function validateSettings(array $settings, array $fields): array
|
||||
{
|
||||
$recipient = trim((string) ($settings['recipient'] ?? ''));
|
||||
if (false === filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidForm('Bitte gib eine gültige Empfängeradresse an.');
|
||||
}
|
||||
// Der Absender ist freiwillig: leer heisst, die Post kommt von der Empfaengeradresse.
|
||||
$sender = trim((string) ($settings['sender'] ?? ''));
|
||||
if ('' !== $sender && false === filter_var($sender, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidForm('Bitte gib eine gültige Absenderadresse an oder lass das Feld leer.');
|
||||
}
|
||||
$replyTo = trim((string) ($settings['replyToField'] ?? ''));
|
||||
if ('' !== $replyTo) {
|
||||
$match = array_filter($fields, static fn (array $f): bool => $f['name'] === $replyTo && 'email' === $f['type']);
|
||||
@@ -165,17 +311,94 @@ final class FormRepository
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
$clean = [
|
||||
'recipient' => $recipient,
|
||||
'sender' => $sender,
|
||||
'subject' => mb_substr(trim((string) ($settings['subject'] ?? '')), 0, 160) ?: 'Neue Formularnachricht',
|
||||
'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.',
|
||||
'replyToField' => $replyTo,
|
||||
'buttonLabel' => mb_substr(trim((string) ($settings['buttonLabel'] ?? '')), 0, 40) ?: 'Absenden',
|
||||
// Die Zusammenfassung vor dem Absenden - ein Schritt, den nur das Skript zeigt; ohne
|
||||
// Skript steht das ganze Formular ohnehin auf einer Seite.
|
||||
'summary' => (bool) ($settings['summary'] ?? false),
|
||||
];
|
||||
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
|
||||
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
|
||||
foreach (self::COLORS as $field => $label) {
|
||||
$value = trim((string) ($settings[$field] ?? ''));
|
||||
if ('' !== $value && 1 !== preg_match(StyleSchema::COLOR, $value)) {
|
||||
throw new InvalidForm(sprintf('„%s“ ist kein Farbwert für %s.', $value, $label));
|
||||
}
|
||||
$clean[$field] = $value;
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
|
||||
public function needsScript(array $form): bool
|
||||
{
|
||||
if ($form['settings']['summary'] ?? false) {
|
||||
return true;
|
||||
}
|
||||
foreach ($form['fields'] as $field) {
|
||||
if ('step' === $field['type'] || null !== ($field['showIf'] ?? null)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Gestaltung eines Formulars als Regel fuer die Seite, die es zeigt.
|
||||
*
|
||||
* Das Formular bekommt seine Farben als Variablen: Rahmen und Grund der Felder direkt, die
|
||||
* Buttonfarben ueber dieselben Token, die auch der Standardbutton der Website benutzt - so
|
||||
* behaelt der Absendeknopf Form und Darstellung aus dem Design und wechselt nur die Farbe.
|
||||
* Nichts gesetzt, nichts geschrieben: dann gilt weiter die Website.
|
||||
*/
|
||||
public function css(array $form, array $theme): string
|
||||
{
|
||||
$values = [];
|
||||
$button = false;
|
||||
foreach (['fieldBorderColor' => '--form-border', 'fieldBackgroundColor' => '--form-bg', 'buttonColor' => '--kb-fill', 'buttonInk' => '--kb-ink'] as $field => $variable) {
|
||||
$value = Theme::cssColor((string) ($form['settings'][$field] ?? ''), $theme);
|
||||
if (null === $value) {
|
||||
continue;
|
||||
}
|
||||
$values[] = $variable.':'.$value;
|
||||
if ('buttonColor' === $field) {
|
||||
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,var(--ink))';
|
||||
$button = true;
|
||||
}
|
||||
if ('buttonInk' === $field) {
|
||||
$values[] = '--kb-hover-ink:'.$value;
|
||||
$button = true;
|
||||
}
|
||||
}
|
||||
if ($button) {
|
||||
// Die Darstellung (gefuellt, Rahmen, dezent) leitet Flaeche und Schrift aus Fuellung
|
||||
// und Tinte ab - am Wurzelelement, mit den Werten von dort. Hier noch einmal, damit
|
||||
// die Ableitung die Farben dieses Formulars nimmt.
|
||||
foreach (ButtonDesign::appearance($theme['buttons']['appearance'] ?? 'solid') as $key => $value) {
|
||||
$values[] = '--kb-'.$key.':'.$value;
|
||||
}
|
||||
}
|
||||
if ([] === $values) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf('.block-form[data-form="%s"]{%s}', $form['slug'], implode(';', $values));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks one submission against the stored definition.
|
||||
*
|
||||
* Ein Feld hinter einer Bedingung zählt nur, wenn sie zutrifft; ein Schritt hinter einer
|
||||
* Bedingung nimmt seine Felder mit. Was nicht galt, wird weder verlangt noch gespeichert -
|
||||
* auch wenn ein Skript im Browser es mitgeschickt hat.
|
||||
*
|
||||
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
|
||||
*/
|
||||
public function check(array $form, array $input): array
|
||||
@@ -183,44 +406,99 @@ final class FormRepository
|
||||
$values = [];
|
||||
$errors = [];
|
||||
$consent = false;
|
||||
// Die gewählten Optionen der bisherigen Auswahlfelder, für die Bedingungen danach.
|
||||
$chosen = [];
|
||||
$stepActive = true;
|
||||
// Kein Pfeil: der bekäme die leere Liste von oben und sähe keine spätere Wahl.
|
||||
$holds = static function (?array $showIf) use (&$chosen): bool {
|
||||
return null === $showIf || [] !== array_intersect($showIf['values'], $chosen[$showIf['field']] ?? []);
|
||||
};
|
||||
foreach ($form['fields'] as $field) {
|
||||
$raw = $input[$field['name']] ?? '';
|
||||
$name = $field['name'];
|
||||
if ('step' === $field['type']) {
|
||||
$stepActive = $holds($field['showIf']);
|
||||
continue;
|
||||
}
|
||||
if (!$stepActive || !$holds($field['showIf'])) {
|
||||
$values[$name] = '';
|
||||
continue;
|
||||
}
|
||||
$raw = $input[$name] ?? '';
|
||||
if ('checkboxes' === $field['type']) {
|
||||
$picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v));
|
||||
if ([] !== array_diff($picked, $field['options'])) {
|
||||
$errors[$name] = 'Bitte wähle nur aus den angebotenen Optionen.';
|
||||
$picked = array_values(array_intersect($picked, $field['options']));
|
||||
} elseif ([] === $picked && $field['required']) {
|
||||
$errors[$name] = 'Bitte wähle mindestens eine Option.';
|
||||
}
|
||||
$chosen[$name] = $picked;
|
||||
$values[$name] = implode(', ', $picked);
|
||||
continue;
|
||||
}
|
||||
$value = is_string($raw) ? trim($raw) : '';
|
||||
$limit = 'textarea' === $field['type'] ? 5000 : 200;
|
||||
if (mb_strlen($value) > $limit) {
|
||||
$errors[$field['name']] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
|
||||
$errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
|
||||
$value = mb_substr($value, 0, $limit);
|
||||
}
|
||||
if ('consent' === $field['type']) {
|
||||
$consent = '' !== $value;
|
||||
if (!$consent) {
|
||||
$errors[$field['name']] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
|
||||
$errors[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
|
||||
}
|
||||
$values[$field['name']] = $consent ? 'ja' : '';
|
||||
$values[$name] = $consent ? 'ja' : '';
|
||||
continue;
|
||||
}
|
||||
if ('' === $value) {
|
||||
if ($field['required']) {
|
||||
$errors[$field['name']] = 'Dieses Feld wird benötigt.';
|
||||
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
|
||||
}
|
||||
$values[$field['name']] = '';
|
||||
$values[$name] = '';
|
||||
continue;
|
||||
}
|
||||
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[$field['name']] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
$errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
}
|
||||
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
|
||||
$errors[$field['name']] = 'Bitte gib eine gültige Telefonnummer an.';
|
||||
$errors[$name] = 'Bitte gib eine gültige Telefonnummer an.';
|
||||
}
|
||||
if ('select' === $field['type'] && !in_array($value, $field['options'], true)) {
|
||||
$errors[$field['name']] = 'Bitte wähle eine der angebotenen Optionen.';
|
||||
if ('number' === $field['type']) {
|
||||
$zahl = str_replace(',', '.', $value);
|
||||
if (!is_numeric($zahl)) {
|
||||
$errors[$name] = 'Bitte gib eine Zahl an.';
|
||||
} elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) {
|
||||
$errors[$name] = match (true) {
|
||||
null === $field['min'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])),
|
||||
null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])),
|
||||
default => sprintf('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
|
||||
};
|
||||
} else {
|
||||
// Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt.
|
||||
$value = self::zahl((float) $zahl);
|
||||
}
|
||||
}
|
||||
$values[$field['name']] = $value;
|
||||
if (in_array($field['type'], ['select', 'radio'], true)) {
|
||||
if (!in_array($value, $field['options'], true)) {
|
||||
$errors[$name] = 'Bitte wähle eine der angebotenen Optionen.';
|
||||
} else {
|
||||
$chosen[$name] = [$value];
|
||||
}
|
||||
}
|
||||
$values[$name] = $value;
|
||||
}
|
||||
|
||||
return ['values' => $values, 'errors' => $errors, 'consent' => $consent];
|
||||
}
|
||||
|
||||
/** Eine Zahl ohne unnötige Nachkommastellen, für Meldungen und die gespeicherte Antwort. */
|
||||
public static function zahl(float $wert): string
|
||||
{
|
||||
$text = number_format($wert, 3, '.', '');
|
||||
|
||||
return str_contains($text, '.') ? rtrim(rtrim($text, '0'), '.') : $text;
|
||||
}
|
||||
|
||||
/** True when this sender has already sent the allowed number of messages within the hour. */
|
||||
public function rateLimited(string $senderHash): bool
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
@@ -31,12 +32,12 @@ final class PageRepository
|
||||
throw new InvalidPage('Unbekannter Inhaltstyp.');
|
||||
}
|
||||
$document = $this->richText->normaliseDocument($document);
|
||||
$this->validator->validate($document);
|
||||
$this->validator->validate($document, writing: true);
|
||||
|
||||
return $this->db->transactional(function () use ($slug, $parentId, $type, $document, $locale): string {
|
||||
$path = $slug;
|
||||
if (null !== $parentId) {
|
||||
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ?', [$parentId, 'default', $locale]);
|
||||
$parent = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND locale = ? AND deleted_at IS NULL', [$parentId, 'default', $locale]);
|
||||
if (false === $parent) {
|
||||
throw new InvalidPage('Die übergeordnete Seite gibt es nicht mehr. Lade die Übersicht neu.');
|
||||
}
|
||||
@@ -244,13 +245,19 @@ final class PageRepository
|
||||
* overwritten in place: the restored state becomes a new revision, so the history stays whole.
|
||||
*/
|
||||
public function restoreRevision(string $pageId, string $revisionId, int $expectedVersion): string
|
||||
{
|
||||
return $this->saveDraft($pageId, $this->revisionDocument($pageId, $revisionId), $expectedVersion);
|
||||
}
|
||||
|
||||
/** Das Dokument einer Fassung dieser Seite - und nur dieser Seite. */
|
||||
public function revisionDocument(string $pageId, string $revisionId): array
|
||||
{
|
||||
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
|
||||
if (false === $raw) {
|
||||
throw new InvalidPage('Diese Fassung gehört nicht zu dieser Seite.');
|
||||
}
|
||||
|
||||
return $this->saveDraft($pageId, json_decode($raw, true, 512, JSON_THROW_ON_ERROR), $expectedVersion);
|
||||
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,7 +273,15 @@ final class PageRepository
|
||||
'SELECT id, path, draft_revision_id, version FROM page WHERE website_id = ? AND deleted_at IS NULL AND publish_at IS NOT NULL AND publish_at <= ? AND draft_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id <> draft_revision_id)',
|
||||
['default', gmdate('Y-m-d H:i:s')],
|
||||
) as $row) {
|
||||
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
|
||||
try {
|
||||
$this->publish($row['id'], $row['draft_revision_id'], (int) $row['version']);
|
||||
} catch (InvalidPage|InvalidDocument|StaleWrite|\InvalidArgumentException $e) {
|
||||
// Eine Seite, deren Fassung heute nicht mehr gilt, bleibt Entwurf - und haelt die
|
||||
// anderen nicht auf. Der Grund steht im Protokoll, die Seite behaelt ihren Termin.
|
||||
error_log(sprintf('Geplante Veröffentlichung von /%s zurückgestellt: %s', $row['path'], $e->getMessage()));
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->db->executeStatement('UPDATE page SET publish_at = NULL WHERE id = ?', [$row['id']]);
|
||||
$published[] = $row['path'];
|
||||
}
|
||||
@@ -319,7 +334,7 @@ final class PageRepository
|
||||
// Sanitising happens before validating and before storing, so the database never holds
|
||||
// markup the allowlist would reject and no renderer has to remember to filter.
|
||||
$document = $this->richText->normaliseDocument($document);
|
||||
$this->validator->validate($document);
|
||||
$this->validator->validate($document, writing: true);
|
||||
|
||||
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
|
||||
$id = bin2hex(random_bytes(16));
|
||||
|
||||
@@ -99,7 +99,7 @@ final class PartialRepository
|
||||
$slug = 'default';
|
||||
}
|
||||
$document = $this->richText->normaliseDocument($document);
|
||||
$this->validator->validate($document, false);
|
||||
$this->validator->validate($document, false, true);
|
||||
if (null !== $this->findByKind($kind, $slug, $locale)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
|
||||
}
|
||||
@@ -117,7 +117,7 @@ final class PartialRepository
|
||||
public function save(string $id, array $document, int $expectedVersion): void
|
||||
{
|
||||
$document = $this->richText->normaliseDocument($document);
|
||||
$this->validator->validate($document, false);
|
||||
$this->validator->validate($document, false, true);
|
||||
$this->db->transactional(function () use ($id, $document, $expectedVersion): void {
|
||||
// A section must not end up containing itself, directly or through another section.
|
||||
$this->guardAgainstCycles($id, $document);
|
||||
@@ -179,6 +179,71 @@ final class PartialRepository
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
/**
|
||||
* Dieselbe Sektion mehrfach auf einer Seite: die zweite und jede weitere Verknüpfung bekommt
|
||||
* eine Kopie mit eigenen Kennungen (`id-2`, `id-3`) und ohne Sprungziele, damit die Seite keine
|
||||
* doppelten IDs trägt - ein Anker, ein Stil und jede Skriptsuche träfen sonst nur die erste.
|
||||
*
|
||||
* Gezählt wird in Renderreihenfolge über alle Dokumente einer Seite hinweg (`$seen` teilen sich
|
||||
* Kopf, Inhalt und Fuß); verschachtelte Verknüpfungen in einer Sektion zählen mit, bis zur
|
||||
* Verschachtelungsgrenze. `$sections` bekommt die Kopien unter `slug~n`, der Verweis im
|
||||
* zurückgegebenen Dokument zeigt dorthin.
|
||||
*
|
||||
* @param array<string, array> $sections die aufgelösten Sektionen, werden um Kopien ergänzt
|
||||
* @param array<string, int> $seen Zähler je Slug
|
||||
* @param array<string, array> $pristine die Sektionen, wie sie gespeichert sind
|
||||
*/
|
||||
public function expandRepeatedLinks(array $document, array &$sections, array &$seen, array $pristine, int $depth = 0): array
|
||||
{
|
||||
$walk = function (array $blocks) use (&$walk, &$sections, &$seen, $pristine, $depth): array {
|
||||
foreach ($blocks as $i => $block) {
|
||||
$slug = 'partialref' === ($block['type'] ?? null) && is_string($block['props']['partial'] ?? null) ? $block['props']['partial'] : null;
|
||||
if (null !== $slug && isset($pristine[$slug])) {
|
||||
$n = $seen[$slug] = ($seen[$slug] ?? 0) + 1;
|
||||
if (1 === $n) {
|
||||
if ($depth < self::MAX_NESTING) {
|
||||
$sections[$slug] = $this->expandRepeatedLinks($pristine[$slug], $sections, $seen, $pristine, $depth + 1);
|
||||
}
|
||||
} else {
|
||||
$key = $slug.'~'.$n;
|
||||
$copy = $this->renamed($pristine[$slug], '-'.$n);
|
||||
$sections[$key] = $depth < self::MAX_NESTING ? $this->expandRepeatedLinks($copy, $sections, $seen, $pristine, $depth + 1) : $copy;
|
||||
$blocks[$i]['props']['partial'] = $key;
|
||||
}
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$blocks[$i]['children'] = $walk($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
};
|
||||
$document['blocks'] = $walk($document['blocks'] ?? []);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/** Ein Dokument mit angehängter Kennung an jedem Block, ohne Sprungziele. */
|
||||
private function renamed(array $document, string $suffix): array
|
||||
{
|
||||
$walk = static function (array $blocks) use (&$walk, $suffix): array {
|
||||
foreach ($blocks as $i => $block) {
|
||||
if (isset($block['id'])) {
|
||||
$blocks[$i]['id'] = substr((string) $block['id'], 0, 64 - strlen($suffix)).$suffix;
|
||||
}
|
||||
unset($blocks[$i]['anchor']);
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$blocks[$i]['children'] = $walk($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
};
|
||||
$document['blocks'] = $walk($document['blocks'] ?? []);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
public function referencedSlugs(array $document): array
|
||||
{
|
||||
$slugs = [];
|
||||
|
||||
@@ -27,6 +27,10 @@ final class SiteSettings
|
||||
// Die sozialen Profile, je Netzwerk eine Adresse (siehe SocialProfiles). Der Baustein
|
||||
// `social` zeigt sie an; die strukturierten Daten führen sie als sameAs mit.
|
||||
'social' => [],
|
||||
// Öffnungszeiten je Wochentag (opens/closes als HH:MM) und das Einsatzgebiet als Liste
|
||||
// von Ortsnamen. Beides landet in LocalBusiness, weil eine Suchmaschine sonst rät.
|
||||
'openingHours' => [],
|
||||
'areaServed' => [],
|
||||
'localBusiness' => false,
|
||||
'llmsTxt' => false,
|
||||
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
||||
@@ -71,14 +75,86 @@ final class SiteSettings
|
||||
'consentBanner' => false,
|
||||
'consentGroups' => ['external'],
|
||||
'consentText' => '',
|
||||
// Sein Aussehen: Anordnung, Form, Farben, Überschrift. Leer heißt Theme-Standard.
|
||||
'consentLayout' => 'karte',
|
||||
'consentShape' => 'weich',
|
||||
'consentTitle' => '',
|
||||
'consentColor' => '',
|
||||
'consentInk' => '',
|
||||
'consentAccent' => '',
|
||||
'consentAccentInk' => '',
|
||||
'privacyPath' => '',
|
||||
'imprintPath' => '',
|
||||
];
|
||||
|
||||
/** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */
|
||||
public const DAYS = ['Monday' => 'Montag', 'Tuesday' => 'Dienstag', 'Wednesday' => 'Mittwoch', 'Thursday' => 'Donnerstag', 'Friday' => 'Freitag', 'Saturday' => 'Samstag', 'Sunday' => 'Sonntag'];
|
||||
|
||||
public function __construct(private Connection $db, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Öffnungszeiten aus dem Formular: je Tag ein Paar aus Öffnen und Schließen; ein Tag ohne
|
||||
* beide Angaben gilt als geschlossen und fehlt in der Liste.
|
||||
*
|
||||
* @return list<array{day: string, opens: string, closes: string}>
|
||||
*/
|
||||
private function openingHours(mixed $input): array
|
||||
{
|
||||
$hours = [];
|
||||
if (is_array($input) && array_is_list($input)) {
|
||||
// Die gespeicherte Form: eine Liste von {day, opens, closes} wird zur Formularform.
|
||||
$map = [];
|
||||
foreach ($input as $row) {
|
||||
if (is_array($row) && isset($row['day'])) {
|
||||
$map[(string) $row['day']] = ['opens' => $row['opens'] ?? '', 'closes' => $row['closes'] ?? ''];
|
||||
}
|
||||
}
|
||||
$input = $map;
|
||||
}
|
||||
foreach (self::DAYS as $day => $label) {
|
||||
$row = is_array($input) && is_array($input[$day] ?? null) ? $input[$day] : [];
|
||||
$opens = trim((string) ($row['opens'] ?? ''));
|
||||
$closes = trim((string) ($row['closes'] ?? ''));
|
||||
if ('' === $opens && '' === $closes) {
|
||||
continue;
|
||||
}
|
||||
foreach ([$opens, $closes] as $time) {
|
||||
if (1 !== preg_match('/^([01]\d|2[0-3]):[0-5]\d$/D', $time)) {
|
||||
throw new InvalidPage(sprintf('Die Öffnungszeit für %s braucht Öffnen und Schließen als Uhrzeit wie 07:30.', $label));
|
||||
}
|
||||
}
|
||||
if ($opens >= $closes) {
|
||||
throw new InvalidPage(sprintf('%s: die Schließzeit muss nach der Öffnungszeit liegen.', $label));
|
||||
}
|
||||
$hours[] = ['day' => $day, 'opens' => $opens, 'closes' => $closes];
|
||||
}
|
||||
|
||||
return $hours;
|
||||
}
|
||||
|
||||
/** Das Einsatzgebiet: ein Ortsname je Zeile, höchstens vierzig. */
|
||||
private function areaServed(string $input): array
|
||||
{
|
||||
$places = [];
|
||||
foreach (preg_split('/[\r\n,]+/', $input) ?: [] as $place) {
|
||||
$place = trim($place);
|
||||
if ('' === $place) {
|
||||
continue;
|
||||
}
|
||||
if (mb_strlen($place) > 80 || 1 === preg_match('/[<>"]/', $place)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist kein Ortsname.', mb_substr($place, 0, 40)));
|
||||
}
|
||||
$places[] = $place;
|
||||
}
|
||||
if (count($places) > 40) {
|
||||
throw new InvalidPage('Mehr als vierzig Orte im Einsatzgebiet sind nicht vorgesehen.');
|
||||
}
|
||||
|
||||
return array_values(array_unique($places));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembered for the length of one request.
|
||||
*
|
||||
@@ -137,6 +213,14 @@ final class SiteSettings
|
||||
}
|
||||
$values['sameAs'] = array_slice(array_unique($values['sameAs']), 0, 20);
|
||||
$values['social'] = SocialProfiles::clean($input['social'] ?? []);
|
||||
// Zwei Formen kommen hier an: die des Formulars (hours[Monday][opens], Orte als Text) und
|
||||
// die gespeicherte (openingHours als Liste, areaServed als Liste), wenn ein anderer Bildschirm
|
||||
// die geladenen Werte unverändert zurückgibt. Beide müssen dasselbe ergeben, sonst löscht
|
||||
// das Anlegen der Rechtsseiten die Öffnungszeiten.
|
||||
$values['openingHours'] = $this->openingHours($input['hours'] ?? $input['openingHours'] ?? []);
|
||||
$areaInput = $input['areaServed'] ?? '';
|
||||
$values['areaServed'] = $this->areaServed(is_array($areaInput) ? implode('
|
||||
', array_map('strval', $areaInput)) : (string) $areaInput);
|
||||
$values['localBusiness'] = (bool) ($input['localBusiness'] ?? false);
|
||||
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
|
||||
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
|
||||
@@ -227,6 +311,16 @@ final class SiteSettings
|
||||
array_map(static fn (mixed $group): string => (string) $group, (array) ($input['consentGroups'] ?? [])),
|
||||
));
|
||||
$values['consentText'] = mb_substr(trim((string) ($input['consentText'] ?? '')), 0, 600);
|
||||
$values['consentLayout'] = in_array($input['consentLayout'] ?? '', ['karte', 'leiste', 'ecke'], true) ? $input['consentLayout'] : 'karte';
|
||||
$values['consentShape'] = in_array($input['consentShape'] ?? '', ['weich', 'rund', 'eckig'], true) ? $input['consentShape'] : 'weich';
|
||||
$values['consentTitle'] = mb_substr(trim((string) ($input['consentTitle'] ?? '')), 0, 80);
|
||||
foreach (['consentColor', 'consentInk', 'consentAccent', 'consentAccentInk'] as $feld) {
|
||||
$farbe = trim((string) ($input[$feld] ?? ''));
|
||||
if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) {
|
||||
throw new InvalidPage('Die Farben des Einwilligungshinweises werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.');
|
||||
}
|
||||
$values[$feld] = $farbe;
|
||||
}
|
||||
foreach (['privacyPath', 'imprintPath'] as $key) {
|
||||
$path = trim(trim((string) ($input[$key] ?? '')), '/');
|
||||
if ('' !== $path && 1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $path)) {
|
||||
@@ -363,6 +457,20 @@ final class SiteSettings
|
||||
if ([] !== $sameAs) {
|
||||
$data['sameAs'] = $sameAs;
|
||||
}
|
||||
// Tage mit denselben Zeiten werden zu einer Angabe: Mo-Fr 07:30-17:00 ist eine Zeile,
|
||||
// nicht fünf. Das ist die Form, die Google für openingHoursSpecification dokumentiert.
|
||||
$gruppen = [];
|
||||
foreach ($settings['openingHours'] ?? [] as $eintrag) {
|
||||
$schluessel = $eintrag['opens'].'-'.$eintrag['closes'];
|
||||
$gruppen[$schluessel] ??= ['@type' => 'OpeningHoursSpecification', 'dayOfWeek' => [], 'opens' => $eintrag['opens'], 'closes' => $eintrag['closes']];
|
||||
$gruppen[$schluessel]['dayOfWeek'][] = $eintrag['day'];
|
||||
}
|
||||
if ([] !== $gruppen) {
|
||||
$data['openingHoursSpecification'] = array_values($gruppen);
|
||||
}
|
||||
if ([] !== ($settings['areaServed'] ?? [])) {
|
||||
$data['areaServed'] = $settings['areaServed'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -85,12 +85,20 @@ final class Statistics
|
||||
/**
|
||||
* The numbers for a range of days, ending today.
|
||||
*
|
||||
* Neben den Reihen ein paar Summen, die eine Zahl einordnen: der gleich lange Zeitraum davor,
|
||||
* der staerkste Tag, die Wochentage im Schnitt, wie viel ueber Verweise kam und wie oft eine
|
||||
* Adresse ins Leere lief. Alles aus derselben Tabelle, nichts davon braucht ein Merkmal, das
|
||||
* eine Person wiedererkennbar macht.
|
||||
*
|
||||
* @return array{
|
||||
* days: list<array{day: string, count: int}>,
|
||||
* total: int, pages: list<array{path: string, count: int}>,
|
||||
* total: int, previousTotal: int, busiestDay: ?string,
|
||||
* pages: list<array{path: string, count: int}>,
|
||||
* referrers: list<array{host: string, count: int}>,
|
||||
* missing: list<array{path: string, count: int}>,
|
||||
* hours: list<array{hour: int, count: int}>,
|
||||
* weekdays: list<array{weekday: int, count: int, average: float}>,
|
||||
* missingTotal: int, referred: int, pageCount: int,
|
||||
* range: int, from: string, compactedBefore: string, busiest: int
|
||||
* }
|
||||
*/
|
||||
@@ -132,10 +140,40 @@ final class Statistics
|
||||
$hours[(int) $row['hour']] = (int) $row['total'];
|
||||
}
|
||||
|
||||
$sum = fn (string $extra): int => (int) $this->db->fetchOne(
|
||||
'SELECT COALESCE(SUM(count), 0) FROM stat_hit WHERE '.$where.' '.$extra,
|
||||
$params,
|
||||
);
|
||||
// Der gleich lange Zeitraum davor: erst der Vergleich macht aus einer Zahl eine Aussage.
|
||||
$previousTotal = (int) $this->db->fetchOne(
|
||||
'SELECT COALESCE(SUM(count), 0) FROM stat_hit WHERE website_id = ? AND day >= ? AND day < ? AND status = 200',
|
||||
['default', $now->modify(sprintf('-%d days', 2 * $range - 1))->format('Y-m-d'), $from],
|
||||
);
|
||||
$weekdays = array_fill(1, 7, ['count' => 0, 'days' => 0]);
|
||||
$busiestDay = null;
|
||||
foreach ($days as $entry) {
|
||||
$weekday = (int) (new \DateTimeImmutable($entry['day'], new \DateTimeZone('UTC')))->format('N');
|
||||
$weekdays[$weekday]['count'] += $entry['count'];
|
||||
++$weekdays[$weekday]['days'];
|
||||
if ($entry['count'] > 0 && (null === $busiestDay || $entry['count'] > $perDay[$busiestDay])) {
|
||||
$busiestDay = $entry['day'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'days' => $days,
|
||||
'total' => array_sum(array_column($days, 'count')),
|
||||
'previousTotal' => $previousTotal,
|
||||
'busiest' => max(1, max(array_column($days, 'count'))),
|
||||
'busiestDay' => $busiestDay,
|
||||
'weekdays' => array_map(static fn (int $weekday): array => [
|
||||
'weekday' => $weekday,
|
||||
'count' => $weekdays[$weekday]['count'],
|
||||
'average' => $weekdays[$weekday]['days'] > 0 ? round($weekdays[$weekday]['count'] / $weekdays[$weekday]['days'], 1) : 0.0,
|
||||
], range(1, 7)),
|
||||
'missingTotal' => $sum('AND status = 404'),
|
||||
'referred' => $sum("AND status = 200 AND referrer_host <> ''"),
|
||||
'pageCount' => (int) $this->db->fetchOne('SELECT COUNT(DISTINCT path) FROM stat_hit WHERE '.$where.' AND status = 200', $params),
|
||||
'pages' => $rows('path', 'AND status = 200', 'path'),
|
||||
'referrers' => $rows('referrer_host', "AND status = 200 AND referrer_host <> ''", 'referrer_host'),
|
||||
'missing' => $rows('path', 'AND status = 404', 'path'),
|
||||
|
||||
@@ -92,6 +92,11 @@ final class StyleCompiler
|
||||
sprintf('"%s" is outside its range.', $key),
|
||||
),
|
||||
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
|
||||
// A share of nothing is a column that cannot be seen, so zero is refused with the rest.
|
||||
'fractions' => $this->require(
|
||||
1 === preg_match(StyleSchema::FRACTIONS, $text) && !in_array(0.0, array_map(floatval(...), explode(' ', $text)), true),
|
||||
sprintf('"%s" is not a list of column shares.', $key),
|
||||
),
|
||||
'media' => $this->require('none' === $text || 1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
default => $this->require(false, 'Unknown style type.'),
|
||||
};
|
||||
@@ -112,6 +117,9 @@ final class StyleCompiler
|
||||
if ('image' === ($block['type'] ?? null)) {
|
||||
$css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent);
|
||||
}
|
||||
if ('row' === ($block['type'] ?? null)) {
|
||||
$css .= $this->columnTracks((string) $block['id'], $block['style'], is_array($block['layout'] ?? null) ? $block['layout'] : [], count($block['children'] ?? []));
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? [], $block);
|
||||
}
|
||||
@@ -196,13 +204,13 @@ final class StyleCompiler
|
||||
if ('' !== $burger) {
|
||||
$rules .= sprintf('#%s .menu-burger>summary{%s}', $id, $burger);
|
||||
}
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'self', $device) : '';
|
||||
$hover = is_array($values['hover'] ?? null) ? $this->declarations($this->inheritShadow($values['hover'], $values), 'self', $device) : '';
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
}
|
||||
// The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved.
|
||||
// The change eases in, so a header does not snap from glass to solid.
|
||||
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($values['scrolled'], 'self', $device) : '';
|
||||
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : '';
|
||||
if ('' !== $scrolled) {
|
||||
$rules .= sprintf('#%s{transition:background-color .3s,color .3s,border-color .3s,box-shadow .3s,opacity .3s}html.is-scrolled #%s{%s}', $id, $id, $scrolled);
|
||||
}
|
||||
@@ -319,6 +327,26 @@ final class StyleCompiler
|
||||
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Zustand, der nur die Schattenfarbe aendert, erbt Versatz, Weichzeichnung und Ausdehnung
|
||||
* vom Grundzustand - sonst hiesse er `box-shadow:0 0 0 0 <farbe>` und waere unsichtbar.
|
||||
*/
|
||||
private function inheritShadow(array $state, array $base): array
|
||||
{
|
||||
foreach (['shadow' => ['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowInset'], 'textShadow' => ['textShadowX', 'textShadowY', 'textShadowBlur']] as $colour => $parts) {
|
||||
if (!isset($state[$colour.'Color']) && [] === array_intersect_key($state, array_flip($parts))) {
|
||||
continue;
|
||||
}
|
||||
foreach ($parts as $part) {
|
||||
if (!isset($state[$part]) && isset($base[$part])) {
|
||||
$state[$part] = $base[$part];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function shadow(array $values): string
|
||||
{
|
||||
$parts = array_intersect_key($values, array_flip(['shadowX', 'shadowY', 'shadowBlur', 'shadowSpread', 'shadowColor']));
|
||||
@@ -461,6 +489,55 @@ final class StyleCompiler
|
||||
return $chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The column shares of a row: "2 1" becomes two tracks of 2fr and 1fr. Shares inherit from the
|
||||
* wider device like every other style. A device whose layout stacks the columns - "one" from
|
||||
* the classic tokens or a single grid column - gets one track instead: the desktop rule carries
|
||||
* an id and would otherwise outrank the class that stacks the columns on a phone.
|
||||
*/
|
||||
private function columnTracks(string $id, array $style, array $layout, int $count): string
|
||||
{
|
||||
$css = $previous = '';
|
||||
$widths = null;
|
||||
$stacked = false;
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = is_array($style[$device] ?? null) ? $style[$device] : [];
|
||||
$widths = $values['columnWidths'] ?? $widths;
|
||||
if (isset($values['gridColumns'])) {
|
||||
$stacked = 1 === (int) $values['gridColumns'];
|
||||
} elseif (isset($layout['design'][$device]['columns'])) {
|
||||
$stacked = 'one' === $layout['design'][$device]['columns'];
|
||||
}
|
||||
if (null === $widths || $count < 2) {
|
||||
continue;
|
||||
}
|
||||
$rule = sprintf('#%s{grid-template-columns:%s}', $id, $stacked ? 'minmax(0,1fr)' : $this->tracks((string) $widths, $count));
|
||||
if ($rule === $previous) {
|
||||
continue;
|
||||
}
|
||||
$css .= isset(self::BREAKPOINTS[$device])
|
||||
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rule)
|
||||
: $rule;
|
||||
$previous = $rule;
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/** One track per column: a column added later gets the average share, surplus shares are dropped. */
|
||||
private function tracks(string $widths, int $count): string
|
||||
{
|
||||
$shares = array_values(array_filter(array_map(floatval(...), explode(' ', $widths)), static fn (float $share): bool => $share > 0));
|
||||
$shares = array_slice($shares, 0, $count);
|
||||
$average = [] === $shares ? 1.0 : array_sum($shares) / count($shares);
|
||||
while (count($shares) < $count) {
|
||||
$shares[] = $average;
|
||||
}
|
||||
$format = static fn (float $share): string => rtrim(rtrim(number_format($share, 1, '.', ''), '0'), '.');
|
||||
|
||||
return implode(' ', array_map(static fn (float $share): string => sprintf('minmax(0,%sfr)', $format($share)), $shares));
|
||||
}
|
||||
|
||||
private function grid(array $values): string
|
||||
{
|
||||
return isset($values['gridColumns'])
|
||||
|
||||
@@ -27,6 +27,8 @@ final class StyleSchema
|
||||
*/
|
||||
public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D';
|
||||
public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D';
|
||||
/** Column shares of a row, up to twelve numbers separated by blanks: "2 1" or "66.7 33.3". */
|
||||
public const FRACTIONS = '/^\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}$/D';
|
||||
|
||||
/**
|
||||
* group – where the property appears in the editor
|
||||
@@ -152,6 +154,9 @@ final class StyleSchema
|
||||
'stretch' => 'Dehnen', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'baseline' => 'Grundlinie',
|
||||
]],
|
||||
'gridColumns' => ['group' => 'layout', 'type' => 'number', 'css' => null, 'label' => 'Spalten', 'min' => 1, 'max' => 12, 'step' => 1],
|
||||
// Die Anteile der Spalten einer Zeile: "2 1" ist zwei Drittel zu einem Drittel. Gesetzt wird
|
||||
// das vor allem durch Ziehen der Spaltengrenze in der Vorschau des Editors.
|
||||
'columnWidths' => ['group' => 'layout', 'type' => 'fractions', 'css' => null, 'label' => 'Spaltenbreiten'],
|
||||
'gap' => ['group' => 'layout', 'type' => 'length', 'css' => 'gap', 'label' => 'Abstand zwischen Elementen'],
|
||||
'order' => ['group' => 'layout', 'type' => 'number', 'css' => 'order', 'label' => 'Reihenfolge', 'min' => -20, 'max' => 20, 'step' => 1],
|
||||
'rowGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'row-gap', 'label' => 'Abstand zwischen Zeilen'],
|
||||
|
||||
+68
-16
@@ -25,6 +25,25 @@ final class Theme
|
||||
'system-ui' => 'Systemschrift',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fertige Paletten zum Übernehmen. Dieselben fünf Schlüssel wie die Voreinstellung, damit
|
||||
* Grundfarben und Blöcke, die auf die Schlüssel ink oder link zeigen, stimmen nach dem Wechsel weiter.
|
||||
* Die Linkfarbe erreicht auf dem Papierton jeweils mindestens 4,5:1 - eine Palette, die
|
||||
* ihre eigenen Links unlesbar macht, wäre keine Hilfe.
|
||||
*/
|
||||
public const PALETTES = [
|
||||
['name' => 'Wald', 'colors' => ['paper' => '#f6f7f4', 'ink' => '#203b34', 'mint' => '#e4efb9', 'accent' => '#b55b12', 'link' => '#a4510f']],
|
||||
['name' => 'Ozean', 'colors' => ['paper' => '#f5f8fa', 'ink' => '#12283a', 'mint' => '#d6ecf3', 'accent' => '#0e7490', 'link' => '#0b5f78']],
|
||||
['name' => 'Sand', 'colors' => ['paper' => '#faf6ef', 'ink' => '#3b2f24', 'mint' => '#f1e4cf', 'accent' => '#b8672a', 'link' => '#9a5320']],
|
||||
['name' => 'Koralle', 'colors' => ['paper' => '#fffaf8', 'ink' => '#2b2320', 'mint' => '#ffe4dc', 'accent' => '#d9483b', 'link' => '#b83a2f']],
|
||||
['name' => 'Schiefer', 'colors' => ['paper' => '#f4f5f7', 'ink' => '#1f2937', 'mint' => '#e2e8f0', 'accent' => '#4f46e5', 'link' => '#4338ca']],
|
||||
['name' => 'Olive', 'colors' => ['paper' => '#f7f7f2', 'ink' => '#2a2f23', 'mint' => '#e6ead3', 'accent' => '#6b7a2e', 'link' => '#55621f']],
|
||||
['name' => 'Nacht', 'colors' => ['paper' => '#14181d', 'ink' => '#eef1f4', 'mint' => '#262e38', 'accent' => '#7cc4ff', 'link' => '#9ad2ff']],
|
||||
];
|
||||
|
||||
/** Die Namen der Palettenfarben, wie sie in einer neuen Palette heißen. */
|
||||
public const COLOR_NAMES = ['paper' => 'Papier', 'ink' => 'Tinte', 'mint' => 'Mint', 'accent' => 'Akzent', 'link' => 'Links'];
|
||||
|
||||
public const DEFAULTS = [
|
||||
'colors' => [
|
||||
['key' => 'paper', 'name' => 'Papier', 'value' => '#f6f7f4'],
|
||||
@@ -269,6 +288,44 @@ final class Theme
|
||||
}
|
||||
|
||||
/** The stylesheet every page carries: palette variables, font variables and base typography. */
|
||||
/**
|
||||
* Eine Farbe aus den Einstellungen als CSS-Wert: `@schlüssel` wird zur Palettenvariablen,
|
||||
* ein Hexwert bleibt, und leer heißt "nicht gesetzt" - dann gilt der Standard des Stylesheets.
|
||||
* Ein Schlüssel, den die Palette nicht kennt, wird ebenfalls zu null: lieber der Standard als
|
||||
* eine Variable, die auf nichts zeigt.
|
||||
*/
|
||||
public static function cssColor(string $value, array $theme): ?string
|
||||
{
|
||||
if ('' === $value) {
|
||||
return null;
|
||||
}
|
||||
if (!str_starts_with($value, '@')) {
|
||||
return $value;
|
||||
}
|
||||
foreach ($theme['colors'] as $color) {
|
||||
if ($color['key'] === substr($value, 1)) {
|
||||
return sprintf('var(--k-%s)', $color['key']);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Die relative Leuchtdichte einer Hexfarbe nach WCAG; 0 fuer Schwarz, 1 fuer Weiss, 0.5 wenn unlesbar. */
|
||||
public static function luminance(string $hex): float
|
||||
{
|
||||
if (1 !== preg_match('/^#([0-9a-f]{6})/i', $hex, $m)) {
|
||||
return 0.5;
|
||||
}
|
||||
$kanal = static function (int $wert): float {
|
||||
$c = $wert / 255;
|
||||
|
||||
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
return 0.2126 * $kanal(hexdec(substr($m[1], 0, 2))) + 0.7152 * $kanal(hexdec(substr($m[1], 2, 2))) + 0.0722 * $kanal(hexdec(substr($m[1], 4, 2)));
|
||||
}
|
||||
|
||||
public function css(array $theme): string
|
||||
{
|
||||
$variables = [];
|
||||
@@ -302,6 +359,17 @@ final class Theme
|
||||
$variables[] = sprintf('--muted:var(--k-muted,color-mix(in srgb,%s 80%%,%s))', $ink, $paper);
|
||||
$variables[] = sprintf('--line:var(--k-line,color-mix(in srgb,%s 20%%,%s))', $ink, $paper);
|
||||
$variables[] = sprintf('--mint:var(--k-mint,color-mix(in srgb,%s 14%%,%s))', $colour($base['linkColor']), $paper);
|
||||
// Dunkle Palette: Formularfelder, Rollbalken und Fehlermeldungen folgen ihr. Das Rot fuer
|
||||
// Fehler war fest auf Weiss gedacht; auf dunklem Grund ist es unlesbar. Also je nach
|
||||
// Helligkeit des Papiers ein Rot, das dort steht - und der Hintergrund der Meldung aus
|
||||
// Rot und Papier gemischt statt aus einem festen Hellrosa.
|
||||
// Fuer die Leuchtdichte braucht es den Hexwert, nicht den Verweis var(--k-...).
|
||||
$hexwert = static fn (string $wert): string => str_starts_with($wert, '@') ? (string) (array_column($theme['colors'], 'value', 'key')[substr($wert, 1)] ?? '#ffffff') : $wert;
|
||||
$dunkel = self::luminance($hexwert($base['backgroundColor'])) < 0.4;
|
||||
$variables[] = '--k-scheme:'.($dunkel ? 'dark' : 'light');
|
||||
$variables[] = '--k-error:'.($dunkel ? '#ff9b8a' : '#8b2f22');
|
||||
$variables[] = sprintf('--k-error-bg:color-mix(in srgb,%s 12%%,%s)', $dunkel ? '#ff9b8a' : '#8b2f22', $hexwert($base['backgroundColor']));
|
||||
$variables[] = sprintf('--k-error-line:color-mix(in srgb,%s 45%%,%s)', $dunkel ? '#ff9b8a' : '#8b2f22', $hexwert($base['backgroundColor']));
|
||||
|
||||
$scale = (float) $base['headingScale'];
|
||||
$headings = '';
|
||||
@@ -328,20 +396,4 @@ final class Theme
|
||||
$headings,
|
||||
).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google Fonts stylesheet for every family that comes from there, or null when none does.
|
||||
* Chosen by the user; it means one request to a third party per page view.
|
||||
*/
|
||||
public function googleFontsUrl(array $theme): ?string
|
||||
{
|
||||
$families = [];
|
||||
foreach ($theme['fonts'] as $font) {
|
||||
if ('google' === $font['source'] && '' !== $font['family']) {
|
||||
$families[] = sprintf('family=%s:wght@%s', str_replace(' ', '+', $font['family']), implode(';', $font['weights']));
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $families ? null : 'https://fonts.googleapis.com/css2?'.implode('&', array_unique($families)).'&display=swap';
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -19,7 +20,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class ConsentController extends AbstractController
|
||||
{
|
||||
public function __construct(private Consent $consent, private SiteSettings $settings)
|
||||
public function __construct(private Consent $consent, private SiteSettings $settings, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -33,11 +34,33 @@ final class ConsentController extends AbstractController
|
||||
default => array_map(static fn (mixed $group): string => (string) $group, (array) $request->request->all('groups')),
|
||||
};
|
||||
$response = $this->redirect($this->backTo($request));
|
||||
$this->consent->remember($request, $response, $groups);
|
||||
// Eine Entscheidung trifft nur der eigene Browser auf dieser Website. Ein fremdes Formular,
|
||||
// das hierher postet, darf keine Einwilligung eintragen - die Route hat keine Sitzung und
|
||||
// damit kein CSRF-Token, also entscheidet die Herkunft der Anfrage.
|
||||
if ($this->sameSite($request)) {
|
||||
$this->consent->remember($request, $response, $groups);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** Ob die Anfrage von dieser Website kommt - nach dem, was der Browser darueber sagt. */
|
||||
private function sameSite(Request $request): bool
|
||||
{
|
||||
$site = $request->headers->get('Sec-Fetch-Site');
|
||||
if (null !== $site) {
|
||||
return \in_array($site, ['same-origin', 'same-site', 'none'], true);
|
||||
}
|
||||
$origin = $request->headers->get('Origin') ?? $request->headers->get('Referer');
|
||||
if (null === $origin || '' === $origin) {
|
||||
// Aeltere Browser nennen keine Herkunft; ohne Anhaltspunkt bleibt die Frage beantwortbar.
|
||||
return true;
|
||||
}
|
||||
$host = parse_url($origin, PHP_URL_HOST);
|
||||
|
||||
return \is_string($host) && strtolower($host) === strtolower($request->getHost());
|
||||
}
|
||||
|
||||
#[Route('/einwilligung', name: 'consent_page', methods: ['GET'])]
|
||||
public function page(Request $request): Response
|
||||
{
|
||||
@@ -46,16 +69,21 @@ final class ConsentController extends AbstractController
|
||||
}
|
||||
// Deliberately without header and footer: this is a page about a decision, not a page of
|
||||
// the website, and it must work even when an area is broken.
|
||||
// Palette und Hinweisfarben wie auf den Seiten, damit die Entscheidung hier aussieht wie
|
||||
// die Frage dort. Das Stylesheet ist ueber seinen Hash erlaubt, nichts weiter.
|
||||
$theme = $this->theme->load();
|
||||
$styles = $this->theme->css($theme).$this->consent->styles($theme);
|
||||
$response = $this->render('consent.html.twig', [
|
||||
'consent' => $this->consent->state($request),
|
||||
'settings' => $this->settings->load(),
|
||||
'pageStyles' => $styles,
|
||||
]);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
// The page carries no embeds and no own styles, so it keeps the strictest policy.
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// The page carries no embeds, so it keeps the strictest policy - plus the one hashed style block.
|
||||
$response->headers->set('Content-Security-Policy', sprintf("default-src 'self'; script-src 'none'; style-src 'self' 'sha256-%s'; img-src 'self' https: data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", base64_encode(hash('sha256', $styles, true))));
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -67,7 +95,9 @@ final class ConsentController extends AbstractController
|
||||
private function backTo(Request $request): string
|
||||
{
|
||||
$target = $request->request->getString('back');
|
||||
if ('' === $target || !str_starts_with($target, '/') || str_starts_with($target, '//')) {
|
||||
// Nur ein Pfad dieser Website: `//fremd.example` ist protokollrelativ, und `/\fremd.example`
|
||||
// lesen Browser genauso.
|
||||
if ('' === $target || 1 !== preg_match('#^/(?![/\\\\])#', $target)) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Controller;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
@@ -17,6 +19,7 @@ use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -28,7 +31,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/%kant.admin_path%')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -285,6 +288,11 @@ final class EditorController extends AbstractController
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
// Dieselbe Regel wie beim Speichern: Skripte aendert nur ein Administrator - auch nicht
|
||||
// ueber den Umweg einer alten Fassung.
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||||
}
|
||||
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
@@ -413,7 +421,7 @@ final class EditorController extends AbstractController
|
||||
if (!is_array($document)) {
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
$this->validator->validate($document);
|
||||
$this->validator->validate($document, writing: true);
|
||||
|
||||
$theme = $this->theme->load();
|
||||
$areas = [];
|
||||
@@ -430,12 +438,38 @@ final class EditorController extends AbstractController
|
||||
$sections[$slug] = $section['document'];
|
||||
}
|
||||
}
|
||||
// Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen.
|
||||
$seen = [];
|
||||
$pristine = $sections;
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
if (isset($areas[$kind])) {
|
||||
$areas[$kind] = $this->partials->expandRepeatedLinks($areas[$kind], $sections, $seen, $pristine);
|
||||
}
|
||||
if ('header' === $kind) {
|
||||
$document = $this->partials->expandRepeatedLinks($document, $sections, $seen, $pristine);
|
||||
}
|
||||
}
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme)]);
|
||||
// Die Vorschau zeigt dieselbe Ausgabe wie die Website - einschliesslich eigenem
|
||||
// CSS, Bereichs- und Sektionsstilen und der Formulare mit ihren Farben. Vorher
|
||||
// fehlte all das, und "sieht in der Vorschau anders aus" war immer wahr.
|
||||
$documents = [$document, ...array_values($areas), ...array_values($sections)];
|
||||
$styles = $this->theme->css($theme);
|
||||
foreach ($documents as $doc) {
|
||||
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
|
||||
}
|
||||
$forms = $this->previewForms($documents, $page);
|
||||
foreach ($forms as $state) {
|
||||
if (null !== $state['form']) {
|
||||
$styles .= $this->forms->css($state['form'], $theme);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
@@ -456,7 +490,9 @@ final class EditorController extends AbstractController
|
||||
$this->code->assertScriptsUnchanged($body['document'], $page['document']);
|
||||
}
|
||||
$page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']);
|
||||
$page['document'] = $body['document'];
|
||||
// Die gespeicherte Fassung, nicht die geschickte: Rich Text wird beim Speichern
|
||||
// bereinigt, und der Editor soll das sofort zeigen statt erst nach dem Neuladen.
|
||||
$page['document'] = $this->pages->findForEditor($id)['document'] ?? $body['document'];
|
||||
}
|
||||
$page['version'] = $body['expectedVersion'] + 1;
|
||||
|
||||
@@ -470,6 +506,33 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Formulare der Vorschau: Definition und Optionsbilder, ohne Zeitstempel - in der Vorschau
|
||||
* wird nichts abgeschickt.
|
||||
*
|
||||
* @param list<array> $documents
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function previewForms(array $documents, array $page): array
|
||||
{
|
||||
$states = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||||
$states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
private function page(string $id): array
|
||||
{
|
||||
return $this->pages->findForEditor($id) ?? throw $this->createNotFoundException('Seite nicht gefunden.');
|
||||
@@ -477,6 +540,6 @@ final class EditorController extends AbstractController
|
||||
|
||||
private function payload(array $page): array
|
||||
{
|
||||
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue()];
|
||||
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor()];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -12,24 +15,35 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class FormAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private FormRepository $forms)
|
||||
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Empfaenger, Felder und die Einsendungen selbst: nichts davon gehoert in die Hand einer
|
||||
// Rolle, die nicht einmal eine Seite veroeffentlichen darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'forms' => $this->forms->all(),
|
||||
'types' => FormRepository::TYPES,
|
||||
'colors' => FormRepository::COLORS,
|
||||
// Die Bilder fuer eine Bildauswahl: die Mediathek als Liste, damit je Option gewaehlt statt getippt wird.
|
||||
'images' => array_map(static fn (array $item): array => ['id' => $item['id'], 'title' => $item['title'], 'src' => $item['variants'][0]['src'] ?? ''], $this->media->page(1, false, 96)['items']),
|
||||
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
|
||||
// Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und
|
||||
// kennt die Palette, damit eine Farbe gewaehlt statt getippt wird.
|
||||
'schema' => ['colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttons' => $theme['buttons'] ?? ButtonDesign::defaults($theme['base']), 'base' => $theme['base']],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
|
||||
public function save(Request $request, string $slug): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('forms', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -45,6 +59,14 @@ final class FormAdminController extends AbstractController
|
||||
'required' => '' !== (string) ($row['required'] ?? ''),
|
||||
'help' => (string) ($row['help'] ?? ''),
|
||||
'options' => array_map(trim(...), explode(',', (string) ($row['options'] ?? ''))),
|
||||
'images' => (string) ($row['images'] ?? ''),
|
||||
'min' => (string) ($row['min'] ?? ''),
|
||||
'max' => (string) ($row['max'] ?? ''),
|
||||
'unit' => (string) ($row['unit'] ?? ''),
|
||||
// Die Bedingung: technischer Name eines früheren Auswahlfeldes und die Werte, bei
|
||||
// denen dieses Feld gilt, als Kommaliste.
|
||||
'showIfField' => (string) ($row['showIfField'] ?? ''),
|
||||
'showIfValues' => (string) ($row['showIfValues'] ?? ''),
|
||||
];
|
||||
}
|
||||
try {
|
||||
@@ -54,9 +76,13 @@ final class FormAdminController extends AbstractController
|
||||
$fields,
|
||||
[
|
||||
'recipient' => $request->request->getString('recipient'),
|
||||
'sender' => $request->request->getString('sender'),
|
||||
'subject' => $request->request->getString('subject'),
|
||||
'success' => $request->request->getString('success'),
|
||||
'replyToField' => $request->request->getString('replyToField'),
|
||||
'buttonLabel' => $request->request->getString('buttonLabel'),
|
||||
'summary' => '' !== $request->request->getString('summary'),
|
||||
...array_map(fn (string $field): string => $request->request->getString($field), array_combine(array_keys(FormRepository::COLORS), array_keys(FormRepository::COLORS))),
|
||||
],
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
);
|
||||
|
||||
@@ -88,10 +88,20 @@ final class FormController extends AbstractController
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($form['fields'] as $field) {
|
||||
// Ein Schritt ist eine Überschrift in der Post; ein Feld, das nicht galt, bleibt weg.
|
||||
if ('step' === $field['type']) {
|
||||
$lines[] = ([] === $lines ? '' : "\n").'== '.$field['label'].' ==';
|
||||
continue;
|
||||
}
|
||||
if (null !== $field['showIf'] && '' === ($values[$field['name']] ?? '')) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = $field['label'].': '.($values[$field['name']] ?? '');
|
||||
}
|
||||
// Der Absender ist die eigene Adresse des Formulars, sonst der Empfaenger. Viele Hoster
|
||||
// liefern nur aus, was von einer Adresse ihrer eigenen Domain kommt.
|
||||
$email = (new Email())
|
||||
->from($form['settings']['recipient'])
|
||||
->from(($form['settings']['sender'] ?? '') ?: $form['settings']['recipient'])
|
||||
->to($form['settings']['recipient'])
|
||||
->subject($form['settings']['subject'])
|
||||
->text(implode("\n", $lines)."\n\n-- \nGesendet über ".rtrim($this->siteOrigin, '/'));
|
||||
@@ -120,17 +130,10 @@ final class FormController extends AbstractController
|
||||
*/
|
||||
private function renderPage(Request $request, array $page, array $form, array $checked): Response
|
||||
{
|
||||
$zustaende = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$zustaende, $form, $checked): void {
|
||||
foreach ($blocks as $block) {
|
||||
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben.
|
||||
if ('form' === ($block['type'] ?? null) && ($block['props']['form'] ?? '') === $form['slug']) {
|
||||
$zustaende[$block['id']] = ['errors' => $checked['errors'], 'values' => $checked['values'], 'sent' => false];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben - wo auch immer
|
||||
// es auf der Seite steht: im Inhalt, im Kopf, im Fuss oder in einer verknuepften Sektion.
|
||||
// Der Seitencontroller ordnet die Meldungen jedem Block mit dieser Adresse zu.
|
||||
$zustaende = ['slug' => $form['slug'], 'errors' => $checked['errors'], 'values' => $checked['values']];
|
||||
|
||||
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
|
||||
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
|
||||
|
||||
@@ -20,6 +20,8 @@ final class MenuController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Ein gespeichertes Menue steht sofort auf der Website - Sache des Veroeffentlichers.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$slot = $request->query->getString('slot', 'header');
|
||||
$slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header';
|
||||
$menu = $this->menus->all()[$slot] ?? null;
|
||||
@@ -43,7 +45,7 @@ final class MenuController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
|
||||
public function save(Request $request, string $slot): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
|
||||
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.'], 403) : $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', slot: $slot);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
@@ -18,6 +19,7 @@ use App\Content\StyleBundle;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Turnstile;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -25,7 +27,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -47,6 +49,11 @@ final class PageController extends AbstractController
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
if (null === $page) {
|
||||
// Eine Seite geht vor; eine eigene Adresse eines Plugins kommt vor Weiterleitung und 404.
|
||||
$route = $this->plugins->route('/'.$path);
|
||||
if (null !== $route) {
|
||||
return $this->pluginRoute($request, $route);
|
||||
}
|
||||
$redirect = $this->redirects->find($path);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
@@ -61,6 +68,27 @@ final class PageController extends AbstractController
|
||||
return $this->renderPage($request, $page, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die
|
||||
* Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript.
|
||||
*/
|
||||
private function pluginRoute(Request $request, array $route): Response
|
||||
{
|
||||
$query = [];
|
||||
foreach ($request->query->all() as $key => $value) {
|
||||
if (is_string($value) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,40}$/D', (string) $key)) {
|
||||
$query[$key] = mb_substr($value, 0, 500);
|
||||
}
|
||||
}
|
||||
$response = $this->render($route['template'], ['settings' => $this->plugins->settingsOf($route['plugin']), 'query' => $query, 'route' => $route]);
|
||||
$response->headers->set('Content-Type', $route['contentType']);
|
||||
if ('html' !== $route['type']) {
|
||||
$response->headers->set('X-Robots-Tag', 'noindex');
|
||||
}
|
||||
|
||||
return $this->shell($response);
|
||||
}
|
||||
|
||||
public function notFound(Request $request, int $status = 404): Response
|
||||
{
|
||||
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
|
||||
@@ -70,43 +98,95 @@ final class PageController extends AbstractController
|
||||
private function renderPage(Request $request, array $page, string $path, int $status = 200): Response
|
||||
{
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($status >= 400 ? null : $page);
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
// Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen
|
||||
// Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß.
|
||||
$pristine = $sections;
|
||||
$seen = [];
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
if ('header' === $kind && isset($areas['header'])) {
|
||||
$areas['header'] = $this->partials->expandRepeatedLinks($areas['header'], $sections, $seen, $pristine);
|
||||
}
|
||||
if ('header' === $kind) {
|
||||
$page['document'] = $this->partials->expandRepeatedLinks($page['document'], $sections, $seen, $pristine);
|
||||
}
|
||||
if ('footer' === $kind && isset($areas['footer'])) {
|
||||
$areas['footer'] = $this->partials->expandRepeatedLinks($areas['footer'], $sections, $seen, $pristine);
|
||||
}
|
||||
}
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
// Tabs und vergroesserbare Bilder koennen auch im Kopf, im Fuss oder in einer verknuepften
|
||||
// Sektion stehen - das Skript und die Richtlinie dafuer richten sich nach allen Dokumenten.
|
||||
$needsTabs = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'tabs'));
|
||||
// Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt.
|
||||
$pluginAssets = $this->plugins->assetsFor($documents);
|
||||
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
|
||||
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
|
||||
// for the small script that marks the page as scrolled.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
// Die Farben jedes Formulars auf der Seite - nach dem Theme, damit sie es uebersteuern.
|
||||
$forms = $this->formStates($page, $documents, $request->query->getString('gesendet'), $request);
|
||||
// Schritte, Bedingungen und Zusammenfassung brauchen das eine Formularskript - nur dort,
|
||||
// wo ein solches Formular steht.
|
||||
$needsFormScript = (bool) array_filter($forms, fn (array $state): bool => null !== $state['form'] && $this->forms->needsScript($state['form']));
|
||||
$styled = [];
|
||||
foreach ($forms as $state) {
|
||||
if (null !== $state['form'] && !isset($styled[$state['form']['slug']])) {
|
||||
$styled[$state['form']['slug']] = true;
|
||||
$styles .= $this->forms->css($state['form'], $theme);
|
||||
}
|
||||
}
|
||||
// Schriften kommen ausschliesslich vom eigenen Server. Fehlen die Dateien, zeigt die
|
||||
// Website die Ersatzschrift des Themes - nie ein Stylesheet von fonts.googleapis.com:
|
||||
// das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den
|
||||
// Seitenaufbau an zwei fremde Verbindungen nacheinander.
|
||||
$localFonts = $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [];
|
||||
$areaScripts = [];
|
||||
foreach ($areas as $kind => $area) {
|
||||
if (isset($this->areaIds[$kind]) && [] !== $this->code->scriptsOf($area)) {
|
||||
$areaScripts[] = '/assets/bereich/'.$this->areaIds[$kind].'.js';
|
||||
}
|
||||
}
|
||||
$postLists = $this->postLists($documents, max(1, $request->query->getInt('seite', 1)));
|
||||
// Eine blätternde Liste macht die Seite zu einer Folge von Seiten: die Canonical nennt die
|
||||
// Seitenzahl, prev und next verbinden die Folge - sonst hielte eine Suchmaschine Seite 3
|
||||
// für ein Duplikat von Seite 1.
|
||||
$blaettern = null;
|
||||
foreach ($postLists as $liste) {
|
||||
if (isset($liste['pages']) && $liste['pages'] > 1) {
|
||||
$blaettern = $liste;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath());
|
||||
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
|
||||
'canonical' => $canonical,
|
||||
'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse],
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
'postLists' => $postLists,
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
'needsTabs' => $needsTabs,
|
||||
'needsFormScript' => $needsFormScript,
|
||||
'pluginStyles' => $pluginAssets['styles'],
|
||||
'pluginScripts' => $pluginAssets['scripts'],
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
@@ -114,11 +194,12 @@ final class PageController extends AbstractController
|
||||
'needsScrolled' => $needsScrolled,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||||
'needsZoom' => $needsZoom,
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'areaScripts' => $areaScripts,
|
||||
'forms' => $forms,
|
||||
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $status >= 400 ? [] : $this->structuredData($page),
|
||||
@@ -133,15 +214,14 @@ final class PageController extends AbstractController
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
], new Response(status: $status));
|
||||
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $needsScrolled || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
@@ -151,7 +231,7 @@ final class PageController extends AbstractController
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||
return $this->shell($response, $wantsScript, $styles, $frames);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,24 +241,20 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* The generated stylesheet is inline for speed, so it is allowed by its own hash rather than
|
||||
* by 'unsafe-inline' – any other inline style, injected or not, stays blocked.
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
|
||||
* Nothing is added to the policy for an embed still waiting
|
||||
* for consent.
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', bool $googleFonts = false, array $frameHosts = []): Response
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
$styleSources[] = sprintf("'sha256-%s'", base64_encode(hash('sha256', $styles, true)));
|
||||
}
|
||||
// Stylesheets und Schriftdateien kommen nur von hier. Kein fonts.googleapis.com, kein
|
||||
// fonts.gstatic.com - die Regel steht in der Kopfzeile und nicht nur in der Vorlage.
|
||||
$fontSources = ["'self'"];
|
||||
if ($googleFonts) {
|
||||
// Chosen deliberately: the stylesheet comes from Google and the files from its CDN.
|
||||
$styleSources[] = 'https://fonts.googleapis.com';
|
||||
$fontSources[] = 'https://fonts.gstatic.com';
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
@@ -204,13 +280,9 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares one state per form block: the definition, whether this request just sent it, and a
|
||||
* signed timestamp the submission route uses to tell a person from a script.
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
/**
|
||||
* Der Zustand jedes Formulars auf dieser Seite.
|
||||
* Der Zustand jedes Formulars auf dieser Seite: die Definition, ob diese Anfrage es gerade
|
||||
* abgeschickt hat, und ein signierter Zeitstempel, an dem der Versand einen Menschen von
|
||||
* einem Skript unterscheidet.
|
||||
*
|
||||
* Kommt die Anfrage aus dem Formularversand zurueck, weil eine Eingabe fehlte, dann stehen die
|
||||
* Meldungen und die eingetippten Werte schon fest und werden hier uebernommen. Genau deshalb
|
||||
@@ -218,7 +290,7 @@ final class PageController extends AbstractController
|
||||
* Formularcode die Seite selbst noch einmal auf - ohne Theme, ohne Schriften, ohne
|
||||
* Stylesheet-Buendel. Wer ein Pflichtfeld vergass, bekam eine Seite ohne Gestaltung zu sehen.
|
||||
*/
|
||||
private function formStates(array $page, string $sentSlug, ?Request $request = null): array
|
||||
private function formStates(array $page, array $documents, string $sentSlug, ?Request $request = null): array
|
||||
{
|
||||
$issued = time();
|
||||
$states = [];
|
||||
@@ -227,8 +299,11 @@ final class PageController extends AbstractController
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
$slug = $block['props']['form'] ?? '';
|
||||
$form = $this->forms->findBySlug($slug);
|
||||
$states[$block['id']] = [
|
||||
'form' => $this->forms->findBySlug($slug),
|
||||
'form' => $form,
|
||||
// Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt.
|
||||
'optionImages' => null === $form ? [] : $this->optionImages($form),
|
||||
'sent' => '' !== $sentSlug && $sentSlug === $slug,
|
||||
'errors' => [], 'values' => [],
|
||||
'blockId' => $block['id'], 'page' => $page,
|
||||
@@ -241,27 +316,89 @@ final class PageController extends AbstractController
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
foreach ($uebergeben as $blockId => $zustand) {
|
||||
if (isset($states[$blockId])) {
|
||||
$states[$blockId] = [...$states[$blockId], ...$zustand];
|
||||
// Formulare stehen auch im Kopf, im Fuss oder in einer verknuepften Sektion - ein
|
||||
// Kontaktformular im Fuss jeder Seite ist der haeufigste Fall.
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
// Die Meldungen aus dem Formularversand gelten jedem Block, der dieses Formular zeigt.
|
||||
if (isset($uebergeben['slug'])) {
|
||||
foreach ($states as $blockId => $state) {
|
||||
if (($state['form']['slug'] ?? null) === $uebergeben['slug']) {
|
||||
$states[$blockId] = [...$state, 'errors' => $uebergeben['errors'], 'values' => $uebergeben['values'], 'sent' => false];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Bilder der Optionen eines Formulars: Kennung => kleinste Fassung mit Massen. Ein Bild,
|
||||
* das die Mediathek nicht mehr kennt, fehlt hier - die Option zeigt dann ihren Text.
|
||||
*
|
||||
* @return array<string, array{src: string, width: int, height: int}>
|
||||
*/
|
||||
private function optionImages(array $form): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($form['fields'] as $field) {
|
||||
$ids = [...$ids, ...array_values($field['images'] ?? [])];
|
||||
}
|
||||
$images = [];
|
||||
foreach ($this->media->findMany($ids) as $id => $item) {
|
||||
$variant = $item['variants'][0] ?? null;
|
||||
if (null !== $variant) {
|
||||
$images[$id] = ['src' => $variant['src'], 'width' => (int) $variant['width'], 'height' => (int) $variant['height']];
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trail from the home page down to this one, resolved from the stored hierarchy rather
|
||||
* than from content, so it cannot claim a page that does not exist.
|
||||
*
|
||||
* @return list<array{name: string, href: string}>
|
||||
*/
|
||||
/** @return list<array<string, mixed>> */
|
||||
private function faqEntries(array $blocks): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ($blocks as $block) {
|
||||
if ('accordion' === ($block['type'] ?? null) && true === ($block['props']['faq'] ?? false)) {
|
||||
foreach ($block['children'] ?? [] as $item) {
|
||||
$texts = array_filter($item['children'] ?? [], static fn (array $child): bool => 'text' === ($child['type'] ?? null));
|
||||
$answer = trim(implode("\n\n", array_map(
|
||||
static fn (array $child): string => trim(html_entity_decode(strip_tags(preg_replace('#<br\s*/?>|</p>#i', "\n", (string) ($child['props']['text'] ?? '')) ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8')),
|
||||
$texts,
|
||||
)));
|
||||
if ('' !== $answer && '' !== trim((string) ($item['props']['title'] ?? ''))) {
|
||||
$entries[] = ['@type' => 'Question', 'name' => $item['props']['title'], 'acceptedAnswer' => ['@type' => 'Answer', 'text' => $answer]];
|
||||
}
|
||||
}
|
||||
}
|
||||
$entries = [...$entries, ...$this->faqEntries($block['children'] ?? [])];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function breadcrumbs(array $page): array
|
||||
{
|
||||
if ($page['path'] === $this->settings->homePath()) {
|
||||
return [];
|
||||
}
|
||||
// Ein Archiv hat keine Seitenhierarchie: die Spur ist „Beiträge“, dann die Kategorie.
|
||||
if ('archive' === ($page['type'] ?? null)) {
|
||||
$trail = [['name' => 'Beiträge', 'href' => '/beitraege']];
|
||||
if ('beitraege' !== $page['path']) {
|
||||
$trail[] = ['name' => $page['document']['title'], 'href' => '/'.$page['path']];
|
||||
}
|
||||
|
||||
return $trail;
|
||||
}
|
||||
$trail = [];
|
||||
$prefix = '';
|
||||
foreach (explode('/', (string) $page['path']) as $segment) {
|
||||
@@ -293,21 +430,6 @@ final class PageController extends AbstractController
|
||||
if (!($settings['scrollTop'] ?? false)) {
|
||||
return '';
|
||||
}
|
||||
$farbe = static function (string $wert) use ($theme): ?string {
|
||||
if ('' === $wert) {
|
||||
return null;
|
||||
}
|
||||
if (!str_starts_with($wert, '@')) {
|
||||
return $wert;
|
||||
}
|
||||
foreach ($theme['colors'] as $eintrag) {
|
||||
if ($eintrag['key'] === substr($wert, 1)) {
|
||||
return sprintf('var(--k-%s)', $eintrag['key']);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
$werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))];
|
||||
$werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') {
|
||||
'eckig' => '0',
|
||||
@@ -316,7 +438,7 @@ final class PageController extends AbstractController
|
||||
});
|
||||
$werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400));
|
||||
foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) {
|
||||
$wert = $farbe((string) ($settings[$feld] ?? ''));
|
||||
$wert = Theme::cssColor((string) ($settings[$feld] ?? ''), $theme);
|
||||
if (null !== $wert) {
|
||||
$werte[] = $variable.':'.$wert;
|
||||
}
|
||||
@@ -382,6 +504,12 @@ final class PageController extends AbstractController
|
||||
}
|
||||
$blocks[] = $article;
|
||||
}
|
||||
// Ein als FAQ gekennzeichnetes Akkordeon wird zur FAQPage: die Titel sind die Fragen, die
|
||||
// Textbausteine im Eintrag die Antwort. Nur was auf der Seite steht, steht auch hier.
|
||||
$fragen = $this->faqEntries($page['document']['blocks'] ?? []);
|
||||
if ([] !== $fragen) {
|
||||
$blocks[] = ['@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $fragen];
|
||||
}
|
||||
$trail = $this->breadcrumbs($page);
|
||||
if (count($trail) > 1) {
|
||||
$blocks[] = [
|
||||
@@ -489,22 +617,36 @@ final class PageController extends AbstractController
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
$heading = null === $term ? 'Beiträge' : $term['name'];
|
||||
|
||||
$areas = $this->areas();
|
||||
$frames = $this->frameHosts($request, ...array_values($areas));
|
||||
$needsScrolled = isset($areas['header']) && $this->hasScrolled($areas['header']);
|
||||
// Das Archiv ist eine Seite wie jede andere: ein Dokument aus Bausteinen, das durch dieselbe
|
||||
// Aufbaukette läuft - Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, Schriften,
|
||||
// Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine
|
||||
// eigene Vorlage, die von alledem nur die Palette kannte.
|
||||
$kopf = [
|
||||
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
|
||||
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? 'Beitrag' : 'Beiträge')]],
|
||||
];
|
||||
if (null !== $term) {
|
||||
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? 'Kategorie' : 'Schlagwort'], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]);
|
||||
}
|
||||
$document = [
|
||||
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
|
||||
'title' => $heading,
|
||||
'description' => null === $term ? 'Alle Beiträge, die neuesten zuerst.' : sprintf('Beiträge %s „%s“.', 'category' === $kind ? 'in der Kategorie' : 'zum Schlagwort', $term['name']),
|
||||
'blocks' => [
|
||||
['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf],
|
||||
['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [
|
||||
['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true]],
|
||||
]],
|
||||
],
|
||||
];
|
||||
$seite = [
|
||||
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => 'de',
|
||||
'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document,
|
||||
];
|
||||
|
||||
return $this->shell($this->render('archive.html.twig', [
|
||||
'areas' => $areas,
|
||||
'needsScrolled' => $needsScrolled,
|
||||
'navigation' => $this->navigation(''),
|
||||
'term' => $term,
|
||||
'heading' => null === $term ? 'Beiträge' : $term['name'],
|
||||
'result' => $result,
|
||||
'base' => $base,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$base.($result['page'] > 1 ? '?seite='.$result['page'] : ''),
|
||||
'navigation' => $this->navigation(''),
|
||||
]), $needsScrolled, frameHosts: $frames);
|
||||
return $this->renderPage($request, $seite, $seite['path']);
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
@@ -529,7 +671,7 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return array<string, list<array>>
|
||||
*/
|
||||
private function postLists(array $document, int $page = 1): array
|
||||
private function postLists(array $documents, int $page = 1): array
|
||||
{
|
||||
$lists = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$lists, $page): void {
|
||||
@@ -552,7 +694,9 @@ final class PageController extends AbstractController
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
@@ -690,9 +834,13 @@ final class PageController extends AbstractController
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
/** Die Kennungen der Bereiche dieser Antwort, fuer ihre Skriptdateien. @var array<string, string> */
|
||||
private array $areaIds = [];
|
||||
|
||||
private function areas(?array $page = null): array
|
||||
{
|
||||
$areas = [];
|
||||
$this->areaIds = [];
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
// A page may ask for another area or for none at all: a landing page without a
|
||||
// navigation is a normal wish, and it must not need a second header document that
|
||||
@@ -707,12 +855,31 @@ final class PageController extends AbstractController
|
||||
$partial ??= $this->partials->findByKind($kind);
|
||||
if (null !== $partial) {
|
||||
$areas[$kind] = $partial['document'];
|
||||
$this->areaIds[$kind] = (string) $partial['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eigenen Skripte eines Bereichs, als Datei - dasselbe Muster wie bei einer Seite. Vorher
|
||||
* wurde ein Skript im Kopf oder Fuss gespeichert, geprueft und nie ausgefuehrt.
|
||||
*/
|
||||
#[Route('/assets/bereich/{id}.js', name: 'area_script', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['GET'])]
|
||||
public function areaScript(string $id): Response
|
||||
{
|
||||
$partial = $this->partials->find($id);
|
||||
if (null === $partial || [] === $this->code->scriptsOf($partial['document'])) {
|
||||
throw $this->createNotFoundException('Kein Skript.');
|
||||
}
|
||||
$response = new Response($this->code->compileScript($partial['document']), 200, ['Content-Type' => 'text/javascript; charset=UTF-8']);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every linked section a page or one of its areas refers to, including sections that
|
||||
* link further sections. A missing section is left out; the renderer shows a placeholder only
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
@@ -11,6 +13,7 @@ use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -35,13 +38,19 @@ final class PartialController extends AbstractController
|
||||
private Theme $theme,
|
||||
private StyleCompiler $styles,
|
||||
private CustomCode $code,
|
||||
private FontLibrary $fonts,
|
||||
private CsrfTokenManagerInterface $csrf,
|
||||
private PluginRegistry $plugins,
|
||||
private FormRepository $forms,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'admin_partials', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
// Ein Bereich kennt keinen Entwurf: Speichern ist Veroeffentlichen. Deshalb ist der ganze
|
||||
// Bildschirm Sache des Veroeffentlichers, nicht erst der Speicherknopf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$partials = [];
|
||||
foreach ($this->partials->all() as $partial) {
|
||||
$partial['usedBy'] = 'section' === $partial['kind'] ? $this->partials->usedBy($partial['slug']) : [];
|
||||
@@ -54,7 +63,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/new', name: 'admin_partial_create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -79,7 +88,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}/delete', name: 'admin_partial_delete', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function delete(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -95,12 +104,16 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}', name: 'admin_partial_editor', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function editor(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
|
||||
return $this->render('admin/editor.html.twig', ['partial' => $this->partial($id), 'kinds' => PartialRepository::KINDS]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/document', name: 'admin_partial_document', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function document(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
|
||||
return $this->json($this->payload($this->partial($id)));
|
||||
}
|
||||
|
||||
@@ -108,7 +121,7 @@ final class PartialController extends AbstractController
|
||||
#[Route('/{id}/preview', name: 'admin_partial_preview', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function write(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||
}
|
||||
@@ -123,7 +136,7 @@ final class PartialController extends AbstractController
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
if ('admin_partial_preview' === $request->attributes->get('_route')) {
|
||||
$this->validator->validate($document, false);
|
||||
$this->validator->validate($document, false, true);
|
||||
|
||||
return $this->renderPreview($partial, $document);
|
||||
}
|
||||
@@ -134,7 +147,8 @@ final class PartialController extends AbstractController
|
||||
$this->code->assertScriptsUnchanged($document, $partial['document']);
|
||||
}
|
||||
$this->partials->save($id, $document, $body['expectedVersion']);
|
||||
$partial['document'] = $document;
|
||||
// Die gespeicherte Fassung zurueck - bereinigter Rich Text steht sofort im Editor.
|
||||
$partial['document'] = $this->partials->find($id)['document'] ?? $document;
|
||||
$partial['version'] = $body['expectedVersion'] + 1;
|
||||
|
||||
return $this->json($this->payload($partial));
|
||||
@@ -163,6 +177,29 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
// Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben.
|
||||
$styles = $this->theme->css($theme).$this->styles->compileDocument($document).$this->code->compileDocument($document);
|
||||
foreach ($sections as $section) {
|
||||
$styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section);
|
||||
}
|
||||
$forms = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$forms): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||||
$forms[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => ['path' => ''], 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
||||
if (null !== $form) {
|
||||
$styles .= $this->forms->css($form, $theme);
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
foreach ($sections as $section) {
|
||||
$walk($section['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', [
|
||||
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
||||
'canonical' => '',
|
||||
@@ -173,9 +210,9 @@ final class PartialController extends AbstractController
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'forms' => [],
|
||||
'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document),
|
||||
'googleFonts' => $this->theme->googleFontsUrl($theme),
|
||||
'forms' => $forms,
|
||||
'pageStyles' => $styles,
|
||||
'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -192,6 +229,8 @@ final class PartialController extends AbstractController
|
||||
'revisionId' => null,
|
||||
'publishedRevisionId' => null,
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
'plugins' => $this->plugins->forEditor(),
|
||||
'presets' => $this->plugins->presetsForEditor(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Der Bildschirm „Plugins“: was im Ordner liegt, was davon eingeschaltet ist, der Schalter, die
|
||||
* Einstellungen eines Plugins.
|
||||
*/
|
||||
final class PluginAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins', name: 'admin_plugins', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
// Ein Plugin bringt Code auf die Website - das schaltet nur ein Administrator.
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
return $this->render('admin/plugins.html.twig', ['plugins' => $this->plugins->all(), 'directory' => $this->plugins->directory()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/{state}', name: 'admin_plugin_toggle', requirements: ['name' => '[a-z][a-z0-9]{1,30}', 'state' => 'an|aus'], methods: ['POST'])]
|
||||
public function toggle(Request $request, string $name, string $state): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('plugins', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
if ('an' === $state) {
|
||||
$this->plugins->activate($name);
|
||||
|
||||
return $this->back(sprintf('„%s“ ist eingeschaltet. Seine Bausteine stehen jetzt im Editor unter „Erweiterungen“.', $this->plugins->all()[$name]['title']), 'success');
|
||||
}
|
||||
$this->plugins->deactivate($name);
|
||||
|
||||
return $this->back(sprintf('„%s“ ist ausgeschaltet. Seine Bausteine bleiben in den Seiten gespeichert, werden aber nicht mehr dargestellt.', $this->plugins->all()[$name]['title']), 'success');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/plugins/{name}/einstellungen', name: 'admin_plugin_settings', requirements: ['name' => '[a-z][a-z0-9]{1,30}'], methods: ['POST'])]
|
||||
public function settings(Request $request, string $name): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('plugins', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->plugins->saveSettings($name, $request->request->all('settings'));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back(sprintf('Die Einstellungen von „%s“ wurden gespeichert.', $this->plugins->all()[$name]['title']), 'success');
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_plugins');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Liefert Stylesheets, Skripte und Bilder eines aktiven Plugins aus.
|
||||
*
|
||||
* Plugins liegen außerhalb von public/, damit ein Ordner plugins/ nichts ausliefert, was nicht
|
||||
* im Manifest steht. Der Weg hierher ist derselbe Ursprung wie die Seite, deshalb reicht der
|
||||
* Richtlinie das 'self', das sie ohnehin erlaubt. Die Adresse trägt die Fassung des Plugins,
|
||||
* also darf der Browser die Datei lange behalten.
|
||||
*/
|
||||
final class PluginAssetController extends AbstractController
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/plugin-assets/{plugin}/{path}', name: 'plugin_asset', requirements: ['plugin' => '[a-z][a-z0-9]{1,30}', 'path' => 'assets/.+'], methods: ['GET'])]
|
||||
public function asset(string $plugin, string $path): Response
|
||||
{
|
||||
$file = $this->plugins->assetFile($plugin, $path) ?? throw $this->createNotFoundException('Datei nicht gefunden.');
|
||||
$response = new BinaryFileResponse($file, 200, [], false);
|
||||
$response->headers->set('Content-Type', PluginRegistry::ASSET_TYPES[strtolower(pathinfo($file, PATHINFO_EXTENSION))]);
|
||||
$response->headers->set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ final class SeoController extends AbstractController
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
||||
'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10),
|
||||
// Das juengere der beiden Daten: eine spaeter bearbeitete und neu veroeffentlichte
|
||||
// Seite behielte sonst das Datum ihrer ersten Veroeffentlichung.
|
||||
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
||||
];
|
||||
}
|
||||
// Archives exist only while they list something.
|
||||
|
||||
@@ -27,6 +27,10 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings', name: 'admin_settings', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Alles hier wirkt sofort auf die Website: 404-Seite und Weiterleitungen sind Sache des
|
||||
// Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
|
||||
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$choosable = [];
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
// Only a published page can serve as the root; a draft would answer every visit with 404.
|
||||
@@ -49,6 +53,7 @@ final class SettingsController extends AbstractController
|
||||
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||||
'palette' => $this->theme->load()['colors'],
|
||||
'socialNetworks' => SocialProfiles::NETWORKS,
|
||||
'weekdays' => SiteSettings::DAYS,
|
||||
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
|
||||
]);
|
||||
}
|
||||
@@ -56,7 +61,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -100,7 +105,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
public function saveRedirect(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@@ -132,7 +137,7 @@ final class SettingsController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
|
||||
public function legalPages(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ final class SystemController extends AbstractController
|
||||
'pendingMigrations' => $this->pendingMigrations(),
|
||||
// Actual runtime connection, with credentials excluded from the screen.
|
||||
'database' => $this->databaseFacts(),
|
||||
// Die Adresse, die Sitemap, Feed und Canonical nennen - und ob die Verwaltung gerade
|
||||
// unter ihr laeuft. Die Schreibweise mit oder ohne www leitet die Anwendung selbst um;
|
||||
// ein ganz anderer Host (Vorschauadresse des Hosters, IP) bleibt erreichbar und
|
||||
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
|
||||
'origin' => $this->originFacts($request),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -114,6 +119,22 @@ final class SystemController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{adresse: string, quelle: string, host: string, passt: bool}
|
||||
*/
|
||||
private function originFacts(Request $request): array
|
||||
{
|
||||
$wanted = strtolower((string) parse_url(trim($this->siteOrigin), PHP_URL_HOST));
|
||||
$port = parse_url(trim($this->siteOrigin), PHP_URL_PORT);
|
||||
|
||||
return [
|
||||
'adresse' => $this->siteOrigin,
|
||||
'quelle' => $this->environmentSource('SITE_ORIGIN'),
|
||||
'host' => $request->getHttpHost(),
|
||||
'passt' => '' !== $wanted && strtolower($request->getHost()) === $wanted && (null === $port || $request->getPort() === $port),
|
||||
];
|
||||
}
|
||||
|
||||
/** Explicit server values take precedence over the single installation file. */
|
||||
private function environmentSource(string $name): string
|
||||
{
|
||||
|
||||
@@ -129,7 +129,11 @@ final class TaskController extends AbstractController
|
||||
if (!$state['admin']) {
|
||||
return new JsonResponse(['off' => true, 'ran' => []]);
|
||||
}
|
||||
@set_time_limit(120);
|
||||
// Anheben, nicht senken: auf einem Prozess ohne Grenze waere 120 eine neue Grenze.
|
||||
$limit = (int) ini_get('max_execution_time');
|
||||
if ($limit > 0 && $limit < 120) {
|
||||
@set_time_limit(120);
|
||||
}
|
||||
$ran = $this->scheduler->runDue('verwaltung', 3, 45);
|
||||
|
||||
return new JsonResponse([
|
||||
|
||||
@@ -20,6 +20,9 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme', name: 'admin_theme', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
// Das Design gilt sofort auf jeder Seite - das ist Administratorensache, wie die
|
||||
// Rollenhilfe es verspricht.
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/theme.html.twig', [
|
||||
@@ -30,6 +33,10 @@ final class ThemeController extends AbstractController
|
||||
'buttonPresets' => ButtonDesign::PRESETS,
|
||||
'fontsLocal' => $this->fonts->hasLocal($theme),
|
||||
'fontsMissing' => $this->fonts->missing($theme),
|
||||
// Die Vorschau der Schriften nutzt die Dateien, die schon hier liegen.
|
||||
'fontsStylesheet' => $this->fonts->hasStylesheet(),
|
||||
'palettes' => Theme::PALETTES,
|
||||
'colorNames' => Theme::COLOR_NAMES,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -42,7 +49,7 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme/fonts', name: 'admin_theme_fonts', methods: ['POST'])]
|
||||
public function fonts(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
@@ -71,7 +78,7 @@ final class ThemeController extends AbstractController
|
||||
#[Route('/%kant.admin_path%/theme/save', name: 'admin_theme_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('theme', $request->request->getString('_csrf_token'))) {
|
||||
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
|
||||
|
||||
@@ -51,12 +51,12 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$headers->set('Referrer-Policy', 'same-origin');
|
||||
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
|
||||
// The preview must show the same typeface as the published page, so the font hosts are
|
||||
// allowed here as well. Everything else stays as narrow as on the public side.
|
||||
// Die Vorschau zeigt dieselbe Schrift wie die veroeffentlichte Seite - und die liegt,
|
||||
// genau wie dort, auf diesem Server. Fremde Schrifthosts braucht sie nicht.
|
||||
// The nonce only ever adds the one script it names; 'self' keeps applying to everything
|
||||
// else, so nothing is loosened by it.
|
||||
$nonce = $event->getRequest()->attributes->get('csp_nonce');
|
||||
$script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'";
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self'; font-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Eine Website, eine Adresse: mit oder ohne www entscheidet SITE_ORIGIN, nicht der Webserver.
|
||||
*
|
||||
* Bisher stand die Umleitung auskommentiert in public/.htaccess und musste von Hand zu SITE_ORIGIN
|
||||
* passen. Zwei Stellen fuer eine Entscheidung gehen auseinander, und dann zeigt jede Seite in
|
||||
* Canonical, Sitemap und Feed auf eine Adresse, die weiterleitet. Jetzt liest die Anwendung die
|
||||
* konfigurierte Adresse und schickt die andere Schreibweise dorthin - dauerhaft (301), mit Pfad
|
||||
* und Abfrage.
|
||||
*
|
||||
* Was sie bewusst nicht tut: einen fremden Host umleiten. Eine Vorschauadresse des Hosters, eine
|
||||
* IP-Adresse oder localhost bleiben erreichbar, auch wenn SITE_ORIGIN etwas anderes nennt - sonst
|
||||
* sperrt eine falsch eingetragene Adresse jeden aus, samt der Verwaltung. Umgeleitet wird nur das
|
||||
* Paar aus `example.de` und `www.example.de`. Und sie erzwingt kein HTTPS: hinter einem Proxy
|
||||
* sieht PHP oft nur http, und eine Schleife waere schlimmer als fehlendes HTTPS. Die drei Zeilen
|
||||
* dafuer stehen weiter in .htaccess, wo der Webserver die Verbindung wirklich kennt.
|
||||
*/
|
||||
final class CanonicalHostSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Vor Wartung (40) und Installer (36): auf dem falschen Host gibt es genau eine Antwort,
|
||||
// und fuer die braucht niemand die Datenbank.
|
||||
return [KernelEvents::REQUEST => ['onRequest', 48]];
|
||||
}
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
// Nur lesende Anfragen: ein 301 macht aus einem POST ein GET und verwirft die Eingabe.
|
||||
// Ein Formular auf der anderen Schreibweise darf noch ankommen; die Seite dahinter
|
||||
// fuehrt dann ohnehin auf die richtige Adresse.
|
||||
if (!$event->isMainRequest() || !\in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
|
||||
return;
|
||||
}
|
||||
$target = self::target($this->siteOrigin, $request);
|
||||
if (null !== $target) {
|
||||
$event->setResponse(new RedirectResponse($target, 301, ['Cache-Control' => 'public, max-age=3600']));
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Adresse, auf die diese Anfrage gehoert - oder null, wenn sie schon dort ist. */
|
||||
public static function target(string $origin, Request $request): ?string
|
||||
{
|
||||
$parts = parse_url(trim($origin));
|
||||
$wanted = strtolower((string) ($parts['host'] ?? ''));
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
if ('' === $wanted || !\in_array($scheme, ['http', 'https'], true)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$host = strtolower($request->getHost());
|
||||
} catch (\UnexpectedValueException) {
|
||||
// Ein Host, den Symfony nicht annimmt, bekommt seine 400 vom Kernel - nicht von hier.
|
||||
return null;
|
||||
}
|
||||
if ($host === $wanted || !self::wwwTwins($host, $wanted)) {
|
||||
return null;
|
||||
}
|
||||
$port = isset($parts['port']) ? ':'.$parts['port'] : '';
|
||||
|
||||
return $scheme.'://'.$wanted.$port.$request->getRequestUri();
|
||||
}
|
||||
|
||||
/** `example.de` und `www.example.de` - und sonst nichts. */
|
||||
private static function wwwTwins(string $a, string $b): bool
|
||||
{
|
||||
return 'www.'.$a === $b || $a === 'www.'.$b;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,22 @@ final class TaskWorkerSubscriber implements EventSubscriberInterface
|
||||
return [KernelEvents::TERMINATE => ['onTerminate', -64]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hebt die Laufzeitgrenze an - und senkt sie nie.
|
||||
*
|
||||
* `set_time_limit()` setzt die Grenze, es verlaengert nicht: auf einem Prozess ohne Grenze
|
||||
* (Konsole, Testlauf, Hoster mit `max_execution_time = 0`) macht ein fester Wert aus
|
||||
* "unbegrenzt" plötzlich "noch so viele Sekunden" - und der Prozess stirbt spaeter an einer
|
||||
* Stelle, die mit Aufgaben nichts zu tun hat.
|
||||
*/
|
||||
private function raiseTimeLimit(int $seconds): void
|
||||
{
|
||||
$current = (int) ini_get('max_execution_time');
|
||||
if ($current > 0 && $current < $seconds) {
|
||||
@set_time_limit($seconds);
|
||||
}
|
||||
}
|
||||
|
||||
public function onTerminate(TerminateEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
@@ -73,7 +89,7 @@ final class TaskWorkerSubscriber implements EventSubscriberInterface
|
||||
// Die Antwort ist raus; ein abgebrochener Aufruf soll die Arbeit trotzdem zu Ende
|
||||
// bringen, sonst bliebe eine Aufgabe auf halbem Weg mit gesetzter Sperre liegen.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit($this->budget + 60);
|
||||
$this->raiseTimeLimit($this->budget + 60);
|
||||
$this->scheduler->runDue($admin ? 'verwaltung' : 'besuch', $this->limit, $this->budget);
|
||||
} catch (\Throwable) {
|
||||
// Eine Aufgabe darf niemals eine Website kaputtmachen. Was schiefging, steht in der
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Infrastructure;
|
||||
|
||||
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
|
||||
|
||||
/**
|
||||
* `%env(projectdir:DATABASE_URL)%`: ersetzt `%kernel.project_dir%` in einer Umgebungsvariablen -
|
||||
* und sonst nichts.
|
||||
*
|
||||
* Symfonys `resolve:` tut mehr: es liest jedes `%name%` als Parameter. In einer MySQL-Adresse
|
||||
* stehen Benutzer und Passwort prozentkodiert (`p%40ss` fuer `p@ss`), und `%40ss%` ist dann ein
|
||||
* Parameter, den es nicht gibt - der Container bricht bei jeder Anfrage ab, nachdem der Installer
|
||||
* die Adresse geschrieben hat. Dieser Prozessor kennt nur den einen Platzhalter, den `.env.example`
|
||||
* und der Installer fuer SQLite verwenden.
|
||||
*/
|
||||
final class ProjectDirEnvProcessor implements EnvVarProcessorInterface
|
||||
{
|
||||
public function __construct(private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
public function getEnv(string $prefix, string $name, \Closure $getEnv): string
|
||||
{
|
||||
$value = $getEnv($name);
|
||||
|
||||
return str_replace('%kernel.project_dir%', $this->projectDir, (string) $value);
|
||||
}
|
||||
|
||||
public static function getProvidedTypes(): array
|
||||
{
|
||||
return ['projectdir' => 'string'];
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,23 @@ final class ArchiveUpdate
|
||||
return ['ok' => true, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
$lock = $this->updater->lock();
|
||||
if (null === $lock) {
|
||||
$melden($schritte, 'Sperre', false, 'Ein anderes Update läuft gerade (var/update.lock). Bitte warten.');
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
try {
|
||||
return $this->apply($schritte, $melden, $ziel, $neueste);
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Teil des Updates, der die Installation veraendert - unter der Sperre. */
|
||||
private function apply(array &$schritte, callable $melden, string $ziel, array $neueste): array
|
||||
{
|
||||
$arbeit = $this->root.'/var/update/'.gmdate('Ymd-His');
|
||||
if (!@mkdir($arbeit, 0o775, true) && !is_dir($arbeit)) {
|
||||
$melden($schritte, 'Arbeitsordner', false, $arbeit.' ließ sich nicht anlegen. Fehlt das Schreibrecht auf var/?');
|
||||
@@ -145,14 +162,14 @@ final class ArchiveUpdate
|
||||
}
|
||||
|
||||
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
|
||||
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
|
||||
if (null === $inhalt || '' === $inhalt) {
|
||||
// Direkt in die Datei, nicht erst als Zeichenkette in den Speicher: ein Archiv von einigen
|
||||
// Megabyte passte in memory_limit, ein groesseres nicht mehr.
|
||||
if (!$this->download->toFile(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel), $archiv)) {
|
||||
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
file_put_contents($archiv, $inhalt);
|
||||
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round(strlen($inhalt) / 1024)));
|
||||
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round((int) filesize($archiv) / 1024)));
|
||||
|
||||
$erwartet = $ziel === $neueste['version'] ? $neueste['checksum'] : $this->checksumOf(sprintf('%s/v%s/kantsites-%s.zip.sha256', $this->releaseBase, $ziel, $ziel));
|
||||
if (null === $erwartet) {
|
||||
@@ -204,6 +221,9 @@ final class ArchiveUpdate
|
||||
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
|
||||
// Betreiber und wären sonst weg.
|
||||
$this->mergePublic($quelle.'/public', $this->root.'/public');
|
||||
// Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber
|
||||
// installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt.
|
||||
$this->mergePublic($quelle.'/plugins', $this->root.'/plugins');
|
||||
} catch (\Throwable $fehler) {
|
||||
foreach (self::ERSETZEN as $eintrag) {
|
||||
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
|
||||
@@ -286,8 +306,9 @@ final class ArchiveUpdate
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
$schritte[] = ['step' => 'Aufräumen', 'ok' => true, 'detail' => $this->aufraeumen()];
|
||||
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).',
|
||||
$this->updater->version(),
|
||||
)];
|
||||
|
||||
@@ -327,10 +348,69 @@ final class ArchiveUpdate
|
||||
|
||||
continue;
|
||||
}
|
||||
if ('.htaccess' === $eintrag && is_file($ziel)) {
|
||||
$this->mergeHtaccess($quelle, $ziel);
|
||||
|
||||
continue;
|
||||
}
|
||||
@copy($quelle, $ziel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die neue .htaccess uebernehmen, aber die Zeilen, die der Betreiber eingeschaltet hat,
|
||||
* eingeschaltet lassen: HTTPS-Zwang und HSTS. Vorher kam jedes Update mit den auskommentierten
|
||||
* Zeilen des Releases zurueck, und die Website lief nach dem Update wieder ohne Umleitung.
|
||||
*/
|
||||
private function mergeHtaccess(string $neu, string $alt): void
|
||||
{
|
||||
$vorher = (string) file_get_contents($alt);
|
||||
$inhalt = (string) file_get_contents($neu);
|
||||
foreach (['RewriteCond %{HTTPS} !=on', 'RewriteCond %{HTTP:X-Forwarded-Proto} !=https', 'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]', 'Header always set Strict-Transport-Security'] as $zeile) {
|
||||
$aktiv = 1 === preg_match('/^\s*'.preg_quote($zeile, '/').'/m', $vorher);
|
||||
if ($aktiv) {
|
||||
$inhalt = (string) preg_replace('/^(\s*)#\s*('.preg_quote($zeile, '/').')/m', '$1$2', $inhalt);
|
||||
}
|
||||
}
|
||||
file_put_contents($alt, $inhalt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raeumt nach einem gelungenen Update auf: die Arbeitsordner unter var/update/ bis auf die
|
||||
* zwei juengsten, und die Zwischenspeicher frueherer Fassungen unter var/cache/. Beides wuchs
|
||||
* vorher mit jedem Update, bis der Speicherplatz des Hostings voll war.
|
||||
*/
|
||||
private function aufraeumen(): string
|
||||
{
|
||||
$entfernt = 0;
|
||||
$ordner = glob($this->root.'/var/update/*', GLOB_ONLYDIR) ?: [];
|
||||
rsort($ordner);
|
||||
foreach (array_slice($ordner, 2) as $pfad) {
|
||||
$this->loeschen($pfad);
|
||||
++$entfernt;
|
||||
}
|
||||
$aktuell = 'release-'.substr(hash_file('sha256', $this->root.'/VERSION') ?: '', 0, 16);
|
||||
foreach (glob($this->root.'/var/cache/*/release-*', GLOB_ONLYDIR) ?: [] as $pfad) {
|
||||
if (basename($pfad) !== $aktuell) {
|
||||
$this->loeschen($pfad);
|
||||
++$entfernt;
|
||||
}
|
||||
}
|
||||
|
||||
return 0 === $entfernt ? 'Nichts aufzuräumen.' : sprintf('%d alte Ordner unter var/update und var/cache entfernt.', $entfernt);
|
||||
}
|
||||
|
||||
private function loeschen(string $pfad): void
|
||||
{
|
||||
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||
if ('.' === $eintrag || '..' === $eintrag) {
|
||||
continue;
|
||||
}
|
||||
is_dir($pfad.'/'.$eintrag) && !is_link($pfad.'/'.$eintrag) ? $this->loeschen($pfad.'/'.$eintrag) : @unlink($pfad.'/'.$eintrag);
|
||||
}
|
||||
@rmdir($pfad);
|
||||
}
|
||||
|
||||
/** Liest die Prüfsumme aus der Datei neben dem Archiv. */
|
||||
private function checksumOf(string $url): ?string
|
||||
{
|
||||
|
||||
+96
-11
@@ -172,14 +172,22 @@ final class Backup
|
||||
|
||||
$file = $this->databaseFile();
|
||||
if (null === $file) {
|
||||
$step($steps, 'Datenbank zurückschreiben', false, 'Diese Installation nutzt keine Dateidatenbank. Für MySQL fehlt das Einspielen noch (siehe docs/UPDATE.md).');
|
||||
// MySQL: der Dump wird mit dem Client eingespielt - derselbe Weg, den man von Hand
|
||||
// gehen wuerde, nur ohne Tippfehler in der Verbindung.
|
||||
try {
|
||||
$this->loadMysql($path.'/'.(string) ($manifest['datenbank']['datei'] ?? 'datenbank.sql'));
|
||||
$step($steps, 'Datenbank zurückschreiben', true, 'Der Dump wurde mit dem MySQL-Client eingespielt.');
|
||||
} catch (InvalidPage $fehler) {
|
||||
$step($steps, 'Datenbank zurückschreiben', false, $fehler->getMessage());
|
||||
|
||||
return ['steps' => $steps, 'ok' => false];
|
||||
}
|
||||
$this->db->close();
|
||||
$copied = @copy($path.'/'.basename($file), $file);
|
||||
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
|
||||
return ['steps' => $steps, 'ok' => false];
|
||||
return ['steps' => $steps, 'ok' => false];
|
||||
}
|
||||
} else {
|
||||
$this->db->close();
|
||||
$copied = @copy($path.'/'.basename($file), $file);
|
||||
if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) {
|
||||
return ['steps' => $steps, 'ok' => false];
|
||||
}
|
||||
}
|
||||
|
||||
// Media are added, never deleted: files uploaded after the backup would otherwise be lost
|
||||
@@ -216,10 +224,7 @@ final class Backup
|
||||
{
|
||||
$file = $this->databaseFile();
|
||||
if (null === $file) {
|
||||
if (!$this->runner->has('mysqldump', $this->root)) {
|
||||
throw new InvalidPage('Diese Installation nutzt keine Dateidatenbank, und mysqldump ist nicht aufrufbar. Ohne eines von beiden gibt es keine Sicherung.');
|
||||
}
|
||||
throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).');
|
||||
return $this->dumpMysql($directory);
|
||||
}
|
||||
$target = $directory.'/'.basename($file);
|
||||
// VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann
|
||||
@@ -366,6 +371,86 @@ final class Backup
|
||||
@rmdir($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Verbindungsdaten einer MySQL/MariaDB-Adresse, oder null bei einer Dateidatenbank.
|
||||
*
|
||||
* @return array{host: string, port: string, user: string, password: string, name: string}|null
|
||||
*/
|
||||
private function mysqlConnection(): ?array
|
||||
{
|
||||
if (1 !== preg_match('#^(mysql|mariadb)://#', $this->databaseUrl)) {
|
||||
return null;
|
||||
}
|
||||
$teile = parse_url($this->databaseUrl);
|
||||
if (!is_array($teile) || '' === trim((string) ($teile['path'] ?? ''), '/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => (string) ($teile['host'] ?? '127.0.0.1'), 'port' => (string) ($teile['port'] ?? 3306),
|
||||
'user' => rawurldecode((string) ($teile['user'] ?? '')), 'password' => rawurldecode((string) ($teile['pass'] ?? '')),
|
||||
'name' => trim((string) $teile['path'], '/'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> die Verbindungsargumente fuer mysqldump und mysql */
|
||||
private function mysqlArguments(array $verbindung): array
|
||||
{
|
||||
$args = ['--host='.$verbindung['host'], '--port='.$verbindung['port'], '--user='.$verbindung['user']];
|
||||
if ('' !== $verbindung['password']) {
|
||||
// Als Argument, nicht als Umgebungsvariable: der Befehlslaeufer startet mit einer
|
||||
// festen Umgebung. Das Argument ist fuer andere Nutzer des Servers kurz sichtbar -
|
||||
// wie bei jedem mysqldump-Aufruf von Hand.
|
||||
$args[] = '--password='.$verbindung['password'];
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/** @return array{datei: string, bytes: int, sha256: string} */
|
||||
private function dumpMysql(string $directory): array
|
||||
{
|
||||
$verbindung = $this->mysqlConnection();
|
||||
if (null === $verbindung) {
|
||||
throw new InvalidPage('Die Datenbankadresse ist weder eine SQLite-Datei noch eine MySQL-Verbindung.');
|
||||
}
|
||||
$dump = $this->runner->resolve('mysqldump', $this->root);
|
||||
if (null === $dump) {
|
||||
throw new InvalidPage('Diese Installation nutzt MySQL, und mysqldump ist nicht aufrufbar. Ohne den Client gibt es keine Sicherung - beim Hoster nach dem Pfad fragen.');
|
||||
}
|
||||
$target = $directory.'/datenbank.sql';
|
||||
// --single-transaction: ein konsistenter Stand ohne Sperre der Tabellen; --result-file:
|
||||
// direkt in die Datei, nicht durch den Speicher dieses Prozesses.
|
||||
$ergebnis = $this->runner->run([...$dump, ...$this->mysqlArguments($verbindung), '--single-transaction', '--quick', '--skip-lock-tables', '--routines', '--result-file='.$target, $verbindung['name']], $this->root, 900);
|
||||
if (!$ergebnis['ok'] || !is_readable($target) || 0 === (int) filesize($target)) {
|
||||
@unlink($target);
|
||||
throw new InvalidPage('mysqldump ist gescheitert: '.trim($ergebnis['output']));
|
||||
}
|
||||
|
||||
return ['datei' => 'datenbank.sql', 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
|
||||
}
|
||||
|
||||
/** Spielt einen Dump mit dem MySQL-Client ein. @throws InvalidPage */
|
||||
private function loadMysql(string $dump): void
|
||||
{
|
||||
$verbindung = $this->mysqlConnection();
|
||||
if (null === $verbindung) {
|
||||
throw new InvalidPage('Die Datenbankadresse ist keine MySQL-Verbindung.');
|
||||
}
|
||||
if (!is_readable($dump)) {
|
||||
throw new InvalidPage('Der Dump fehlt in der Sicherung: '.basename($dump));
|
||||
}
|
||||
$client = $this->runner->resolve('mysql', $this->root);
|
||||
if (null === $client) {
|
||||
throw new InvalidPage('Der MySQL-Client (mysql) ist nicht aufrufbar; der Dump liegt unter '.$dump.' und lässt sich von Hand einspielen.');
|
||||
}
|
||||
$this->db->close();
|
||||
$ergebnis = $this->runner->run([...$client, ...$this->mysqlArguments($verbindung), '--database='.$verbindung['name'], '--execute=source '.str_replace('\\', '/', $dump)], $this->root, 900);
|
||||
if (!$ergebnis['ok']) {
|
||||
throw new InvalidPage('Der MySQL-Client hat den Dump nicht eingespielt: '.trim($ergebnis['output']));
|
||||
}
|
||||
}
|
||||
|
||||
private function databaseFile(): ?string
|
||||
{
|
||||
if (!str_starts_with($this->databaseUrl, 'sqlite:')) {
|
||||
|
||||
@@ -25,6 +25,66 @@ final class CommandRunner
|
||||
return !in_array('proc_open', $disabled, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Was ein Kindprozess von der Umgebung sehen darf: Pfade, ein Zuhause fuer Composer und Git,
|
||||
* aber kein APP_ENV, das einen Konsolenbefehl gegen die falsche Umgebung laufen liesse.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function environment(): array
|
||||
{
|
||||
$environment = ['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir(), 'TMP' => sys_get_temp_dir()];
|
||||
// Composer verlangt HOME (Linux) oder APPDATA (Windows) - ohne sie bricht `composer
|
||||
// install` ab, waehrend `composer --version` noch laeuft. Git braucht HOME fuer .gitconfig.
|
||||
foreach (['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'COMPOSER_HOME', 'PATHEXT', 'LANG', 'LC_ALL'] as $name) {
|
||||
$value = getenv($name);
|
||||
if (is_string($value) && '' !== $value) {
|
||||
$environment[$name] = $value;
|
||||
}
|
||||
}
|
||||
if (!isset($environment['HOME']) && !isset($environment['USERPROFILE'])) {
|
||||
// Unter php-fpm ist HOME oft leer; ein Ordner in var/ tut es fuer Composer und Git.
|
||||
$environment['HOME'] = sys_get_temp_dir();
|
||||
}
|
||||
if (!isset($environment['COMPOSER_HOME'])) {
|
||||
$environment['COMPOSER_HOME'] = $environment['HOME'] ?? sys_get_temp_dir();
|
||||
}
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die PHP-Kommandozeile, mit der Konsolenbefehle laufen.
|
||||
*
|
||||
* Aus einem Webprozess ist PHP_BINARY der Server selbst (php-fpm) oder leer (mod_php) - und
|
||||
* `php-fpm bin/console` startet keinen Befehl. Dann wird die Kommandozeile im Pfad gesucht.
|
||||
*/
|
||||
public function php(): string
|
||||
{
|
||||
if (isset($this->resolved['php'])) {
|
||||
return $this->resolved['php'][0];
|
||||
}
|
||||
$candidates = [];
|
||||
if ('cli' === \PHP_SAPI && '' !== \PHP_BINARY) {
|
||||
$candidates[] = \PHP_BINARY;
|
||||
}
|
||||
$suffix = '\\' === \DIRECTORY_SEPARATOR ? '.exe' : '';
|
||||
foreach ([\PHP_BINDIR.'/php'.$suffix, \PHP_BINDIR.'/php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, 'php'.$suffix, 'php'.\PHP_MAJOR_VERSION.'.'.\PHP_MINOR_VERSION.$suffix, \PHP_BINARY] as $candidate) {
|
||||
if ('' !== $candidate && !\in_array($candidate, $candidates, true)) {
|
||||
$candidates[] = $candidate;
|
||||
}
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->run([$candidate, '-r', 'echo PHP_VERSION;'], sys_get_temp_dir(), 30)['ok']) {
|
||||
$this->resolved['php'] = [$candidate];
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0] ?? 'php';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $command
|
||||
*
|
||||
@@ -43,7 +103,7 @@ final class CommandRunner
|
||||
$cwd,
|
||||
// A minimal, predictable environment: no inherited APP_ENV that would make a console
|
||||
// command run against the wrong environment.
|
||||
['PATH' => getenv('PATH') ?: '', 'SystemRoot' => getenv('SystemRoot') ?: '', 'TEMP' => sys_get_temp_dir()],
|
||||
$this->environment(),
|
||||
);
|
||||
if (!is_resource($process)) {
|
||||
return ['ok' => false, 'exit' => -1, 'output' => sprintf('%s konnte nicht gestartet werden.', $command[0])];
|
||||
@@ -109,7 +169,7 @@ final class CommandRunner
|
||||
}
|
||||
// A phar in the project is the way on hosting where nothing is installed globally.
|
||||
if (is_file($cwd.'/'.$executable.'.phar')) {
|
||||
$candidates[] = [PHP_BINARY, $cwd.'/'.$executable.'.phar'];
|
||||
$candidates[] = [$this->php(), $cwd.'/'.$executable.'.phar'];
|
||||
}
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->run([...$candidate, '--version'], $cwd, 30)['ok']) {
|
||||
|
||||
+81
-6
@@ -46,6 +46,66 @@ class Download
|
||||
return $this->grund;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die Adresse direkt in eine Datei - fuer Archive, die nicht als Zeichenkette in den
|
||||
* Speicher passen sollen. Ohne curl geht es ueber get(); dann ist der Speicher die Grenze.
|
||||
*/
|
||||
public function toFile(string $url, string $path): bool
|
||||
{
|
||||
$this->grund = '';
|
||||
if (!str_starts_with($url, 'https://')) {
|
||||
$this->grund = 'Nur HTTPS-Adressen werden geholt.';
|
||||
|
||||
return false;
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
$datei = @fopen($path, 'w');
|
||||
if (!is_resource($datei)) {
|
||||
$this->grund = 'Die Zieldatei ließ sich nicht anlegen: '.$path;
|
||||
|
||||
return false;
|
||||
}
|
||||
$curl = curl_init($url);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_FILE => $datei,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||
CURLOPT_TIMEOUT => $this->timeout,
|
||||
CURLOPT_USERAGENT => 'KantSites-Updater',
|
||||
]);
|
||||
$ok = curl_exec($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$fehler = curl_errno($curl);
|
||||
$text = curl_error($curl);
|
||||
curl_close($curl);
|
||||
fclose($datei);
|
||||
if (true === $ok && 200 === $status && (int) filesize($path) > 0) {
|
||||
return true;
|
||||
}
|
||||
@unlink($path);
|
||||
if (!in_array($fehler, self::ZERTIFIKATSFEHLER, true)) {
|
||||
$this->grund = match (true) {
|
||||
0 !== $fehler => 'Die Verbindung kam nicht zustande: '.$text,
|
||||
0 === $status => 'Der Server hat nicht geantwortet.',
|
||||
default => sprintf('Die Adresse antwortet mit %d statt 200.', $status),
|
||||
};
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
$inhalt = $this->get($url);
|
||||
if (null === $inhalt || '' === $inhalt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false !== file_put_contents($path, $inhalt);
|
||||
}
|
||||
|
||||
/** Den Inhalt der Adresse, oder null wenn sie nicht mit 200 antwortet. */
|
||||
public function get(string $url): ?string
|
||||
{
|
||||
@@ -63,6 +123,10 @@ class Download
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
// Auch eine Weiterleitung bleibt bei HTTPS: sonst pruefte die erste Zeile nur die
|
||||
// erste Adresse, und die Gegenstelle koennte auf http:// umlenken.
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
|
||||
@@ -96,12 +160,23 @@ class Download
|
||||
|
||||
return null;
|
||||
}
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => $this->timeout, 'follow_location' => 1, 'max_redirects' => 3, 'header' => "User-Agent: KantSites-Updater\r\n"],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $context);
|
||||
if (is_string($antwort)) {
|
||||
// Der Dateizeiger folgt Weiterleitungen auch auf http://; deshalb hier von Hand, und
|
||||
// jede Station muss HTTPS sein.
|
||||
$antwort = false;
|
||||
for ($hop = 0; $hop <= 3 && str_starts_with($url, 'https://'); ++$hop) {
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => $this->timeout, 'follow_location' => 0, 'ignore_errors' => true, 'header' => "User-Agent: KantSites-Updater\r\n"],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $context);
|
||||
$kopf = implode("\n", $http_response_header ?? []);
|
||||
if (!is_string($antwort) || 1 !== preg_match('/^HTTP\/\S+ 3\d\d/m', $kopf) || 1 !== preg_match('/^Location:\s*(\S+)/mi', $kopf, $ziel)) {
|
||||
break;
|
||||
}
|
||||
$url = $ziel[1];
|
||||
$antwort = false;
|
||||
}
|
||||
if (is_string($antwort) && 1 === preg_match('/^HTTP\/\S+ 200/m', implode("\n", $http_response_header ?? []))) {
|
||||
$this->grund = '';
|
||||
|
||||
return $antwort;
|
||||
|
||||
@@ -9,8 +9,10 @@ use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\CmsUser;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\EventSubscriber\InstallSubscriber;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
use Doctrine\Migrations\Configuration\Connection\ExistingConnection;
|
||||
use Doctrine\Migrations\Configuration\Migration\PhpFile;
|
||||
use Doctrine\Migrations\DependencyFactory;
|
||||
@@ -57,11 +59,22 @@ final class Installation
|
||||
*/
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
// Die Marke schreibt die Website, sobald ein Konto existiert. Sie zaehlt auch dann, wenn
|
||||
// die Datenbank gerade nicht antwortet: ein gedrehtes Passwort oder eine Wartung beim
|
||||
// Hoster darf den Installer nicht wieder oeffnen - dort koennte sonst jeder eine eigene
|
||||
// Datenbank eintragen und sich zum Administrator machen.
|
||||
if (is_file($this->root.'/'.InstallSubscriber::MARKER)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return (bool) $this->db->fetchOne('SELECT id FROM cms_user LIMIT 1');
|
||||
} catch (\Throwable) {
|
||||
// No database, no table, no installation - all three mean the same here.
|
||||
} catch (TableNotFoundException) {
|
||||
// Keine Tabelle: die Datenbank ist da, aber leer - das ist der Installer-Fall.
|
||||
return false;
|
||||
} catch (\Throwable) {
|
||||
// Keine Verbindung. Vor dem Datenbankschritt ist das normal (es gibt noch keine
|
||||
// eingetragene Datenbank); danach ist es eine Stoerung, keine fehlende Installation.
|
||||
return $this->environmentWritten();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+78
-4
@@ -24,15 +24,46 @@ final class Updater
|
||||
public const MAINTENANCE_FILE = 'var/maintenance';
|
||||
public const LOG_FILE = 'var/updates.log';
|
||||
|
||||
/** Eine Datei, die waehrend eines Updates gesperrt ist - Git-Weg und Archiv-Weg teilen sie sich. */
|
||||
public const LOCK_FILE = 'var/update.lock';
|
||||
|
||||
/** Der Name der Sicherung, die vor dem Update entstand - der Rueckweg spielt sie ein. */
|
||||
private ?string $backupName = null;
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
private string $databaseUrl,
|
||||
private CommandRunner $runner,
|
||||
private Connection $db,
|
||||
private string $php = PHP_BINARY,
|
||||
private ?string $php = null,
|
||||
private ?Backup $backups = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperrt Updates gegeneinander. Zwei Klicks auf "Aktualisieren", ein Cronjob neben der
|
||||
* Verwaltung, der Archiv-Weg neben dem Git-Weg: jeder zweite Lauf bekommt null und faengt
|
||||
* nichts an. Die Sperre faellt mit dem Handle, also spaetestens mit dem Prozess.
|
||||
*
|
||||
* @return resource|null
|
||||
*/
|
||||
public function lock(): mixed
|
||||
{
|
||||
$file = $this->root.'/'.self::LOCK_FILE;
|
||||
@mkdir(dirname($file), 0o775, true);
|
||||
$handle = @fopen($file, 'c');
|
||||
if (!is_resource($handle)) {
|
||||
return null;
|
||||
}
|
||||
if (!flock($handle, LOCK_EX | LOCK_NB)) {
|
||||
fclose($handle);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $handle;
|
||||
}
|
||||
|
||||
/** The version that is installed, from the file that ships inside the tag. */
|
||||
public function version(): string
|
||||
{
|
||||
@@ -224,6 +255,21 @@ final class Updater
|
||||
}
|
||||
|
||||
// ---- from here on the installation is being changed ----
|
||||
$lock = $this->lock();
|
||||
if (!$step($steps, 'Sperre', null !== $lock, null !== $lock ? 'Kein anderes Update läuft.' : 'Ein anderes Update läuft gerade (var/update.lock). Bitte warten.')) {
|
||||
return ['ok' => false, 'steps' => $steps, 'target' => $target];
|
||||
}
|
||||
try {
|
||||
return $this->apply($steps, $step, $target, $from, $lockBefore);
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Teil des Updates, der die Installation veraendert - unter der Sperre. */
|
||||
private function apply(array &$steps, callable $step, string $target, string $from, string $lockBefore): array
|
||||
{
|
||||
$backup = $this->backup();
|
||||
if (!$step($steps, 'Sicherung', null !== $backup, $backup ?? 'Die Datenbank ließ sich nicht sichern. Ohne Sicherung wird nicht aktualisiert.')) {
|
||||
return ['ok' => false, 'steps' => $steps, 'target' => $target];
|
||||
@@ -288,7 +334,12 @@ final class Updater
|
||||
if (!$step($steps, 'Rauchtest', $health['ok'], $this->lastLine($health['output']))) {
|
||||
throw new \RuntimeException('health');
|
||||
}
|
||||
} catch (\RuntimeException) {
|
||||
} catch (\Throwable $error) {
|
||||
// Nicht nur die eigenen Abbrueche: jeder Fehler auf dem Weg nimmt den Rueckweg, statt
|
||||
// den Wartungszustand ohne ein Wort stehen zu lassen.
|
||||
if (!$error instanceof \RuntimeException) {
|
||||
$step($steps, 'Unerwarteter Fehler', false, $error->getMessage());
|
||||
}
|
||||
$this->rollback($steps, $from, $migrated ? $backup : null, $step);
|
||||
$this->write($steps, $target, false);
|
||||
|
||||
@@ -345,7 +396,19 @@ final class Updater
|
||||
}
|
||||
$step($steps, 'Zurück auf den alten Stand', $back['ok'], $back['ok'] ? 'Auf '.substr($commit, 0, 8).' zurückgesetzt.' : $back['output']);
|
||||
|
||||
if (null !== $backup && null !== $this->databaseFile()) {
|
||||
if (null !== $backup && null !== $this->backupName && null !== $this->backups) {
|
||||
// Ueber die Sicherung, nicht ueber eine rohe Kopie: derselbe konsistente Weg fuer
|
||||
// SQLite (VACUUM INTO) und MySQL (Dump und Client).
|
||||
$restored = $this->backups->restore($this->backupName, true)['ok'];
|
||||
$step($steps, 'Datenbank zurückschreiben', $restored, $restored
|
||||
? 'Die Sicherung von vor der Migration ist wieder eingespielt.'
|
||||
: 'Die Sicherung ließ sich nicht zurückschreiben. Sie liegt unter var/backups/'.$this->backupName.'.');
|
||||
if (!$restored) {
|
||||
$step($steps, 'Wartungszustand bleibt', false, 'Die Datenbank konnte nicht wiederhergestellt werden. Der Betrieb bleibt angehalten.');
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (null !== $backup && null !== $this->databaseFile()) {
|
||||
$file = $this->databaseFile();
|
||||
$copy = $this->backupDirectory.'/'.basename((string) $file);
|
||||
$restored = is_readable($copy) && @copy($copy, (string) $file);
|
||||
@@ -393,6 +456,17 @@ final class Updater
|
||||
private function backup(): ?string
|
||||
{
|
||||
$this->backupDirectory = null;
|
||||
$this->backupName = null;
|
||||
if (null !== $this->backups) {
|
||||
try {
|
||||
$sicherung = $this->backups->create('vor dem Update');
|
||||
$this->backupName = $sicherung['name'];
|
||||
|
||||
return sprintf('var/backups/%s (Datenbank %s, Medien %d Datei(en)).', $sicherung['name'], $this->bytes((int) ($sicherung['manifest']['datenbank']['bytes'] ?? 0)), (int) ($sicherung['manifest']['medien']['dateien'] ?? 0));
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(4));
|
||||
if (!@mkdir($directory, 0o700, true)) {
|
||||
return null;
|
||||
@@ -438,7 +512,7 @@ final class Updater
|
||||
/** @return array{ok: bool, exit: int, output: string} */
|
||||
private function console(array $arguments, int $timeout): array
|
||||
{
|
||||
return $this->runner->run([$this->php, ...$arguments], $this->root, $timeout);
|
||||
return $this->runner->run([$this->php ??= $this->runner->php(), ...$arguments], $this->root, $timeout);
|
||||
}
|
||||
|
||||
/** The SQLite file behind DATABASE_URL, or null for a server database. */
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
<?php
|
||||
|
||||
namespace App\Plugin;
|
||||
|
||||
use App\Domain\InvalidDocument;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Die Erweiterungen dieser Installation: ein Ordner je Plugin unter plugins/, darin plugin.json.
|
||||
*
|
||||
* Ein Plugin bringt Bausteine mit. Jeder Baustein hat einen Typ mit Namensraum (`vergleich.bild`),
|
||||
* ein Schema seiner Eigenschaften, eine Twig-Vorlage und optional ein Stylesheet und ein Skript,
|
||||
* die nur auf Seiten geladen werden, auf denen der Baustein vorkommt. Der Kern prüft die
|
||||
* Eigenschaften gegen das Schema, bevor die Vorlage etwas davon sieht - ein Wert, der nicht im
|
||||
* Schema steht oder nicht hineinpasst, erreicht die Ausgabe nie.
|
||||
*
|
||||
* Seit dem zweiten Teil von Arbeitspaket H (ADR 103) kann ein Plugin außerdem: Bausteine mit
|
||||
* Kindern (Container), Einstellungen für den Betreiber, eigene öffentliche Adressen und
|
||||
* Seitenvorlagen für den Editor. Alles davon ist Manifest plus Twig - PHP-Code bringt ein
|
||||
* Plugin nicht mit, und deshalb gibt es auch keine Hooks in Speichern oder Veröffentlichen.
|
||||
*
|
||||
* Eingeschaltet wird ein Plugin in der Verwaltung; der Schalter liegt in der Datenbank. Ein
|
||||
* ausgeschaltetes oder fehlendes Plugin lässt seine Bausteine unangetastet im Dokument (ADR 011):
|
||||
* sie werden gespeichert, im Editor als Platzhalter gezeigt und öffentlich als Kommentar
|
||||
* ausgegeben.
|
||||
*
|
||||
* Serverseitiger Plugin-Code (Twig-Vorlagen) läuft mit den Rechten der Anwendung. Das ist kein
|
||||
* Sandkasten und wird auch nicht so genannt: ein Plugin installiert nur, wer dem Code traut.
|
||||
*/
|
||||
final class PluginRegistry
|
||||
{
|
||||
public const NAME = '/^[a-z][a-z0-9]{1,30}$/D';
|
||||
public const BLOCK = '/^[a-z][a-z0-9-]{1,40}$/D';
|
||||
/** Die Arten von Eigenschaften, die ein Schema kennt; jede hat ein Feld im Editor. */
|
||||
public const KINDS = ['text', 'textarea', 'number', 'boolean', 'select', 'url', 'media'];
|
||||
/** Was ein Plugin als Datei ausliefern darf. */
|
||||
public const ASSET_TYPES = ['css' => 'text/css; charset=UTF-8', 'js' => 'text/javascript; charset=UTF-8', 'svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'webp' => 'image/webp', 'woff2' => 'font/woff2'];
|
||||
/** Was eine eigene Adresse eines Plugins antworten darf. */
|
||||
public const ROUTE_TYPES = ['html' => 'text/html; charset=UTF-8', 'json' => 'application/json', 'text' => 'text/plain; charset=UTF-8', 'xml' => 'application/xml; charset=UTF-8'];
|
||||
/** Eine Adresse wie eine Seite: Slugs, bis zu vier Ebenen - dieselbe Form, die der Seitenaufruf annimmt. */
|
||||
public const ROUTE_PATH = '/^\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}$/D';
|
||||
public const MAX_PROPS = 30;
|
||||
|
||||
/** @var array<string, array>|null */
|
||||
private ?array $manifests = null;
|
||||
/** @var array<string, array{active: bool, version: string, activatedAt: ?string, settings: array}>|null */
|
||||
private ?array $rows = null;
|
||||
|
||||
public function __construct(private Connection $db, private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
public function directory(): string
|
||||
{
|
||||
return $this->projectDir.'/plugins';
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Plugins im Ordner, gültige wie ungültige, mit ihrem Schalter.
|
||||
*
|
||||
* @return array<string, array{name: string, title: string, description: string, version: string, requires: string, blocks: array<string, array>, settings: array<string, array>, routes: array<string, array>, presets: list<array>, active: bool, error: ?string, activatedVersion: string, activatedAt: ?string, values: array}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
if (null !== $this->manifests) {
|
||||
return $this->manifests;
|
||||
}
|
||||
$this->manifests = [];
|
||||
$rows = $this->rows();
|
||||
foreach (glob($this->directory().'/*/plugin.json') ?: [] as $file) {
|
||||
$name = basename(dirname($file));
|
||||
$row = $rows[$name] ?? ['active' => false, 'version' => '', 'activatedAt' => null, 'settings' => []];
|
||||
$state = ['active' => $row['active'], 'error' => null, 'activatedVersion' => $row['version'], 'activatedAt' => $row['activatedAt']];
|
||||
try {
|
||||
$manifest = $this->manifest($file, $name);
|
||||
// Die gespeicherten Einstellungen, ergänzt um die Vorgaben des Manifests.
|
||||
$values = [];
|
||||
foreach ($manifest['settings'] as $key => $schema) {
|
||||
$values[$key] = array_key_exists($key, $row['settings']) ? $row['settings'][$key] : $schema['default'];
|
||||
}
|
||||
$this->manifests[$name] = [...$manifest, ...$state, 'values' => $values];
|
||||
} catch (\RuntimeException $e) {
|
||||
// Ein kaputtes Manifest hält die anderen nicht auf: das Plugin erscheint in der
|
||||
// Verwaltung mit seinem Fehler und ist nie aktiv, egal was der Schalter sagt.
|
||||
$this->manifests[$name] = ['name' => $name, 'title' => $name, 'description' => '', 'version' => '', 'requires' => '', 'blocks' => [], 'settings' => [], 'routes' => [], 'presets' => [], ...$state, 'active' => false, 'error' => $e->getMessage(), 'values' => []];
|
||||
}
|
||||
}
|
||||
ksort($this->manifests);
|
||||
|
||||
return $this->manifests;
|
||||
}
|
||||
|
||||
/** @return array<string, array{active: bool, version: string, activatedAt: ?string, settings: array}> */
|
||||
private function rows(): array
|
||||
{
|
||||
if (null !== $this->rows) {
|
||||
return $this->rows;
|
||||
}
|
||||
$this->rows = [];
|
||||
try {
|
||||
foreach ($this->db->fetchAllAssociative('SELECT name, active, version, activated_at, settings FROM plugin') as $row) {
|
||||
$settings = json_decode((string) ($row['settings'] ?? '{}'), true);
|
||||
$this->rows[$row['name']] = ['active' => (bool) $row['active'], 'version' => (string) $row['version'], 'activatedAt' => $row['activated_at'], 'settings' => is_array($settings) ? $settings : []];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Vor der Migration gibt es die Tabelle nicht; dann ist kein Plugin aktiv, und die
|
||||
// Website läuft trotzdem.
|
||||
}
|
||||
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
public function activate(string $name): void
|
||||
{
|
||||
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
|
||||
if (null !== $plugin['error']) {
|
||||
throw new \InvalidArgumentException('Das Plugin lässt sich nicht einschalten: '.$plugin['error']);
|
||||
}
|
||||
$this->write($name, ['active' => true, 'version' => $plugin['version'], 'activated_at' => gmdate('Y-m-d H:i:s')]);
|
||||
}
|
||||
|
||||
public function deactivate(string $name): void
|
||||
{
|
||||
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
|
||||
$this->write($name, ['active' => false, 'version' => $plugin['version'], 'activated_at' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Einstellungen eines Plugins, geprüft gegen das Schema seines Manifests.
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function saveSettings(string $name, array $input): void
|
||||
{
|
||||
$plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.');
|
||||
if (null !== $plugin['error']) {
|
||||
throw new \InvalidArgumentException('Das Plugin lässt sich nicht einstellen: '.$plugin['error']);
|
||||
}
|
||||
try {
|
||||
$values = $this->validateAgainst($plugin['settings'], $input, true);
|
||||
} catch (InvalidDocument $e) {
|
||||
throw new \InvalidArgumentException($e->getMessage());
|
||||
}
|
||||
$this->write($name, ['settings' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]);
|
||||
}
|
||||
|
||||
/** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */
|
||||
public function settingsOf(string $name): array
|
||||
{
|
||||
return $this->all()[$name]['values'] ?? [];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $fields */
|
||||
private function write(string $name, array $fields): void
|
||||
{
|
||||
$existing = $this->rows()[$name] ?? null;
|
||||
if (null === $existing) {
|
||||
$this->db->insert('plugin', ['name' => $name, 'active' => false, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
|
||||
} else {
|
||||
$this->db->update('plugin', $fields, ['name' => $name]);
|
||||
}
|
||||
$this->manifests = null;
|
||||
$this->rows = null;
|
||||
}
|
||||
|
||||
/** Die Bausteine aller aktiven Plugins, nach Typ. @return array<string, array> */
|
||||
public function blocks(): array
|
||||
{
|
||||
$blocks = [];
|
||||
foreach ($this->all() as $plugin) {
|
||||
if (!$plugin['active'] || null !== $plugin['error']) {
|
||||
continue;
|
||||
}
|
||||
foreach ($plugin['blocks'] as $block) {
|
||||
$blocks[$block['type']] = $block;
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/** Die Definition eines Bausteins, wenn sein Plugin aktiv ist. */
|
||||
public function block(string $type): ?array
|
||||
{
|
||||
return $this->blocks()[$type] ?? null;
|
||||
}
|
||||
|
||||
/** Eine eigene Adresse eines aktiven Plugins, zum Beispiel /vergleich/daten - oder null. */
|
||||
public function route(string $path): ?array
|
||||
{
|
||||
$path = '/'.ltrim($path, '/');
|
||||
foreach ($this->all() as $plugin) {
|
||||
if (!$plugin['active'] || null !== $plugin['error']) {
|
||||
continue;
|
||||
}
|
||||
foreach ($plugin['routes'] as $route) {
|
||||
if ($route['path'] === $path) {
|
||||
return $route;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Eigenschaften eines Bausteins gegen sein Schema und gibt sie bereinigt zurück.
|
||||
*
|
||||
* Fehlende Eigenschaften bekommen ihren Vorgabewert; unbekannte werden ignoriert, damit ein
|
||||
* Plugin eine Eigenschaft streichen kann, ohne alte Seiten unlesbar zu machen. Was da ist,
|
||||
* muss passen - sonst InvalidDocument, wie bei jedem Kernbaustein.
|
||||
*
|
||||
* @throws InvalidDocument
|
||||
*/
|
||||
public function validateProps(string $type, array $props): array
|
||||
{
|
||||
$block = $this->block($type) ?? throw new InvalidDocument(sprintf('Der Baustein „%s“ gehört zu keinem aktiven Plugin.', $type));
|
||||
|
||||
return $this->validateAgainst($block['props'], $props, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Werte gegen ein Schema. Aus einem Formular der Verwaltung kommen Zahlen und Schalter als
|
||||
* Text - dann wird umgewandelt; aus einem Dokument müssen die Typen schon stimmen.
|
||||
*
|
||||
* @throws InvalidDocument
|
||||
*/
|
||||
private function validateAgainst(array $schemaMap, array $values, bool $fromForm): array
|
||||
{
|
||||
$clean = [];
|
||||
foreach ($schemaMap as $key => $schema) {
|
||||
$value = $values[$key] ?? ($fromForm && 'boolean' === $schema['kind'] ? false : $schema['default']);
|
||||
$label = $schema['label'];
|
||||
if ($fromForm) {
|
||||
if ('number' === $schema['kind'] && is_string($value)) {
|
||||
$value = is_numeric(str_replace(',', '.', trim($value))) ? str_replace(',', '.', trim($value)) + 0 : $value;
|
||||
}
|
||||
if ('boolean' === $schema['kind']) {
|
||||
$value = (bool) $value;
|
||||
}
|
||||
}
|
||||
switch ($schema['kind']) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'url':
|
||||
if (!is_string($value)) {
|
||||
throw new InvalidDocument(sprintf('„%s“ muss Text sein.', $label));
|
||||
}
|
||||
$value = trim($value);
|
||||
if (mb_strlen($value) > $schema['maxLength']) {
|
||||
throw new InvalidDocument(sprintf('„%s“ ist auf %d Zeichen begrenzt.', $label, $schema['maxLength']));
|
||||
}
|
||||
if ('url' === $schema['kind'] && '' !== $value && 1 !== preg_match('~^(/(?!/)|#[a-zA-Z]|https://|mailto:|tel:)~', $value)) {
|
||||
throw new InvalidDocument(sprintf('„%s“ muss ein lokaler Pfad, eine Sprungmarke oder eine HTTPS-Adresse sein.', $label));
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
if (!is_int($value) && !is_float($value)) {
|
||||
throw new InvalidDocument(sprintf('„%s“ muss eine Zahl sein.', $label));
|
||||
}
|
||||
if ($value < $schema['min'] || $value > $schema['max']) {
|
||||
throw new InvalidDocument(sprintf('„%s“ liegt zwischen %s und %s.', $label, $schema['min'], $schema['max']));
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (!is_bool($value)) {
|
||||
throw new InvalidDocument(sprintf('„%s“ ist an oder aus.', $label));
|
||||
}
|
||||
break;
|
||||
case 'select':
|
||||
if (!is_string($value) || !array_key_exists($value, $schema['choices'])) {
|
||||
throw new InvalidDocument(sprintf('„%s“ kennt diesen Wert nicht.', $label));
|
||||
}
|
||||
break;
|
||||
case 'media':
|
||||
if (!is_string($value) || ('' !== $value && 1 !== preg_match('/^[a-f0-9]{32}$/D', $value))) {
|
||||
throw new InvalidDocument(sprintf('„%s“ muss ein Bild aus der Mediathek sein.', $label));
|
||||
}
|
||||
break;
|
||||
}
|
||||
$clean[$key] = $value;
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** Was der Editor braucht: Typ, Beschriftung, Symbol, Container-Regeln und das Schema mit Vorgaben. @return list<array> */
|
||||
public function forEditor(): array
|
||||
{
|
||||
return array_values(array_map(static fn (array $block): array => [
|
||||
'type' => $block['type'], 'label' => $block['label'], 'icon' => $block['icon'], 'plugin' => $block['plugin'],
|
||||
'description' => $block['description'], 'props' => $block['props'],
|
||||
'container' => $block['container'], 'children' => $block['children'],
|
||||
], $this->blocks()));
|
||||
}
|
||||
|
||||
/** Die Seitenvorlagen der aktiven Plugins für den Werkzeugkasten des Editors. @return list<array> */
|
||||
public function presetsForEditor(): array
|
||||
{
|
||||
$presets = [];
|
||||
foreach ($this->all() as $plugin) {
|
||||
if (!$plugin['active'] || null !== $plugin['error']) {
|
||||
continue;
|
||||
}
|
||||
foreach ($plugin['presets'] as $preset) {
|
||||
$presets[] = ['id' => 'plugin:'.$plugin['name'].':'.$preset['name'], 'label' => $preset['label'], 'description' => $preset['description'], 'plugin' => $plugin['title'], 'blocks' => $preset['blocks']];
|
||||
}
|
||||
}
|
||||
|
||||
return $presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stylesheets und Skripte der Plugin-Bausteine, die in diesen Dokumenten vorkommen - jede
|
||||
* Datei einmal, in der Reihenfolge des ersten Auftretens.
|
||||
*
|
||||
* @param list<array> $documents
|
||||
*
|
||||
* @return array{styles: list<string>, scripts: list<string>}
|
||||
*/
|
||||
public function assetsFor(array $documents): array
|
||||
{
|
||||
$blocks = $this->blocks();
|
||||
if ([] === $blocks) {
|
||||
return ['styles' => [], 'scripts' => []];
|
||||
}
|
||||
$styles = [];
|
||||
$scripts = [];
|
||||
$walk = static function (array $list) use (&$walk, &$styles, &$scripts, $blocks): void {
|
||||
foreach ($list as $block) {
|
||||
$definition = $blocks[$block['type'] ?? ''] ?? null;
|
||||
if (null !== $definition) {
|
||||
if (null !== $definition['css']) {
|
||||
$styles[$definition['css']] = true;
|
||||
}
|
||||
if (null !== $definition['script']) {
|
||||
$scripts[$definition['script']] = true;
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return ['styles' => array_keys($styles), 'scripts' => array_keys($scripts)];
|
||||
}
|
||||
|
||||
/** Der Pfad einer Datei, die ein aktives Plugin ausliefern darf - oder null. */
|
||||
public function assetFile(string $plugin, string $relative): ?string
|
||||
{
|
||||
$definition = $this->all()[$plugin] ?? null;
|
||||
if (null === $definition || !$definition['active'] || null !== $definition['error']) {
|
||||
return null;
|
||||
}
|
||||
if (1 !== preg_match('~^assets/[a-zA-Z0-9_./-]{1,120}$~D', $relative) || str_contains($relative, '..')) {
|
||||
return null;
|
||||
}
|
||||
$extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION));
|
||||
if (!isset(self::ASSET_TYPES[$extension])) {
|
||||
return null;
|
||||
}
|
||||
$file = $this->directory().'/'.$plugin.'/'.$relative;
|
||||
|
||||
return is_file($file) ? $file : null;
|
||||
}
|
||||
|
||||
/** Liest und prüft ein Manifest. @throws \RuntimeException */
|
||||
private function manifest(string $file, string $name): array
|
||||
{
|
||||
if (1 !== preg_match(self::NAME, $name)) {
|
||||
throw new \RuntimeException('Der Ordnername darf nur Kleinbuchstaben und Ziffern enthalten und muss mit einem Buchstaben beginnen.');
|
||||
}
|
||||
try {
|
||||
$data = json_decode((string) file_get_contents($file), true, 24, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
throw new \RuntimeException('plugin.json ist kein gültiges JSON: '.$e->getMessage());
|
||||
}
|
||||
if (!is_array($data) || ($data['name'] ?? null) !== $name) {
|
||||
throw new \RuntimeException(sprintf('plugin.json muss "name": "%s" enthalten, so wie der Ordner heißt.', $name));
|
||||
}
|
||||
$title = trim((string) ($data['title'] ?? ''));
|
||||
if ('' === $title || mb_strlen($title) > 80) {
|
||||
throw new \RuntimeException('Das Manifest braucht einen Titel mit höchstens 80 Zeichen.');
|
||||
}
|
||||
$version = (string) ($data['version'] ?? '');
|
||||
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) {
|
||||
throw new \RuntimeException('Die Fassung des Plugins hat die Form 1.2.3.');
|
||||
}
|
||||
$requires = (string) ($data['requires'] ?? '');
|
||||
if ('' !== $requires) {
|
||||
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $requires)) {
|
||||
throw new \RuntimeException('"requires" nennt die kleinste KantSites-Fassung in der Form 1.2.3.');
|
||||
}
|
||||
$installed = trim((string) @file_get_contents($this->projectDir.'/VERSION'));
|
||||
if ('' !== $installed && version_compare($installed, $requires, '<')) {
|
||||
throw new \RuntimeException(sprintf('Braucht KantSites %s, installiert ist %s.', $requires, $installed));
|
||||
}
|
||||
}
|
||||
$blocks = [];
|
||||
foreach ((array) ($data['blocks'] ?? []) as $key => $block) {
|
||||
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($block)) {
|
||||
throw new \RuntimeException(sprintf('Der Bausteinname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
|
||||
}
|
||||
$blocks[$name.'.'.$key] = $this->blockDefinition($name, $key, $block, $version);
|
||||
}
|
||||
// Erlaubte Kinder eines Containers muessen Bausteine sein, die es gibt: Kernbausteine
|
||||
// oder Bausteine dieses Plugins.
|
||||
foreach ($blocks as $type => $block) {
|
||||
foreach ($block['children'] ?? [] as $child) {
|
||||
if (!isset($blocks[$child]) && !str_contains($child, '.') && 1 !== preg_match('/^[a-z]+$/D', $child)) {
|
||||
throw new \RuntimeException(sprintf('„%s“ erlaubt das Kind „%s“, das es nicht gibt.', $type, $child));
|
||||
}
|
||||
}
|
||||
}
|
||||
$settings = [];
|
||||
if (!is_array($data['settings'] ?? [])) {
|
||||
throw new \RuntimeException('"settings" ist kein Objekt.');
|
||||
}
|
||||
foreach ((array) ($data['settings'] ?? []) as $key => $schema) {
|
||||
$settings[$key] = $this->propSchema('Einstellungen', (string) $key, is_array($schema) ? $schema : []);
|
||||
}
|
||||
$routes = $this->routes($name, $data['routes'] ?? []);
|
||||
$presets = $this->presets($name, $data['presets'] ?? []);
|
||||
if ([] === $blocks && [] === $routes && [] === $presets) {
|
||||
throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.');
|
||||
}
|
||||
|
||||
return ['name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets];
|
||||
}
|
||||
|
||||
private function blockDefinition(string $plugin, string $key, array $block, string $version): array
|
||||
{
|
||||
$label = trim((string) ($block['label'] ?? ''));
|
||||
if ('' === $label || mb_strlen($label) > 60) {
|
||||
throw new \RuntimeException(sprintf('Der Baustein „%s“ braucht eine Beschriftung mit höchstens 60 Zeichen.', $key));
|
||||
}
|
||||
$template = (string) ($block['template'] ?? '');
|
||||
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+\.html\.twig$~D', $template) || str_contains($template, '..') || !is_file($this->directory().'/'.$plugin.'/'.$template)) {
|
||||
throw new \RuntimeException(sprintf('Die Vorlage „%s“ des Bausteins „%s“ fehlt im Pluginordner.', $template, $key));
|
||||
}
|
||||
$assets = [];
|
||||
foreach (['css', 'script'] as $art) {
|
||||
$relative = $block[$art] ?? null;
|
||||
if (null === $relative || '' === $relative) {
|
||||
$assets[$art] = null;
|
||||
continue;
|
||||
}
|
||||
$extension = 'css' === $art ? 'css' : 'js';
|
||||
if (!is_string($relative) || 1 !== preg_match('~^assets/[a-zA-Z0-9_./-]+\.'.$extension.'$~D', $relative) || str_contains($relative, '..') || !is_file($this->directory().'/'.$plugin.'/'.$relative)) {
|
||||
throw new \RuntimeException(sprintf('Die Datei „%s“ des Bausteins „%s“ fehlt oder liegt nicht unter assets/.', (string) $relative, $key));
|
||||
}
|
||||
$assets[$art] = '/plugin-assets/'.$plugin.'/'.$relative.'?v='.$version;
|
||||
}
|
||||
$props = [];
|
||||
if (!is_array($block['props'] ?? [])) {
|
||||
throw new \RuntimeException(sprintf('"props" des Bausteins „%s“ ist kein Objekt.', $key));
|
||||
}
|
||||
if (count($block['props'] ?? []) > self::MAX_PROPS) {
|
||||
throw new \RuntimeException(sprintf('Der Baustein „%s“ hat mehr als %d Eigenschaften.', $key, self::MAX_PROPS));
|
||||
}
|
||||
foreach ($block['props'] ?? [] as $name => $schema) {
|
||||
$props[$name] = $this->propSchema($key, (string) $name, is_array($schema) ? $schema : []);
|
||||
}
|
||||
// Ein Container nimmt Kinder auf - alle Bausteine oder nur die genannten.
|
||||
$container = (bool) ($block['container'] ?? false);
|
||||
$children = null;
|
||||
if ($container && isset($block['children'])) {
|
||||
if (!is_array($block['children']) || [] === $block['children']) {
|
||||
throw new \RuntimeException(sprintf('"children" des Bausteins „%s“ ist eine Liste erlaubter Bausteintypen.', $key));
|
||||
}
|
||||
$children = [];
|
||||
foreach ($block['children'] as $child) {
|
||||
$child = (string) $child;
|
||||
if (1 !== preg_match('/^[a-z]+(?:\.[a-z][a-z0-9-]{1,40})?$/D', $child)) {
|
||||
throw new \RuntimeException(sprintf('„%s“ ist kein Bausteintyp.', $child));
|
||||
}
|
||||
$children[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $plugin.'.'.$key, 'plugin' => $plugin, 'label' => $label,
|
||||
'description' => mb_substr(trim((string) ($block['description'] ?? '')), 0, 200),
|
||||
'icon' => mb_substr(trim((string) ($block['icon'] ?? '')), 0, 2) ?: '⬡',
|
||||
'template' => '@plugins/'.$plugin.'/'.$template,
|
||||
'css' => $assets['css'], 'script' => $assets['script'],
|
||||
'props' => $props,
|
||||
'container' => $container, 'children' => $children,
|
||||
];
|
||||
}
|
||||
|
||||
/** Eigene Adressen: Pfad wie eine Seite, Vorlage im Pluginordner, Antwortart aus der Liste. */
|
||||
private function routes(string $plugin, mixed $input): array
|
||||
{
|
||||
if (!is_array($input)) {
|
||||
throw new \RuntimeException('"routes" ist kein Objekt.');
|
||||
}
|
||||
$routes = [];
|
||||
foreach ($input as $key => $route) {
|
||||
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($route)) {
|
||||
throw new \RuntimeException(sprintf('Der Adressname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
|
||||
}
|
||||
$path = (string) ($route['path'] ?? '');
|
||||
if (1 !== preg_match(self::ROUTE_PATH, $path)) {
|
||||
throw new \RuntimeException(sprintf('Die Adresse „%s“ hat nicht die Form /name oder /name/unterseite.', $path));
|
||||
}
|
||||
// Was der Kern selbst beantwortet, darf ein Plugin nicht belegen.
|
||||
if (1 === preg_match('~^/(admin|media|assets|plugin-assets|formular|kategorie|schlagwort|beitraege|einwilligung)(/|$)~', $path)) {
|
||||
throw new \RuntimeException(sprintf('Die Adresse „%s“ gehört dem Kern.', $path));
|
||||
}
|
||||
$template = (string) ($route['template'] ?? '');
|
||||
if (1 !== preg_match('~^[a-zA-Z0-9_/-]+(?:\.[a-z]+)?\.twig$~D', $template) || str_contains($template, '..') || !is_file($this->directory().'/'.$plugin.'/'.$template)) {
|
||||
throw new \RuntimeException(sprintf('Die Vorlage „%s“ der Adresse „%s“ fehlt im Pluginordner.', $template, $key));
|
||||
}
|
||||
$type = (string) ($route['type'] ?? 'html');
|
||||
if (!isset(self::ROUTE_TYPES[$type])) {
|
||||
throw new \RuntimeException(sprintf('Die Antwortart „%s“ der Adresse „%s“ ist keine von html, json, text, xml.', $type, $key));
|
||||
}
|
||||
$routes[$key] = ['name' => $key, 'plugin' => $plugin, 'path' => $path, 'template' => '@plugins/'.$plugin.'/'.$template, 'type' => $type, 'contentType' => self::ROUTE_TYPES[$type]];
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/** Seitenvorlagen: Bausteine, die der Editor als Ganzes einfügt. Geprüft wird beim Einfügen wie jedes Dokument. */
|
||||
private function presets(string $plugin, mixed $input): array
|
||||
{
|
||||
if (!is_array($input)) {
|
||||
throw new \RuntimeException('"presets" ist kein Objekt.');
|
||||
}
|
||||
$presets = [];
|
||||
foreach ($input as $key => $preset) {
|
||||
if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($preset)) {
|
||||
throw new \RuntimeException(sprintf('Der Vorlagenname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key));
|
||||
}
|
||||
$label = trim((string) ($preset['label'] ?? ''));
|
||||
if ('' === $label || mb_strlen($label) > 60) {
|
||||
throw new \RuntimeException(sprintf('Die Vorlage „%s“ braucht eine Beschriftung mit höchstens 60 Zeichen.', $key));
|
||||
}
|
||||
if (!is_array($preset['blocks'] ?? null) || !array_is_list($preset['blocks']) || [] === $preset['blocks']) {
|
||||
throw new \RuntimeException(sprintf('Die Vorlage „%s“ braucht eine Liste von Bausteinen unter "blocks".', $key));
|
||||
}
|
||||
if (strlen(json_encode($preset['blocks'], JSON_THROW_ON_ERROR)) > 65536) {
|
||||
throw new \RuntimeException(sprintf('Die Vorlage „%s“ ist größer als 64 KiB.', $key));
|
||||
}
|
||||
$presets[] = ['name' => $key, 'label' => $label, 'description' => mb_substr(trim((string) ($preset['description'] ?? '')), 0, 200), 'blocks' => $preset['blocks']];
|
||||
}
|
||||
|
||||
return $presets;
|
||||
}
|
||||
|
||||
private function propSchema(string $block, string $name, array $schema): array
|
||||
{
|
||||
if (1 !== preg_match('/^[a-z][a-zA-Z0-9]{0,31}$/D', $name)) {
|
||||
throw new \RuntimeException(sprintf('Die Eigenschaft „%s“ von „%s“ braucht einen Namen aus Buchstaben und Ziffern.', $name, $block));
|
||||
}
|
||||
$kind = (string) ($schema['kind'] ?? 'text');
|
||||
if (!in_array($kind, self::KINDS, true)) {
|
||||
throw new \RuntimeException(sprintf('Die Eigenschaft „%s“ von „%s“ hat die unbekannte Art „%s“.', $name, $block, $kind));
|
||||
}
|
||||
$label = trim((string) ($schema['label'] ?? $name));
|
||||
$clean = ['kind' => $kind, 'label' => mb_substr($label, 0, 80), 'help' => mb_substr(trim((string) ($schema['help'] ?? '')), 0, 200)];
|
||||
switch ($kind) {
|
||||
case 'text':
|
||||
case 'url':
|
||||
$clean['maxLength'] = max(1, min(2000, (int) ($schema['maxLength'] ?? 200)));
|
||||
$clean['default'] = mb_substr((string) ($schema['default'] ?? ''), 0, $clean['maxLength']);
|
||||
break;
|
||||
case 'textarea':
|
||||
$clean['maxLength'] = max(1, min(10000, (int) ($schema['maxLength'] ?? 2000)));
|
||||
$clean['default'] = mb_substr((string) ($schema['default'] ?? ''), 0, $clean['maxLength']);
|
||||
break;
|
||||
case 'number':
|
||||
$clean['min'] = is_numeric($schema['min'] ?? null) ? $schema['min'] + 0 : 0;
|
||||
$clean['max'] = is_numeric($schema['max'] ?? null) ? $schema['max'] + 0 : 1000;
|
||||
if ($clean['min'] >= $clean['max']) {
|
||||
throw new \RuntimeException(sprintf('Bei „%s“ von „%s“ muss min unter max liegen.', $name, $block));
|
||||
}
|
||||
$default = is_numeric($schema['default'] ?? null) ? $schema['default'] + 0 : $clean['min'];
|
||||
$clean['default'] = max($clean['min'], min($clean['max'], $default));
|
||||
break;
|
||||
case 'boolean':
|
||||
$clean['default'] = (bool) ($schema['default'] ?? false);
|
||||
break;
|
||||
case 'select':
|
||||
$choices = [];
|
||||
foreach ((array) ($schema['choices'] ?? []) as $value => $text) {
|
||||
$value = (string) $value;
|
||||
if ('' !== $value && mb_strlen($value) <= 40) {
|
||||
$choices[$value] = mb_substr(trim((string) $text), 0, 60) ?: $value;
|
||||
}
|
||||
}
|
||||
if (count($choices) < 2) {
|
||||
throw new \RuntimeException(sprintf('Die Auswahl „%s“ von „%s“ braucht mindestens zwei Werte.', $name, $block));
|
||||
}
|
||||
$clean['choices'] = $choices;
|
||||
$default = (string) ($schema['default'] ?? array_key_first($choices));
|
||||
$clean['default'] = array_key_exists($default, $choices) ? $default : array_key_first($choices);
|
||||
break;
|
||||
case 'media':
|
||||
$clean['default'] = '';
|
||||
break;
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,8 @@ final class UserDirectory
|
||||
|
||||
public const ROLE_HELP = [
|
||||
'ROLE_ADMIN' => 'Darf alles, einschließlich Benutzer, Design, Einstellungen, eigene Skripte und endgültiges Löschen.',
|
||||
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, aber keine Benutzer verwalten und keine Skripte setzen.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe bearbeiten und speichern, aber nicht veröffentlichen.',
|
||||
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, dazu Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite – aber keine Benutzer, kein Design, keine Unternehmensdaten und keine Skripte.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen.',
|
||||
];
|
||||
|
||||
/** Bcrypt silently ignores everything after 72 bytes, so that is the honest upper limit. */
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Ob der Kopfbereich seine Fläche selbst gestaltet.
|
||||
*
|
||||
* Der klebende Kopf malt eine weiße Fläche mit Schattenlinie, damit ein Kopf ohne eigene
|
||||
* Gestaltung beim Scrollen nicht über dem Inhalt durchscheint. Gibt eine Sektion des Kopfbereichs
|
||||
* aber selbst einen Hintergrund vor - normal, beim Überfahren oder beim Scrollen -, lag diese
|
||||
* Fläche dahinter: eine durchsichtige Sektion zeigte Weiß statt Seite, und die Linie ließ sich
|
||||
* weder färben noch abschalten. Dann trägt die Sektion die Fläche allein.
|
||||
*
|
||||
* Eine Funktion statt einer Variablen, weil vier Wege den Kopf ausgeben - Seite, Archiv,
|
||||
* Editor-Vorschau und Bereichs-Vorschau - und keiner davon vergessen werden darf.
|
||||
*/
|
||||
final class HeaderExtension extends AbstractExtension
|
||||
{
|
||||
/** Die Eigenschaften, die eine Fläche ausmachen. Eine Textfarbe allein ist keine. */
|
||||
private const SURFACE = ['backgroundColor', 'gradientFrom', 'gradientTo', 'backgroundImage'];
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [new TwigFunction('kant_header_surface', self::ownSurface(...))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur Sektionen der obersten Ebene zählen: eine Spalte mit weißem Kasten um das Logo macht
|
||||
* noch keinen Kopf, und ohne Fläche dahinter schiene der Rest über dem Inhalt durch.
|
||||
*/
|
||||
public static function ownSurface(?array $document): bool
|
||||
{
|
||||
foreach ($document['blocks'] ?? [] as $block) {
|
||||
if ('section' !== ($block['type'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($block['style'] ?? [] as $values) {
|
||||
if (!is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
foreach ([$values, $values['hover'] ?? [], $values['scrolled'] ?? []] as $state) {
|
||||
foreach (self::SURFACE as $key) {
|
||||
if (is_array($state) && '' !== (string) ($state[$key] ?? '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user