Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fb53c1fc | ||
|
|
24f98633d0 | ||
|
|
e47f278a13 | ||
|
|
e8a9dcdf5c | ||
|
|
04927b9563 | ||
|
|
3b1dfc0948 | ||
|
|
e21e800f2c | ||
|
|
d8c8cbe7bd | ||
|
|
45d1141951 | ||
|
|
42e74af052 | ||
|
|
106f84dfd5 | ||
|
|
6632836580 | ||
|
|
d0019c80af | ||
|
|
ab9e6a7ea0 | ||
|
|
8328bf0964 | ||
|
|
245f189d57 | ||
|
|
99b3aba39a | ||
|
|
c91f996524 | ||
|
|
0ed4c83e41 | ||
|
|
9e0365f841 | ||
|
|
ff2b57db58 | ||
|
|
741eaa80f7 | ||
|
|
23fdca4edb | ||
|
|
a8368068aa | ||
|
|
f4ec0d9877 | ||
|
|
d08d6f648a | ||
|
|
4539f357c1 | ||
|
|
d885b3ddb5 | ||
|
|
59a40157a4 | ||
|
|
6b1aa50d02 | ||
|
|
30aac4389d | ||
|
|
53fb713f74 | ||
|
|
c8a8fa7460 | ||
|
|
66ff68abf8 |
No files matched your search
+62
-2
@@ -25,9 +25,9 @@
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$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"
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-a1c96276da5c/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=24M -d post_max_size=26M -d memory_limit=256M -S 127.0.0.1:8092 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8091
|
||||
"port": 8092
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
@@ -68,6 +68,66 @@
|
||||
"$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": "ebsolar",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8096'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8096 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8096
|
||||
},
|
||||
{
|
||||
"name": "ebsolar-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8097'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8097 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8097
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"name": "kantarci-immobilien",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8098'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8098 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8098
|
||||
},
|
||||
{
|
||||
"name": "kantarci-immobilien-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8099'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8099 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8099
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,23 @@
|
||||
# KantSites
|
||||
|
||||
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. Vier Websites laufen lokal als eigene Installationen (Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Malerservice Drews), `web.kant-it.de` und das Drews-Hosting mit Releasearchiven.
|
||||
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`.
|
||||
|
||||
## Bildecken unter Design (ab 1.21.0)
|
||||
|
||||
Wie stark die Ecken der Bilder gerundet sind, steht jetzt unter **Design → Bildecken** und gilt für die ganze Website – Bilder, Galerien, Logoleisten und Beitragslisten. Vorgabe ist `12px` wie bisher, `0px` macht sie eckig. Ein einzelnes Bild lässt sich davon abweichend einstellen: im Editor beim ausgewählten Bild unter **Design → Bild → Bildecken**, dort auch je Gerät und je Ecke. Vorher stand die Rundung fest im Stylesheet und war in keinem Feld zu finden.
|
||||
|
||||
## Kaufmännisches Und und Zeilenumbrüche bleiben, wie sie sind (ab 1.20.1)
|
||||
|
||||
**Behoben.** Ein Text ohne Auszeichnung wurde beim Speichern doppelt kodiert: aus `&` wurde `&`, beim nächsten Speichern `&amp;` – irgendwann stand die Entity sichtbar auf der Seite. Ebenso wuchsen Zeilenumbrüche: jedes zweite Speichern machte aus einem `<br>` zwei. Beides ist behoben, und wiederholtes Speichern ändert einen Text jetzt nicht mehr. Was bereits falsch gespeichert ist, räumt `php bin/console cms:repair:entities` auf: ohne Option zeigt er nur, was sich ändern würde, mit `--schreiben` wendet er es an. Angefasst werden die veröffentlichte Fassung und der Entwurf jeder Seite sowie die Bereiche; ältere Fassungen bleiben Historie.
|
||||
|
||||
## Eigenes HTML und eingebettete Seiten (ab 1.20.0)
|
||||
|
||||
Der Baustein **Eigenes HTML** (Werkzeugkasten, Gruppe „Seite und Daten“) nimmt Markup auf, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. Ändern darf ihn nur ein **Administrator**, genau wie das Feld „Eigenes Skript“; für alle anderen ist er sichtbar und schreibgeschützt, und der Server weist eine Änderung ohnehin ab. Zwei Tags nimmt er nicht an: `<script>` gehört unter „Erweitert“ in „Eigenes Skript“, und ein `<iframe>` in den Baustein **Externer Inhalt** – nur der fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein (ADR 106).
|
||||
|
||||
Am eingebetteten Rahmen lässt sich alles gestalten, was dieser Website gehört: Höhe als Eigenschaft, Abstände, Breite, Rand und Ecken über Design-Tokens und freie Stile, dazu eigenes CSS am Block (`& iframe{…}`) und für den Platzhalter vor der Einwilligung (`&.embed-blocked`, `& .embed-title`, `& .consent-button`). **Was im Rahmen steht, gehört dem Anbieter** – daran kommt kein CSS dieser Website heran, das trennt der Browser zwischen zwei Herkünften. Den Einwilligungsbalken selbst gestaltet man unter **Einstellungen** (Form und vier Farben).
|
||||
|
||||
## 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.
|
||||
@@ -16,6 +30,18 @@ Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`.
|
||||
|
||||
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.
|
||||
@@ -258,7 +284,7 @@ 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`.
|
||||
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. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. 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.
|
||||
|
||||
@@ -347,6 +373,18 @@ Der Onepager „Tagespflege Jacqueline Moldovan“ liegt in `var/tagespflege-mol
|
||||
|
||||
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.
|
||||
|
||||
### Sechste lokale Referenzwebsite: EB Solar
|
||||
|
||||
Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden) liegt in `var/ebsolar.db` mit Medien in `var/ebsolar-media`; die Startkonfiguration `ebsolar` in `.claude/launch.json` nutzt Port 8096, `ebsolar-prod` denselben Bestand im Produktionsmodus auf Port 8097 zum Messen. `scripts/seed-ebsolar.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als wiederverwendbare Sektion, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge mit Galerie und Bildbetrachter, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `solarcheck` (acht Schritte wie das Original auf ebsolar.de, die fünf Auswahlfragen als Bildkarten – gezeichnet und gerastert von `node scripts/build-ebsolar-options.mjs`); `sh scripts/reset-ebsolar.sh [Bilderordner]` (Standard `var/ebsolar-assets`: Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos und `projects.json` 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.
|
||||
|
||||
### Siebte lokale Referenzwebsite: KANTARCI Immobilien (Landingpage)
|
||||
|
||||
Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien GmbH, Baden-Baden) liegt in `var/kantarci-immobilien.db` mit Medien in `var/kantarci-immobilien-media`; die Startkonfiguration `kantarci-immobilien` in `.claude/launch.json` nutzt Port 8098, `kantarci-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8099 zum Messen. `sh scripts/reset-kantarci-immobilien.sh [Bilderordner]` (Standard `var/kantarci-immobilien-assets` mit `kantarci-banner.jpeg` aus dem gelieferten Paket) schneidet Logo, Hausmotiv und Seitensymbol aus dem Banner (`scripts/build-kantarci-assets.php`), zeichnet die Bildkarten der Mietanfrage (`node scripts/build-kantarci-options.mjs`), wirft die Installation weg und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` (drei Schritte mit Bildkarten und Zusammenfassung) auf. Schriften sind Arial und Georgia, es gibt nichts 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`.
|
||||
@@ -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
|
||||
|
||||
@@ -38,6 +38,9 @@ services:
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Content\DocumentLibrary:
|
||||
arguments:
|
||||
$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.
|
||||
@@ -79,6 +82,11 @@ services:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
# Eigene kurze Zeitgrenzen statt des Download-Dienstes mit denen fuer Releasearchive.
|
||||
App\Ops\OriginCheck:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$download: null
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -135,3 +143,11 @@ services:
|
||||
App\EventSubscriber\QueryHeaderSubscriber:
|
||||
arguments:
|
||||
$environment: '%kernel.environment%'
|
||||
|
||||
App\Plugin\PluginRegistry:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Twig\PluginExtension:
|
||||
arguments:
|
||||
$loader: '@twig.loader.native_filesystem'
|
||||
+106
-2
@@ -2,6 +2,110 @@
|
||||
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026)
|
||||
|
||||
Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte.
|
||||
|
||||
**Den Rahmen gab es schon.** Der Baustein `embed` („Externer Inhalt“, Schema v14) bettet eine fremde Seite ein: Adresse, Titel, Anbieter, Höhe – und davor die Einwilligungsfrage. Vor der Einwilligung wird nichts geladen, kein Bild und keine Anfrage; erst danach kommt der Host in `frame-src` der Sicherheitsrichtlinie. Ein `<iframe>`, das jemand als rohes Markup einfügt, hätte beides nicht: keine Frage, und der Browser lädt es gar nicht erst, weil `frame-src` den Host nicht kennt. Deshalb ist `<iframe>` im HTML-Baustein eine Fehlermeldung mit dem Weg zum richtigen Baustein – nicht etwas, das man einfügen kann und das dann schweigend nichts tut. Dasselbe gilt für `<script>`: das Feld „Eigenes Skript“ unter „Erweitert“ liefert es als eigene Datei aus, inline würde die Richtlinie es ohnehin blockieren.
|
||||
|
||||
**Der Baustein (Schema v24).** `html` mit einer einzigen Eigenschaft: das Markup, bis 20.000 Zeichen, unverändert ausgegeben. Es gibt keine Filterliste – wer ihn setzen darf, ist die eigentliche Antwort, und die ist dieselbe wie beim eigenen Skript: **nur ein Administrator**. Geprüft wird wie dort gegen den gespeicherten Stand (`CustomCode::assertHtmlUnchanged()`), in beiden Controllern und auch beim Zurückholen einer alten Fassung – ein Redakteur kann eine Seite mit HTML also weiter bearbeiten, ohne das HTML anfassen zu können. Im Editor ist das Feld für ihn sichtbar und schreibgeschützt, wie das Skriptfeld.
|
||||
|
||||
**Warum keine Filterung wie bei Rich Text.** Genau die Fälle, für die man den Baustein nimmt – ein fremdes Widget, eine eigene Tabelle, eine Auszeichnung, die es als Baustein nicht gibt – fallen durch jede Whitelist. Ein gefilterter HTML-Baustein hätte den Wunsch nicht erfüllt, sondern nur verschoben.
|
||||
|
||||
**Folgen.** `README.md` nennt den Baustein und seine Rolle. Nicht gebaut: eine Vorschau, die fremde Skripte ausführt (die Editorvorschau führt keine aus, das bleibt so), und eine Ausnahme von der Administratorregel für einzelne Seiten.
|
||||
|
||||
## 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.
|
||||
@@ -24,7 +128,7 @@ Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
**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.
|
||||
**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. Seit 1.21.8 lässt sie sich dort auch ändern, wie die „WordPress-Adresse“: Das Feld schreibt über `Environment::write` in `.env`, aber erst nachdem `OriginCheck` die Installation unter der neuen Adresse erreicht hat (`GET /.well-known/kantsites-origin` liefert einen HMAC aus `APP_SECRET`). Eine Umstellung auf www ohne DNS oder Zertifikat würde sonst jede Seite samt Verwaltung ins Leere leiten. „Trotzdem übernehmen“ erscheint erst nach einer Ablehnung. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -950,7 +1054,7 @@ Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon is
|
||||
|
||||
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
|
||||
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein quadratischer Ausschnitt in 32 px (Reiter), 180 px (Startbildschirm) sowie 48 und 192 px (Google nimmt nur Vielfache von 48), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png`, `/favicon-192.png`, `/favicon.ico` (48 px) und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt. Auch `/favicon.svg` beantwortet die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen, mit eigenem ein 302 auf das 192er – als Datei in `public/` hätte der Webserver immer das mitgelieferte Zeichen ausgeliefert, und Suchmaschinen hätten es behalten.
|
||||
|
||||
Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
|
||||
|
||||
|
||||
+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).*
|
||||
|
||||
|
||||
+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.
|
||||
@@ -1,5 +1,9 @@
|
||||
# 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/`.
|
||||
|
||||
+260
-1
@@ -1,5 +1,264 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.22.1: vergrößerbare Galerie mit einem Haken (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bei Klick vergrößern bei Galerie muss man da alles einzeln machen? kann man bei Galerie das mit einem Klick für alle elemente machen?“
|
||||
|
||||
- Der Haken „Bilder vergrößerbar“ an der Galerie gilt jetzt für jedes Bild darin, das unter „Beim Klick“ nichts selbst gewählt hat. Das gilt auch für später hinzugefügte Bilder. Bisher schaltete er nur das Blättern im Popup ein, und jedes Bild musste einzeln auf „Bild vergrößern“ gestellt werden.
|
||||
- Eine eigene Wahl geht vor: „Nichts tun“ bleibt still, ein Link bleibt ein Link. „Nie eingestellt“ und „Nichts tun“ sind im Dokument unterscheidbar, weil der Editor `click` erst beim Ändern speichert. Bestehende Galerien ändern sich deshalb nur dort, wo der Haken gesetzt ist und ein Bild noch nie eingestellt wurde.
|
||||
- Der Editor zeigt bei einem solchen Bild „Bild vergrößern“ als aktuelle Wahl, nach derselben Regel wie die Vorlage (`galleryZoom` in `blocks/block.html.twig`).
|
||||
|
||||
**Geprüft:** neuer Test `CmsTest::testAZoomableGalleryMakesEveryImageWithoutItsOwnChoiceZoomable` (ohne Wahl, „Nichts tun“, Link, Galerie ohne Haken), volle Kette vor dem Release.
|
||||
|
||||
## PDFs in der Mediathek, als Linkziel und im Betrachter (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „kann man aktuell PDFs hochladen und als Button verlinken? … und ein Inline PDF Viewer wie Bild-Popup“, danach: „ja setz um, einfacher Browser-Betrachter“.
|
||||
|
||||
- **Hochladen:** Das Upload-Feld der Mediathek nimmt jetzt neben JPEG, PNG und WebP auch PDFs bis 20 MB an. Ob eine Datei ein PDF ist, entscheidet ihr Inhalt (`%PDF-` in den ersten 1024 Bytes), nicht die Endung. Die PDFs stehen in einer eigenen Tabelle `document` (Migration `20260917000100`) und nicht in `media`. Jede Bildauswahl erwartet dort Varianten mit Breite und Höhe. Die Datei liegt unverändert als `<id>.pdf` im Medienordner und geht deshalb mit jeder Sicherung mit. Die Mediathek zeigt die PDFs unter den Bildern, mit Titel und Archivieren.
|
||||
- **Ausliefern:** `/media/<id>.pdf` kommt mit `Content-Type: application/pdf`, `nosniff`, `inline` und dem Titel als Dateinamen, `X-Robots-Tag: noindex` und `frame-ancestors 'self'`: Einbetten darf es nur die eigene Website. Archivierte PDFs bleiben erreichbar, wie Bilder.
|
||||
- **Verlinken:** Jedes Linkziel (Button, Bild, Symbolkarte, CTA, Symbol) hat den Knopf „PDF aus Mediathek wählen“ mit einer Liste, Suche und Hochladen direkt im Dialog. Ins Feld kommt die Adresse `/media/<id>.pdf`, ein gewöhnlicher lokaler Pfad, also ohne neue Schemaversion.
|
||||
- **Betrachter:** `public/assets/lightbox.js` (dieselbe Datei wie der Bildbetrachter, damit eine Seite kein Skript mehr lädt) zeigt ein verlinktes PDF in einem `<dialog>` mit `<iframe>` und nutzt dafür den PDF-Betrachter des Browsers, dazu Herunterladen, „Im neuen Tab“ und Schließen. Wo der Browser keinen eingebauten Betrachter hat (`navigator.pdfViewerEnabled`) oder das Gerät ein Touchgerät ist, öffnet sich das PDF im neuen Tab: Android-Chrome hat keinen Betrachter, iOS-Safari zeigt im Rahmen nur die erste Seite. Ist „In neuem Tab öffnen“ gesetzt oder das Skript nicht geladen, bleibt es ein normaler Link. Skript und `frame-src 'self'` kommen nur auf Seiten, deren Dokumente (auch Kopf, Fuß, Sektionen und Fließtext) auf ein PDF der Mediathek zeigen.
|
||||
- **Grenzen:** Hochgeladene PDFs sind über ihre Adresse öffentlich. Kein PDF.js, also sieht der Betrachter je nach Browser anders aus. Chrome zeigt im Kopf des Betrachters die Kennung aus der Adresse, nicht den Titel. PDFs werden nicht gelöscht, nur archiviert.
|
||||
|
||||
**Geprüft:** zwei neue Tests (`CmsTest::testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer`, `EditorSecurityTest::testPdfsGoThroughTheMediaUploadAndTheEditorPicker`: Ablehnung von Nicht-PDFs, Kopfzeilen, 404, Skript und Richtlinie nur mit PDF-Link, Formular-Upload, API mit Token, Archiv). Im Browser mit der Pilotkopie: Klick auf den Button öffnet das zweiseitige PDF im Popup, Schließen gibt den Fokus an den Button zurück, bei 375 px mit Touch öffnet es einen neuen Tab. Der Auswahldialog im Editor ist nur per Syntaxprüfung geprüft, nicht im Browser. Dafür wäre eine Anmeldung nötig.
|
||||
|
||||
## Siebte Installation: Landingpage „KANTARCI Immobilien – Mietanfrage“ (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „neues Projekt wir brauchen eine einfache landingpage nach gleichem prinzip. einfache landingpage impressum und datenschutz notwendig? baus ein“ – dazu das Paket `KANTARCI-Immobilien-Landingpage.zip` (Next.js/Vinext für Cloudflare mit D1-Datenbank: Kopf, dunkler Hero, Werteleiste, Mietanfrage, Fuß, ein Banner als einziges Bild). Später: „Formular gefällt mir noch nicht kannst du ein multipage formular machen ggfls mit icon checkbox“ und die Anbieterdaten (Geschäftsführer Ridvan Kantarci, Ooser Hauptstr. 51, 76532 Baden-Baden, info@kantarci-immo.de).
|
||||
|
||||
**Neu.** Datenbank `var/kantarci-immobilien.db`, Medien `var/kantarci-immobilien-media`, Bildquellen `var/kantarci-immobilien-assets` (das Banner, daraus zugeschnitten Logo, Hausmotiv und Seitensymbol, dazu `options/` mit 14 Bildkarten), Startkonfigurationen `kantarci-immobilien` (Port 8098, dev) und `kantarci-immobilien-prod` (Port 8099). `sh scripts/reset-kantarci-immobilien.sh` schneidet die Bilder zu (`scripts/build-kantarci-assets.php`, GD), zeichnet die Bildkarten (`node scripts/build-kantarci-options.mjs`, Chromium), migriert und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf, Fuß, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` auf. Schriften Arial und Georgia (System).
|
||||
|
||||
- **Impressum und Datenschutz sind Pflicht:** geschäftlicher Auftritt (§ 5 DDG) und ein Formular mit personenbezogenen Daten (Art. 13 DSGVO). Offen und im Text als „NOCH ZU PRÜFEN“ markiert: Handelsregister, Telefonnummer, USt-IdNr., Erlaubnis nach § 34c GewO (nur bei Vermittlung/Verwaltung für Dritte), Urheber des Hausfotos, Hosting- und Mail-Auftragsverarbeiter, Löschfrist.
|
||||
- **Mietanfrage in drei Schritten** mit Zusammenfassung: Haushalt (Bildkarten Allein, Zu zweit, Familie mit Kindern, Wohngemeinschaft; Personenzahl nur bei den beiden letzten), Wohnwünsche (Zimmer als Bildkarten, Ausstattung als Mehrfachauswahl mit sechs Bildkarten, Einzug, Warmmiete, Objekt), Kontaktdaten. Der Einzug ist eine Auswahl statt eines Datums – der Formularbaukasten kennt kein Datumsfeld.
|
||||
- **E-Mail:** Die Vorlage nennt `immo@kantarci-immobilien.de`, die Domain existiert aber nicht (kein DNS, 18.09.2026). Überall steht jetzt `info@kantarci-immo.de` (MX vorhanden), auch als Empfänger der Anfrage.
|
||||
- **CMS-Lücken beim Aufbau:** eigenes CSS kennt kein `@media` – Raster, die am Telefon einspaltig werden sollen, laufen über `repeat(auto-fill,minmax(…))`. Verschachtelte Selektoren erlauben keine Umlaute, also auch kein `[data-label="Gewünschter Einzug"]` (Feld heißt deshalb „Einzugstermin“). Die Zusammenfassung ist selbst ein `.form-step`; wer die Schritte zweispaltig setzt, muss `.form-summary-body` über beide Spalten legen. `<small>` in einem Textbaustein wird entfernt. Die Fehlermeldung des Formulars duzt („Bitte prüfe …“), die Seite siezt.
|
||||
|
||||
**Geprüft:** Aufbau per Reset-Skript fehlerfrei; Startseite, Impressum, Datenschutz 200, unbekannte Adresse 404; im Browser (1280 px und 375 px, kein seitliches Scrollen) alle drei Schritte, Bedingung Personenzahl, Zusammenfassung und Absenden – die Anfrage liegt vollständig in `form_submission`. Mailversand ist lokal nicht eingerichtet. Lighthouse und axe noch nicht gelaufen.
|
||||
|
||||
## Adresse der Website in der Verwaltung (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: „ja bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird und übernommen wird“ – mit oder ohne www wie die „WordPress-Adresse“, statt nur über `cms:site:origin` oder die `.env` von Hand.
|
||||
|
||||
- **System → Diese Installation → Adresse** hat ein Feld „Adresse der Website“ und einen Link, der direkt auf die andere Schreibweise (mit/ohne www) umstellt. Gespeichert wird `SITE_ORIGIN` in `.env` über `Environment::write`. Das gilt ab der nächsten Anfrage, weil `%env()%` zur Laufzeit gelesen wird. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld, und ein POST wird abgewiesen.
|
||||
- **Schutz vor dem Aussperren:** Vor dem Speichern fragt die Installation sich selbst unter der neuen Adresse an (`OriginCheck`, `GET /.well-known/kantsites-origin`). Zurückkommen muss ein HMAC aus `APP_SECRET`, den nur diese Installation kennt. Ohne DNS-Eintrag oder Zertifikat für www wird nichts geändert – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen. Erst nach einer Ablehnung erscheint „Trotzdem übernehmen“ (etwa wenn der Server sich selbst nicht erreicht, oder bei http im lokalen Betrieb). Die Prüfadresse ist auch im Wartungszustand erreichbar.
|
||||
- Wechselt die Verwaltung dadurch den Host, ist dort eine neue Anmeldung nötig. Das Sitzungscookie gilt nur für die Schreibweise, unter der es entstand. Die Meldung sagt das.
|
||||
|
||||
**Geprüft:** zwei neue Tests in `SystemScreenTest` (Server-Umgebung ohne Feld, Ablehnung einer nicht erreichbaren Adresse ohne Schreiben in `.env`, falsche Eingaben, Prüfadresse; `OriginCheck` mit fremder und eigener Kennung). Im Browser mit der Pilotkopie bei 1280 und 375 px (kein seitliches Scrollen) Feld, Ablehnung und „Trotzdem übernehmen“ angesehen.
|
||||
|
||||
## Favicon auch für die Google-Suche (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: Google zeigt bei Elektro Bernhard weiter das KantSites-K, obwohl ein eigenes Symbol gesetzt ist – „mach das im Kern sobald man Favicon setzt alles ersetzen“.
|
||||
|
||||
- **Ursache:** Das eigene Symbol gab es nur in 32 und 180 px, Google nimmt nur Vielfache von 48 px. Und `/favicon.svg` lag als Datei in `public/`, der Webserver lieferte also immer das K aus – dort hatte Google es beim ersten Besuch gefunden.
|
||||
- **Neu:** `FAVICON_SIZES` sind 32, 48, 180 und 192. Der Kopf verweist zusätzlich auf `/favicon-192.png` (`sizes="192x192"`). `/favicon.ico` liefert das 48er-PNG. `/favicon.svg` beantwortet jetzt die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen (liegt jetzt unter `public/assets/favicon.svg`), mit eigenem ein 302 auf `/favicon-192.png`. Fehlen bei älteren Installationen die neuen Größen, entstehen sie beim ersten Abruf aus der gespeicherten Einstellung. Wird das Symbol entfernt, gelten die liegengebliebenen PNGs nicht mehr.
|
||||
- **Update:** Das Archiv-Update kopiert `public/` nur hinzu; `ENTFALLEN_IN_PUBLIC` löscht deshalb die alte `public/favicon.svg`, sonst bliebe sie auf dem Server und überdeckte die Route.
|
||||
- **Danach bei Google:** In der Search Console die Startseite unter „URL prüfen“ neu zur Indexierung einreichen. Google holt Favicons selten neu ab, das kann trotzdem Tage bis Wochen dauern.
|
||||
|
||||
**Geprüft:** neuer Test `testGoogleGetsTheOwnSiteIconUnderEveryAddress` und der bisherige Favicon-Test grün, Update-Tests unverändert (eine vorbestehende Warnung), php-cs-fixer sauber; lokal (ohne eigenes Symbol) `/favicon.svg` 200, `/favicon.ico` → `/favicon.svg`.
|
||||
|
||||
## Favicon mit Zuschnitt, Pflichtseiten per Suche, neueste zuerst (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Was ein Dropdown? nicht dein ernst? Sofort fixen über Mediathek auswählbar machen zurecht schneidbar machen und auch uploadbar dort machen!“ – dazu „Genauso Adresse Datenschutz und Impressum über search felder auswählbar machen“ und „Beiträge und Seiten sortieren absteigend also neueste zuerst“.
|
||||
|
||||
- **Symbol der Website** hat einen eigenen Abschnitt in den Einstellungen statt einer Auswahlliste am Ende der Unternehmensdaten. Der Knopf öffnet die Mediathek (dort auch Upload), danach folgt ein Zuschnitt: Quadrat ziehen, Größe an der Ecke, per Regler oder mit +/−, Pfeiltasten zum Verschieben, Vorschau in 180 und 32 px. Gespeichert wird `faviconCrop` als `links,oben,kante` in Anteilen (links/oben von Breite/Höhe, Kante von der kürzeren Seite), `renderFavicon()` schneidet daraus die PNGs. `faviconVersion` hängt am Verweis; ohne Zuschnitt bleibt es bei den ersten acht Zeichen der Bildkennung, bestehende Verweise ändern sich also nicht.
|
||||
- `openMedia()` kennt `needsAlt: false`: Für das Symbol wird kein Alternativtext verlangt. Die Stile des Mediathek-Dialogs stehen jetzt in `media-dialog.css` statt in `editor.css`, damit der Dialog auch außerhalb des Editors funktioniert.
|
||||
- **Datenschutz und Impressum** werden über die Inhaltssuche gewählt wie die 404-Seite. Die Suche kann jetzt statt der Kennung den Pfad speichern (`valueKey: 'path'` bzw. `data-value="path"`), gespeichert wird also weiter die Adresse.
|
||||
- **Seiten- und Beitragsliste** der Verwaltung sortieren nach Anlagedatum absteigend. Die Einrückung der Unterseiten ist dort entfallen – ohne Baumreihenfolge hätte sie eine falsche Elternseite angedeutet.
|
||||
|
||||
## Sechste Installation: der Redesign-Entwurf „EB Solar“ (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „bitte redesign aufsetzen ebsolar.de achte auf die richtlinien wie immer machs wie die anderen tagespflege, elektro-baernhard co“ – dazu das Paket `ebsolar-redesign.zip` (statische Website: Startseite, Projektübersicht, acht Projektseiten, eigene SVG-Symbole, Kontaktformular per `mailto`).
|
||||
|
||||
**Neu.** Neben Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Maler Drews und DEEG steht jetzt **EB Solar** (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden): Datenbank `var/ebsolar.db`, Medien `var/ebsolar-media`, Bildquellen `var/ebsolar-assets` (Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos, `projects.json`), Startkonfigurationen `ebsolar` (Port 8096, dev) und `ebsolar-prod` (Port 8097, `APP_ENV=prod` zum Messen). `scripts/seed-ebsolar.php` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als **wiederverwendbare Sektion**, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge, Menü und zwei Formulare; `sh scripts/reset-ebsolar.sh` wirft weg, migriert und baut neu. Schrift ist Arial (System) wie in der Vorlage, es gibt nichts zu holen.
|
||||
|
||||
**Aus der Vorlage übernommen:** Farben (`#b52c2c` Rot, `#252b30` Tinte, `#f5f6f6` helle Fläche, `#e6e8e8` Linie), Schriftgrößen, Abstände, Wortlaut und die Reihenfolge der Abschnitte – Hero mit Bildunterschrift über dem Foto, die vier Zusicherungen zwischen zwei Linien, drei Schritte, „Gutes Handwerk. Neue Energie.“ auf heller Fläche, Projektraster, Herstellerleiste, „Was kann Ihr Dach?“.
|
||||
|
||||
**Bewusste Abweichungen.**
|
||||
- Das Kontaktformular der Vorlage öffnet nur das E-Mail-Programm des Besuchers. Hier ist es das serverseitige Formular `kontakt`; die Anfrage liegt in der Verwaltung und geht an pv@elektro-bernhard.de.
|
||||
- Der **Solarcheck** ist im Paket nur ein Verweis auf ebsolar.de. Hier ist er eine eigene Seite mit dem Formular `solarcheck`: dieselben acht Schritte und dieselben Fragen wie im Original (abgerufen 16.09.2026), nur der Schieberegler für den Jahresverbrauch ist ein Zahlenfeld – ein Schieberegler ist im Formularbaustein nicht vorgesehen.
|
||||
- Die acht Projektseiten sind **Beiträge** der Kategorie „Projekte“ mit Galerie und Bildbetrachter; `/projekte` ist eine Beitragsliste. Die Projektart („Photovoltaik“, „Terrassenmontage · SolarEdge“) steht als **Schlagwort** am Beitrag und nicht als Kategorie: „vorheriges/nächstes Projekt“ folgt der ersten Kategorie, und die muss bei allen acht dieselbe sein, sonst führt die Navigation an Rheinstetten vorbei. Die Beitragsliste zeigt nur Kategorien, deshalb tragen die Karten anders als in der Vorlage keine Artzeile.
|
||||
- Impressum und Datenschutz sind eigene Seiten (Angaben vom Impressum der laufenden Website); was nur Elektro Bernhard beantworten kann, steht als „NOCH ZU PRÜFEN“ im Text.
|
||||
- Kein Cookie-Banner: es wird nichts von Dritten geladen.
|
||||
|
||||
**Bildkarten im Solarcheck (17.09.2026).** Umut: „du kannst so schöne svg's erstellen kannst du bitte noch schöne icons für den solarcheck machen statt checkboxe schöne bilder?“ Die fünf Auswahlfragen (Personen, Dachform, Dachfläche, Eindeckung, Speicher) zeigen jetzt Bildkarten statt Optionsfelder – 20 Symbole in Tinte und Rot auf heller Fläche, im Stil der übrigen Symbole der Website. `scripts/build-ebsolar-options.mjs` zeichnet sie als SVG und rastert sie mit Chromium auf 800×600 (4:3 wie die Karte), weil die Mediathek kein SVG nimmt; SVG und PNG liegen in `var/ebsolar-assets/options/`, das Aufbauskript hängt die PNGs per `images` an die Felder. Lighthouse `/solarcheck` danach: Leistung 98/98, Barrierefreiheit, Best Practices, SEO 100.
|
||||
|
||||
**Im Kern behoben: Pfeile waren Schriftzeichen.** Der Button haengte ein `↗` an, die Beitragsnavigation ein `←`/`→` – Buchstaben also, die je nach Schrift anders aussehen und mit der Schriftstaerke nicht mitwachsen. Der Symbolsatz (`templates/blocks/icon.html.twig`, `DocumentValidator::ICONS`) kennt jetzt drei Pfeile: `arrow-right`, `arrow-left`, `arrow-up-right`, gezeichnet wie die uebrigen 17 Symbole. Button und Beitragsnavigation setzen sie als eingebettetes SVG in denselben `<span aria-hidden>` wie bisher – `& span{display:none}` nimmt den Pfeil also weiterhin weg, wo eine Vorlage keinen will (DEEG). Die Groesse haengt an der Schrift des Knopfs (`.button-arrow>svg{width:1.15em}`), nicht an einer festen Zahl. Auf EB Solar tragen auch die Textlinks ihren Pfeil als Symbolbaustein neben dem Text; er zeigt auf dieselbe Adresse und ist als Schmuck von Vorlesesoftware und Tastatur ausgenommen.
|
||||
|
||||
**Im Kern behoben: zweimal derselbe Selektor in eigenem CSS loeschte die erste Regel.** `CustomCode::parse()` legte die Deklarationen je Selektor in einem Array ab – `$rules[$suffix] = …`. Standen `& .post-card{border:0}` und `& .post-card{transform:none}` untereinander, gewann die zweite, und `border:0` verschwand ohne Meldung; im Projektraster von EB Solar kam dadurch der 1-px-Rahmen des Kerns zurueck. In CSS ist zweimal derselbe Selektor eine Kaskade, kein Widerruf: die Deklarationen werden jetzt in der Reihenfolge zusammengefuegt, in der sie standen. Dasselbe galt fuer `&{…}` neben den Eigenschaften des Elements selbst – auch das ging vorher verloren.
|
||||
|
||||
**Im Kern behoben: das vorgezogene Bild wurde nicht vorab geholt.** Für ein Hintergrundbild des ersten Abschnitts schreibt `page.html.twig` längst ein `<link rel="preload" as="image">` (`heroBackground`). Für ein gewöhnliches `<img>` über der Falz fehlte das Gegenstück – der Browser fand es erst, wenn das Stylesheet geparst war, und diese Rundreise ging auf die Startseite von EB Solar (deren Startbild ein Bild und kein Hintergrund ist) direkt in die LCP-Zeit. Neu liefert `PageController::heroImage()` Quelle, Variantensatz und Größenangabe genau so, wie `blocks/block.html.twig` sie an das `img` schreibt (sonst lädt der Browser zweimal), und die Vorlage schreibt daraus die Vorabladung – aber nur, wenn nicht ohnehin schon ein Hintergrundbild vorgeholt wird.
|
||||
|
||||
**Und eine Regel fürs Aufbauen:** eine Einblendung auf dem größten Element über der Falz kostet fast eine Sekunde LCP (gemessen: 916 ms „element render delay“, mobil 92 statt 99). Das Startbild blendet deshalb nicht ein; Kleinzeile, Überschrift, Text und Knöpfe daneben schon.
|
||||
|
||||
**Geprüft:** `npm run test:links` gegen Port 8096: 17 Adressen in der Sitemap, 48 Seiten, 127 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur und ohne JavaScript bestanden, **keine redaktionellen Befunde**; Ladebudget der Startseite 14 Anfragen, 17 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8097 für `/`, `/projekte`, `/solarcheck`, `/michelbach`, `/rheinstetten`, `/impressum`, `/datenschutz`: **Barrierefreiheit, Best Practices und SEO überall 100**, Leistung 99–100, agentisches Browsing überall 100 außer `/solarcheck` mobil (98). Startseite LCP 2,1 s mobil / 0,8 s Desktop, CLS 0, TBT 0 ms; Berichte in `reports/lighthouse-ebsolar/`. Bildbetrachter der Galerie geöffnet und geblättert („1 von 4“), Beitragsnavigation stimmt mit der Reihenfolge der Vorlage überein (Michelbach ← Rheinstetten, → Au am Rhein), 404 und Solarcheck im Browser angesehen, Desktop und 375 px. PHP-Tests **581 grün** (PHP 8.4), php-cs-fixer und Twig-Lint ohne Befund.
|
||||
|
||||
**Stolperstein beim Messen:** Im Produktionsmodus liegen die uebersetzten Twig-Vorlagen in `var/cache/prod`. Nach einer Aenderung an `blocks/icon.html.twig` warf jede Seite mit Pfeil dort **500** („Key ‚arrow-right‘ … does not exist“), waehrend der Entwicklungsserver auf 8096 laengst richtig lieferte. `APP_ENV=prod … bin/console cache:clear` raeumt das auf; wer im Produktionsmodus misst, muss das nach jeder Vorlagenaenderung tun.
|
||||
|
||||
**Bekannt:** `/solarcheck` mobil hat CLS 0,08 (agentisches Browsing 98). Das Formularskript blendet nach dem Laden alle Schritte bis auf den ersten aus – ohne Skript stehen sie untereinander, mit Skript springt die Seite einmal. Der Wert liegt unter dem Ziel von 0,1, aber die Bewegung ist sichtbar; sie gehört in den Kern (Schritte serverseitig zusammenfalten), nicht ins Aufbauskript.
|
||||
|
||||
|
||||
## Startseite nach der Installation: eine Baustellenseite statt einer Anleitung (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du bitte für die Erstseite bzw. nach der Installation eine schöne Erstseite erstellen – Under Construction oder so, ohne Details mit ‚melde dich an‘ usw. Am besten auch noch Logo von KantSites: ‚Hier entsteht eine neue Webpräsenz mit KantSites‘.“
|
||||
|
||||
**Was los war.** `DemoContent::starterPage()` lieferte zwei Abschnitte mit Arbeitsanweisungen an die Betreiberin oder den Betreiber: „Melde dich an, öffne sie im Editor …“ und „Die nächsten Schritte“ mit Design, Menüs und Einstellungen. Das stand auf der einzigen Seite, die bis zum ersten Bearbeiten **öffentlich** erreichbar ist – gelesen hat es also der Besucher, nicht der Betreiber. Für den stehen dieselben Schritte längst auf dem letzten Installerbildschirm (`templates/install/done.html.twig`) und in der Verwaltung.
|
||||
|
||||
**Neu.** Eine Baustellenseite, die genau das sagt und sonst nichts: das KantSites-Zeichen (`public/assets/kantsites-logo.svg`, dieselbe Form wie das Favicon), die Kleinzeile „In Arbeit“ in der Akzentfarbe, als einzige H1 der Name der Website, darunter „Hier entsteht eine neue Webpräsenz.“, ein kurzer Trennstrich und die Zeile „Erstellt mit KantSites“. Der Abschnitt ist ein Flex-Container mit `min-height: 70vh` und einem Verlauf von `@paper` nach `@mint`, stellt den Inhalt also mittig auf den Bildschirm; die Farben sind Palettenverweise und folgen jedem Designwechsel. Alles sind gewöhnliche Bausteine mit gewöhnlichen Stilwerten – im Editor also vollständig änder- und löschbar. Die Seitenbeschreibung ist jetzt ein echter Satz statt „NOCH AUSZUFÜLLEN“, damit eine frisch installierte Website in Suchergebnissen nicht mit einem Platzhalter steht.
|
||||
|
||||
**Unverändert:** Kopf- und Fußbereich, die beiden Rechtsseiten als Gerüst mit markierten Pflichtangaben, Menüs.
|
||||
|
||||
**Geprüft:** `tests/InstallFlowTest.php` (Installation über die sechs Bildschirme, danach liefert `/` die neue Seite), `php vendor/bin/phpunit` 579 von 579. Im Browser angesehen: eine frisch migrierte Datenbank mit dem Inhalt einer Neuinstallation, Desktop und 375 px.
|
||||
|
||||
## Fassung 1.21.0: Bildecken gehören dem Designbereich (16.09.2026)
|
||||
|
||||
**Anlass:** Umut zum Hero-Bild von DEEG: „Das sollte abgerundet sein, aber wurde von irgendwo gemacht, und die Ecken sehen komisch aus. Es gibt auch im Editor gar nicht Bildecken abrunden, oder?“
|
||||
|
||||
**Was los war.** `.block-image{border-radius:12px}` stand fest in `enhancements.css` – jedes Bild jeder Installation war gerundet, ohne dass ein Feld das gesagt hätte. Auf DEEG (eine durchgehend eckige Vorlage) fiel es auf, weil über dem gerundeten Bild eckige Elemente liegen: der dunkle Verlauf, das Schild, die blaue Aufruf-Fläche. An den Ecken schaute deshalb die eckige Fläche aus der Rundung heraus.
|
||||
|
||||
**Behoben.** Die Ecken sind jetzt eine Einstellung unter **Design → Bildecken** (`theme.base.imageRadius`, geprüft wie die Inhaltsbreite, ausgegeben als `--k-image-radius`). Vorgabe bleibt `12px`, damit bestehende Websites gleich aussehen; `0px` macht alle Bilder eckig. Für ein einzelnes Bild gab es die Einstellung schon immer – im Editor unter **Design → Bild → Bildecken** (`imageRadius` im Stilschema, für Bild, Galerie, Logoleiste und Beitragsliste); sie überschreibt den Wert der Website.
|
||||
|
||||
**DEEG:** `imageRadius` auf `0px`, im Aufbauskript und in der Installation. Das Hero-Bild ist damit eckig wie die Vorlage, und die Ecken stimmen wieder.
|
||||
|
||||
**Geprüft:** neuer Test in `tests/CmsTest.php`: Vorgabe ist `12px`, eine Seite mit Bild trägt `--k-image-radius:12px`, nach dem Speichern von `0px` trägt sie `--k-image-radius:0px`, und „rund“ wird abgewiesen wie jede andere Nicht-Größenangabe.
|
||||
|
||||
## Fassung 1.20.1: das kaufmännische Und, das bei jedem Speichern wuchs (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Beim Startseitenbild wird der Text & umgewandelt, aber in der Ansicht nach dem Speichern wird & daraus.“
|
||||
|
||||
**Ursache.** `RichText::sanitise()` hat Text ohne ein einziges Tag als reinen Text behandelt und nur escaped. Was der Editor schickt, ist aber HTML – auch ohne Tag. Aus `Freudenstadt & Umgebung` wurde so bei **jedem** Speichern eine Stufe mehr: `&amp;`, `&amp;amp;` … bis die Entity sichtbar auf der Seite stand. Auf kant-it.de war der Entwurf der Startseite schon bei drei Stufen. Derselbe Zweig hatte einen zweiten Fehler gleicher Art: `nl2br()` ließ den Zeilenumbruch hinter dem `<br>` stehen, und der nächste Durchlauf machte über `breakLines()` ein zweites `<br>` daraus – aus einem Absatz wurden zwei.
|
||||
|
||||
**Behoben.** Der Zweig dekodiert jetzt zuerst (`html_entity_decode`) und escaped danach, und er ersetzt den Umbruch, statt ihn zu ergänzen. Damit endet er bei derselben Darstellung wie der DOM-Zweig, und beide sind unveränderlich gegenüber wiederholtem Speichern.
|
||||
|
||||
**Bestand.** Der Konsolenbefehl `bin/console cms:repair:entities` räumt auf, was schon gespeichert ist: veröffentlichte Fassung und Entwurf jeder Seite sowie die Bereiche, mit Trockenlauf und `--schreiben`, jede Änderung gegen den Validator geprüft (Seiten wie beim Speichern, Bereiche ohne Überschriftspflicht). Als Befehl und nicht als Skript unter `scripts/`, weil dieser Ordner nicht ins Releasearchiv geht – eine Installation ohne Git hätte das Skript sonst nie gesehen. Ältere Fassungen bleiben Historie. Auf DEEG angewandt (eine Stelle im Entwurf der Startseite). **Offen: kant-it.de** – dort stehen zwei dreifach kodierte Stellen im Entwurf der Startseite; das Skript gehört nach dem Update dort einmal ausgeführt, sonst steht der Murks beim nächsten Veröffentlichen live.
|
||||
|
||||
**Geprüft:** `tests/RepairEntitiesCommandTest.php` (Trockenlauf ändert nichts; Schreiben entschachtelt drei Stufen und lässt ein richtiges `&` stehen; ein zweiter Lauf findet nichts mehr) und ein neuer Test in `tests/CmsTest.php`: sechs Eingaben (Entity, nacktes Und, `<`, Zeilenumbruch, Markup, wörtliche Tags) dreimal hintereinander durch `sanitise()` – jedes Mal dasselbe Ergebnis; dazu eine Seite, die dreimal gespeichert und veröffentlicht wird und danach immer noch ein einziges `&` trägt.
|
||||
|
||||
**Nebenbei (DEEG):** Das Favicon zeigte den mittigen Ausschnitt des Logos, also „EE“ aus dem Schriftzug. Neu ist ein eigenes quadratisches Zeichen: der Dachgiebel aus dem Logo, weiß auf Logo-Blau, gerendert aus `logo-deeg.png` von `scripts/set-immobilien-deeg-favicon.php`. Vier Entwürfe standen zur Wahl (Doppeldach, Giebel, Dach blau auf weiß, Buchstabe D), Umut hat den Giebel gewählt.
|
||||
|
||||
## Baustein „Eigenes HTML“, DEEG-Angebotsseite (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann … z. B. eine neue Angebotsseite bei DEEG, da soll ein iframe rein, im gleichen Stil wie das Impressum.“
|
||||
|
||||
**Gebaut.** Neuer Kernbaustein `html` („Eigenes HTML“, Dokumentschema **v24**): eine Eigenschaft, das Markup bis 20.000 Zeichen, unverändert ausgegeben. `CustomCode::checkHtml()` weist `<script>` und `<iframe>` mit dem Weg zum richtigen Ort ab; `CustomCode::assertHtmlUnchanged()` hält Änderungen auf die Administratorrolle fest – in `EditorController` (Speichern und Zurückholen einer Fassung) und in `PartialController`, wie beim eigenen Skript. Im Editor: Eintrag in der Gruppe „Seite und Daten“, mehrzeiliges Feld, schreibgeschützt ohne Administratorrolle. In `site.css` nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt.
|
||||
|
||||
**Nicht gebaut, weil vorhanden:** der iframe. Der Baustein „Externer Inhalt“ (v14) macht das seit Langem – mit Einwilligung davor und dem Host in `frame-src`. Begründung in ADR 106.
|
||||
|
||||
**DEEG.** Die Seite `/angebote` gab es schon (im Editor angelegt, Starterabschnitt plus Rahmen mit Platzhaltertexten „test“). Sie ist jetzt im Stil der Rechtsseiten aufgebaut: Kleinzeile, große leichte Überschrift „Unsere Angebote“, Vorspann auf heller Fläche, darunter die IS24-Ergebnisliste (Portal 16200281, Höhe 1000) und eine Hinweiszeile zum Portal und zum Kontakt. „Immobilien“ in der Navigation und der Knopf im Angebote-Band der Startseite zeigen auf `/angebote` statt auf die alte Website. Aufbau per Skript: `scripts/add-immobilien-deeg-angebote.php`.
|
||||
|
||||
**Einwilligung und Gestaltung (DEEG).** Die Gruppe „Externe Inhalte“ ist eingeschaltet (`consentBanner`, `consentGroups`), der Balken trägt die Farben der Website (eckig, Logo-Blau als Akzent, Papier als Fläche). Vor der Zustimmung steht der Platzhalter, und der trägt über eigenes CSS am Block denselben Zuschnitt wie die Vorlage: eckiger Rahmen auf heller Fläche, linksbündig, blauer Knopf. **Gestaltbar ist der Rahmen, nicht sein Inhalt** – Abstände, Breite, Rand, Ecken und Höhe gehören dieser Website, die Darstellung der Liste gehört ImmobilienScout24; an fremdes Dokument im iframe kommt kein CSS heran, das trennt der Browser. Im Browser geprüft: Platzhalter, Zustimmung, geladene Liste.
|
||||
|
||||
**Nicht mehr offen:** Die Einwilligungsfrage war auf dieser Installation aus (`consentBanner` false, keine Gruppen). Das ist erledigt: Umut hat die Einwilligung eingeschaltet, ein fremder Rahmen lädt jetzt erst nach der Zustimmung.
|
||||
|
||||
**Geprüft:** `phpunit` 574 Tests grün, `npm test` 35 (neu: der Baustein startet leer, steht im Werkzeugkasten, hebt das Dokument auf v24). `npm run test:editor` um den ganzen Weg erweitert und bestanden: Baustein aus dem Werkzeugkasten einfügen, Markup eintippen, in der Vorschau sehen, veröffentlichen, auf der Website Zeichen für Zeichen wiederfinden – und `<script>` wie `<iframe>` werden mit ihrer Meldung abgewiesen (422, Meldung in der Leiste über dem Editor), ohne die veröffentlichte Seite anzufassen. Zwei neue PHP-Tests: Markup erscheint unverändert auf der veröffentlichten Seite und `<script>`, `<iframe>` sowie Schema v23 werden abgewiesen; HTML-Änderungen werden gegen den gespeicherten Stand geprüft.
|
||||
|
||||
## 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?“
|
||||
@@ -834,7 +1093,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Mediathek derzeit für statische Bilder und PDFs (seit 22.09.2026): keine Videos/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
|
||||
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260917000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Dokumente: PDFs in der Mediathek, zum Verlinken und fuer den Betrachter.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Eine eigene Tabelle statt einer Art-Spalte in media: jede Stelle, die ein Bild sucht,
|
||||
// erwartet Varianten mit Breite und Hoehe. Ein PDF dort waere ein Bild ohne Bild - an
|
||||
// einem Dutzend Stellen. Getrennt kann keine Bildauswahl je ein Dokument finden.
|
||||
$document = $schema->createTable('document');
|
||||
$document->addColumn('id', 'string', ['length' => 32]);
|
||||
$document->addColumn('title', 'string', ['length' => 160]);
|
||||
$document->addColumn('bytes', 'integer');
|
||||
$document->addColumn('archived', 'boolean', ['default' => false]);
|
||||
$document->addColumn('version', 'integer', ['default' => 1]);
|
||||
$document->addColumn('created_at', 'datetime_immutable');
|
||||
$document->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('document');
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
+40
-2
@@ -684,6 +684,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.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 }
|
||||
/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */
|
||||
.origin-form { margin-top: 4px; max-width: 560px }
|
||||
.origin-form label:first-of-type { margin-top: 10px }
|
||||
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
|
||||
.card-body .origin-row .secondary { margin: 0 }
|
||||
.origin-form .check-label { margin-top: 10px }
|
||||
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
|
||||
.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 }
|
||||
@@ -751,9 +759,25 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
|
||||
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
|
||||
|
||||
.favicon-field{display:flex;align-items:center;gap:12px}
|
||||
.favicon-field select{flex:1}
|
||||
.favicon-field{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
|
||||
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
|
||||
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
|
||||
.favicon-editor[hidden]{display:none}
|
||||
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
|
||||
.favicon-stage img{display:block;max-width:100%;max-height:360px;width:auto;height:auto}
|
||||
.favicon-frame{position:absolute;box-sizing:border-box;border:2px solid #fff;outline:1px solid rgba(0,0,0,.6);box-shadow:0 0 0 9999px rgba(20,30,25,.55);cursor:move}
|
||||
.favicon-stage{overflow:hidden}
|
||||
.favicon-frame:focus-visible{outline:3px solid var(--accent);outline-offset:0}
|
||||
.favicon-handle{position:absolute;right:-7px;bottom:-7px;width:14px;height:14px;background:#fff;border:1px solid rgba(0,0,0,.6);border-radius:3px;cursor:nwse-resize}
|
||||
.favicon-side input[type=range]{width:100%}
|
||||
.favicon-samples{display:flex;align-items:flex-end;gap:16px;margin:12px 0}
|
||||
.favicon-samples figure{margin:0;text-align:center;font-size:11px;color:var(--muted)}
|
||||
.favicon-samples canvas{display:block;margin:0 auto 6px;border-radius:8px;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/12px 12px;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.favicon-sample-large{width:90px;height:90px}
|
||||
.favicon-sample-small{width:32px;height:32px;border-radius:4px!important}
|
||||
@media(max-width:700px){.favicon-editor{grid-template-columns:1fr}}
|
||||
|
||||
.color-field { display: flex; gap: 8px; align-items: center }
|
||||
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
|
||||
@@ -864,6 +888,18 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.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 }
|
||||
@@ -939,3 +975,5 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.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 } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
@@ -12,7 +12,7 @@ export const CATEGORIES = [
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
];
|
||||
|
||||
/** How many columns a row can be added with. */
|
||||
@@ -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.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -68,8 +93,10 @@ export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion'
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -86,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.
|
||||
@@ -96,6 +128,8 @@ export function createBlock(type) {
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
|
||||
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
|
||||
if (type === 'legal') block.props = { label: 'Rechtliches' };
|
||||
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
|
||||
if (type === 'html') block.props = { html: '' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
||||
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
||||
@@ -121,12 +155,14 @@ 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 (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 (type === 'html') upgradeDocument(document, 24);
|
||||
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);
|
||||
@@ -277,7 +313,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 23;
|
||||
export const SCHEMA_VERSION = 24;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
@@ -436,8 +472,19 @@ export function insertRow(document, columns, selected) {
|
||||
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; };
|
||||
@@ -471,6 +518,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);
|
||||
|
||||
@@ -11,31 +11,6 @@ dialog::backdrop{background:#172e2966}
|
||||
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||
|
||||
/* ---- media library dialog (unchanged) ---------------------------------------------------- */
|
||||
.media-launch{margin-top:14px;white-space:normal}
|
||||
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
||||
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
||||
.media-heading h2{margin:0}
|
||||
.media-heading .eyebrow{margin-bottom:8px}
|
||||
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
.media-tools label{margin-top:10px}
|
||||
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
|
||||
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
|
||||
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
|
||||
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
|
||||
.media-card strong{font-size:12px;overflow-wrap:anywhere}
|
||||
.media-card span{font-size:11px;color:var(--muted)}
|
||||
.media-details{padding-left:22px;border-left:1px solid var(--line)}
|
||||
.media-details button,.media-details a{margin-top:12px;display:block}
|
||||
.media-details input{font-size:13px}
|
||||
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
|
||||
.media-footer label{margin:0;font-size:12px}
|
||||
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
#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}}
|
||||
|
||||
/* ---- shell ------------------------------------------------------------------------------- */
|
||||
html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
|
||||
+104
-14
@@ -1,5 +1,5 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -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'];
|
||||
@@ -243,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';
|
||||
@@ -330,13 +330,22 @@ function renderContent(block, panel) {
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
if (key === 'href') {
|
||||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => { input.value = item.src; applyValue(); history.mergeKey = null; } }));
|
||||
container.append(pdf);
|
||||
field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
}
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
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.
|
||||
@@ -371,7 +380,7 @@ function renderContent(block, panel) {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'stat') {
|
||||
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
|
||||
@@ -387,7 +396,7 @@ function renderContent(block, panel) {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'contact') {
|
||||
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
|
||||
@@ -408,7 +417,7 @@ function renderContent(block, panel) {
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'social') {
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
|
||||
@@ -458,6 +467,29 @@ function renderContent(block, panel) {
|
||||
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
|
||||
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
|
||||
}
|
||||
if (block.type === 'html') {
|
||||
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
|
||||
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
|
||||
const label = document.createElement('label'); label.htmlFor = 'own-html'; label.textContent = 'HTML';
|
||||
const input = document.createElement('textarea');
|
||||
input.id = 'own-html'; input.rows = 10; input.spellcheck = false; input.className = 'own-code';
|
||||
input.value = block.props?.html ?? '';
|
||||
input.readOnly = !mayScript;
|
||||
input.addEventListener('change', () => {
|
||||
history.change(doc => {
|
||||
upgradeDocument(doc, 24);
|
||||
const current = locate(doc.blocks, block.id)?.block;
|
||||
if (!current) return;
|
||||
current.props = { ...current.props, html: input.value };
|
||||
});
|
||||
changed();
|
||||
});
|
||||
const note = document.createElement('p'); note.className = 'field-help';
|
||||
note.innerHTML = mayScript
|
||||
? 'Markup, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. <strong><script></strong> gehört unter „Erweitert“ in das Feld „Eigenes Skript“, ein <strong><iframe></strong> in den Baustein „Externer Inhalt“ – nur der fragt vorher um Einwilligung. Die Vorschau zeigt das HTML so, wie die Seite es zeigt.'
|
||||
: 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.';
|
||||
panel.append(label, input, note);
|
||||
}
|
||||
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') {
|
||||
@@ -502,16 +534,19 @@ function renderContent(block, panel) {
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
||||
if (block.type === 'image') {
|
||||
renderImageWidth(block, panel);
|
||||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||
panel.append(imagePreview.element);
|
||||
const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options';
|
||||
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
|
||||
// Ohne eigene Wahl folgt das Bild seiner Galerie - dieselbe Regel wie in der Vorlage.
|
||||
const holder = locate(history.document.blocks, block.id)?.parent;
|
||||
const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true;
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine'));
|
||||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
|
||||
showLinkOptions(clickAction); panel.append(linkOptions);
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
@@ -526,7 +561,7 @@ function renderContent(block, panel) {
|
||||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||
}
|
||||
if (block.type === 'gallery') {
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' });
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Gilt für alle Bilder der Galerie: ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Ein Bild mit eigener Einstellung unter „Beim Klick“ behält sie.' });
|
||||
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
@@ -665,7 +700,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)) {
|
||||
@@ -714,6 +749,35 @@ 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 => {
|
||||
@@ -1012,6 +1076,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(); }
|
||||
@@ -1024,7 +1091,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(); }
|
||||
@@ -1101,6 +1174,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();
|
||||
@@ -1128,7 +1218,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;
|
||||
|
||||
@@ -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)}
|
||||
.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:var(--k-image-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)}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* The site icon in the settings: an image from the media library (uploads happen right there in
|
||||
* the dialog), then a square cut-out chosen by dragging. The crop is stored as fractions -
|
||||
* left and top of the width and height, the edge of the shorter side - so it holds for every
|
||||
* variant of the image, and the server cuts the PNG files from it when the form is saved.
|
||||
*/
|
||||
const field = document.querySelector('[data-favicon-field]');
|
||||
if (field) setup(field);
|
||||
|
||||
function setup(field) {
|
||||
const idInput = field.querySelector('[name="faviconMediaId"]');
|
||||
const cropInput = field.querySelector('[name="faviconCrop"]');
|
||||
const choose = field.querySelector('[data-favicon-choose]');
|
||||
const remove = field.querySelector('[data-favicon-remove]');
|
||||
const current = field.querySelector('.favicon-current');
|
||||
const base = document.querySelector('meta[name="admin-base"]').content;
|
||||
|
||||
const editor = document.createElement('div'); editor.className = 'favicon-editor'; editor.hidden = true;
|
||||
editor.innerHTML = `<div class="favicon-stage"><img alt="" draggable="false"><div class="favicon-frame" tabindex="0" role="application" aria-label="Ausschnitt für das Symbol. Mit den Pfeiltasten verschieben, mit Umschalt in großen Schritten, mit Plus und Minus die Größe ändern."><span class="favicon-handle" aria-hidden="true"></span></div></div>
|
||||
<div class="favicon-side">
|
||||
<label for="favicon-size">Größe des Ausschnitts</label>
|
||||
<input id="favicon-size" type="range" min="2" max="100" step="1" value="100">
|
||||
<p class="field-help">Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.</p>
|
||||
<div class="favicon-samples" aria-label="Vorschau">
|
||||
<figure><canvas width="180" height="180" class="favicon-sample-large"></canvas><figcaption>Startbildschirm</figcaption></figure>
|
||||
<figure><canvas width="32" height="32" class="favicon-sample-small"></canvas><figcaption>Browser-Reiter</figcaption></figure>
|
||||
</div>
|
||||
<button type="button" class="text-button" data-favicon-center>Mittig ausrichten</button>
|
||||
</div>`;
|
||||
field.after(editor);
|
||||
const stage = editor.querySelector('.favicon-stage');
|
||||
const image = stage.querySelector('img');
|
||||
const frame = stage.querySelector('.favicon-frame');
|
||||
const size = editor.querySelector('#favicon-size');
|
||||
const large = editor.querySelector('.favicon-sample-large');
|
||||
const small = editor.querySelector('.favicon-sample-small');
|
||||
|
||||
let crop = { x: 0, y: 0, s: 1 };
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
const edge = () => crop.s * Math.min(image.naturalWidth, image.naturalHeight);
|
||||
|
||||
/** Keep the square inside the picture, then show it and write it into the form. */
|
||||
const apply = () => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight; if (!w || !h) return;
|
||||
crop.s = clamp(crop.s, 0.02, 1);
|
||||
const e = edge();
|
||||
crop.x = clamp(crop.x, 0, (w - e) / w); crop.y = clamp(crop.y, 0, (h - e) / h);
|
||||
Object.assign(frame.style, { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${e / w * 100}%`, height: `${e / h * 100}%` });
|
||||
size.value = Math.round(crop.s * 100);
|
||||
cropInput.value = [crop.x, crop.y, crop.s].map(value => String(Math.round(value * 1e6) / 1e6)).join(',');
|
||||
for (const canvas of [large, small]) {
|
||||
const context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, crop.x * w, crop.y * h, e, e, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
};
|
||||
const center = () => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight, e = edge();
|
||||
crop.x = (w - e) / 2 / w; crop.y = (h - e) / 2 / h; apply();
|
||||
};
|
||||
/** Resize around the middle of the square, so the slider and the keys do not drift it away. */
|
||||
const resize = s => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight, before = edge();
|
||||
crop.s = clamp(s, 0.02, 1); const after = edge();
|
||||
crop.x += (before - after) / 2 / w; crop.y += (before - after) / 2 / h; apply();
|
||||
};
|
||||
|
||||
const load = (src, initial) => new Promise((resolve, reject) => {
|
||||
image.onload = () => { editor.hidden = false; crop = initial || { x: 0, y: 0, s: 1 }; if (initial) apply(); else center(); resolve(); };
|
||||
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
|
||||
image.src = src;
|
||||
});
|
||||
const largest = item => item.variants.at(-1).src;
|
||||
|
||||
const showCurrent = label => {
|
||||
current.replaceChildren(); current.append(document.createTextNode(label));
|
||||
choose.textContent = idInput.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !idInput.value;
|
||||
};
|
||||
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: async items => {
|
||||
if (!items.length) return;
|
||||
idInput.value = items[0].id; cropInput.value = '';
|
||||
showCurrent(`Neues Symbol aus „${items[0].title}“ – wird beim Speichern übernommen.`);
|
||||
try { await load(largest(items[0])); frame.focus({ preventScroll: true }); editor.scrollIntoView({ block: 'nearest' }); }
|
||||
catch (error) { showCurrent(error.message); }
|
||||
} }));
|
||||
remove.addEventListener('click', () => {
|
||||
idInput.value = ''; cropInput.value = ''; editor.hidden = true; image.removeAttribute('src');
|
||||
showCurrent('Mitgeliefertes Zeichen – wird beim Speichern übernommen.'); choose.focus();
|
||||
});
|
||||
editor.querySelector('[data-favicon-center]').addEventListener('click', center);
|
||||
size.addEventListener('input', () => resize(size.value / 100));
|
||||
|
||||
// Drag to move, drag the corner to resize. Pointer events cover mouse, pen and touch alike.
|
||||
let drag = null;
|
||||
frame.addEventListener('pointerdown', event => {
|
||||
event.preventDefault(); frame.setPointerCapture(event.pointerId); frame.focus({ preventScroll: true });
|
||||
drag = { mode: event.target.classList.contains('favicon-handle') ? 'resize' : 'move', startX: event.clientX, startY: event.clientY, crop: { ...crop }, rect: stage.getBoundingClientRect() };
|
||||
});
|
||||
frame.addEventListener('pointermove', event => {
|
||||
if (!drag) return;
|
||||
const w = image.naturalWidth, h = image.naturalHeight, { rect } = drag;
|
||||
const dx = (event.clientX - drag.startX) / rect.width, dy = (event.clientY - drag.startY) / rect.height;
|
||||
if (drag.mode === 'move') { crop.x = drag.crop.x + dx; crop.y = drag.crop.y + dy; }
|
||||
else {
|
||||
// The corner follows the larger of both movements, measured in image pixels.
|
||||
const grow = Math.max(dx * w, dy * h), shorter = Math.min(w, h);
|
||||
const room = Math.min(w - drag.crop.x * w, h - drag.crop.y * h);
|
||||
crop.x = drag.crop.x; crop.y = drag.crop.y;
|
||||
crop.s = clamp(drag.crop.s * shorter + grow, 0.02 * shorter, room) / shorter;
|
||||
}
|
||||
apply();
|
||||
});
|
||||
const stop = () => { drag = null; };
|
||||
frame.addEventListener('pointerup', stop); frame.addEventListener('pointercancel', stop);
|
||||
frame.addEventListener('keydown', event => {
|
||||
const step = event.shiftKey ? 0.1 : 0.01;
|
||||
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
|
||||
if (moves[event.key]) { event.preventDefault(); crop.x += moves[event.key][0]; crop.y += moves[event.key][1]; apply(); }
|
||||
else if (['+', '='].includes(event.key)) { event.preventDefault(); resize(crop.s + step); }
|
||||
else if (event.key === '-') { event.preventDefault(); resize(crop.s - step); }
|
||||
});
|
||||
|
||||
// An icon that is already set opens with its picture and its cut-out.
|
||||
if (/^[a-f0-9]{32}$/.test(idInput.value)) {
|
||||
fetch(`${base}/api/media/${idInput.value}`, { credentials: 'same-origin' })
|
||||
.then(response => { if (!response.ok || response.redirected) throw new Error(); return response.json(); })
|
||||
.then(({ item }) => {
|
||||
const parts = cropInput.value.split(',').map(Number);
|
||||
return load(largest(item), parts.length === 3 && parts.every(Number.isFinite) ? { x: parts[0], y: parts[1], s: parts[2] } : null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -13,6 +13,7 @@ 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',
|
||||
@@ -63,18 +64,37 @@ if (root) {
|
||||
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 field = el('div', 'form-preview-field');
|
||||
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 ? ' *' : '')));
|
||||
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}`, type === 'select' ? '— bitte wählen —' : ''));
|
||||
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));
|
||||
}
|
||||
const help = card.querySelector('[data-field-help]').value.trim();
|
||||
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.'));
|
||||
@@ -95,16 +115,52 @@ if (root) {
|
||||
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 = type !== 'select';
|
||||
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 required.disabled = false;
|
||||
if (type === 'consent') { required.checked = true; required.disabled = true; }
|
||||
else if (type === 'step') { required.checked = false; required.disabled = true; }
|
||||
else required.disabled = false;
|
||||
});
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128"><rect width="128" height="128" rx="32" fill="#182d2b"/><path d="M36 28h16v42l26-28h22L68 76l34 28H78L52 82v22H36z" fill="#e5f0b6"/></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -99,3 +99,86 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/*
|
||||
* Der PDF-Betrachter - in derselben Datei wie der Bildbetrachter, damit eine Seite mit beidem
|
||||
* nicht zwei Skripte laedt.
|
||||
*
|
||||
* Ohne dieses Skript ist ein Link auf ein PDF ein gewöhnlicher Link: der Browser öffnet die
|
||||
* Datei. Das Skript zeigt sie stattdessen in einem Rahmen über der Seite, mit dem Betrachter,
|
||||
* den der Browser ohnehin mitbringt - kein eigener Nachbau, der ein Megabyte wiegt.
|
||||
*
|
||||
* Wo der Browser keinen solchen Betrachter hat oder ihn im Rahmen nur halb zeigt, bleibt es beim
|
||||
* neuen Tab: Chrome auf Android hat keinen, und Safari auf dem iPhone zeigt im Rahmen nur die
|
||||
* erste Seite. Ein Link mit „In neuem Tab öffnen“ bleibt ohnehin, was er ist.
|
||||
*/
|
||||
(() => {
|
||||
const PDF = /^\/media\/[a-f0-9]{32}\.pdf$/;
|
||||
const links = [...document.querySelectorAll('a[href]')].filter(link => {
|
||||
const url = new URL(link.href, location.href);
|
||||
|
||||
return url.origin === location.origin && PDF.test(url.pathname) && '_blank' !== link.target;
|
||||
});
|
||||
if (0 === links.length) {
|
||||
return;
|
||||
}
|
||||
const eingebettet = false !== navigator.pdfViewerEnabled && !matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
let dialog = null;
|
||||
let rahmen = null;
|
||||
let titel = null;
|
||||
let laden = null;
|
||||
|
||||
const baue = () => {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'pdf-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'pdf-dialog-title');
|
||||
dialog.innerHTML = `
|
||||
<div class="pdf-bar">
|
||||
<p class="pdf-title" id="pdf-dialog-title"></p>
|
||||
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
||||
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
|
||||
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
||||
`;
|
||||
document.body.append(dialog);
|
||||
rahmen = dialog.querySelector('.pdf-frame');
|
||||
titel = dialog.querySelector('.pdf-title');
|
||||
laden = [dialog.querySelector('.pdf-download'), dialog.querySelector('.pdf-open')];
|
||||
dialog.querySelector('.pdf-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
// Der Rahmen wird geleert, sonst läuft ein eingebettetes Video oder Audio im PDF weiter
|
||||
// und das nächste Öffnen zeigt kurz das vorige Dokument.
|
||||
dialog.addEventListener('close', () => rahmen.removeAttribute('src'));
|
||||
};
|
||||
|
||||
for (const link of links) {
|
||||
link.addEventListener('click', event => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!eingebettet) {
|
||||
window.open(link.href, '_blank', 'noopener');
|
||||
|
||||
return;
|
||||
}
|
||||
if (null === dialog) {
|
||||
baue();
|
||||
}
|
||||
const name = (link.getAttribute('aria-label') || link.textContent || '').trim();
|
||||
titel.textContent = name || 'Dokument';
|
||||
rahmen.title = name ? `PDF: ${name}` : 'PDF-Dokument';
|
||||
for (const aktion of laden) {
|
||||
aktion.href = link.href;
|
||||
}
|
||||
rahmen.src = link.href;
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
/* The media library dialog. Loaded by the editor and by every screen that picks an image from
|
||||
the library (the site icon in the settings), so it does not rely on editor.css. */
|
||||
.media-dialog{padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
|
||||
.media-dialog::backdrop{background:#172e2966}
|
||||
.media-dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.media-dialog .panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
|
||||
.media-launch{margin-top:14px;white-space:normal}
|
||||
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
||||
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
||||
.media-heading h2{margin:0}
|
||||
.media-heading .eyebrow{margin-bottom:8px}
|
||||
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
.media-tools label{margin-top:10px}
|
||||
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
|
||||
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
|
||||
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
|
||||
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
|
||||
.media-card strong{font-size:12px;overflow-wrap:anywhere}
|
||||
.media-card span{font-size:11px;color:var(--muted)}
|
||||
.media-details{padding-left:22px;border-left:1px solid var(--line)}
|
||||
.media-details button,.media-details a{margin-top:12px;display:block}
|
||||
.media-details input{font-size:13px}
|
||||
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
|
||||
.media-footer label{margin:0;font-size:12px}
|
||||
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
#media-status{font-size:12px;margin-block:15px}
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@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}}
|
||||
/* PDFs: eine Liste statt eines Rasters - es gibt nichts zu sehen außer dem Titel. */
|
||||
.document-dialog{max-width:720px}
|
||||
.document-list{display:grid;gap:8px;max-height:360px;overflow:auto;padding:4px}
|
||||
.document-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;border:2px solid var(--line);border-radius:9px;padding:10px 12px;background:white;color:var(--ink);text-align:left}
|
||||
.document-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.document-card strong{font-size:13px;overflow-wrap:anywhere}
|
||||
.document-card span{font-size:11px;color:var(--muted)}
|
||||
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
|
||||
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
.href-document{margin:6px 0 4px}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dragging images onto the upload field, and saying which files are about to go up.
|
||||
* Dragging images and PDFs onto the upload field, and saying which files are about to go up.
|
||||
*
|
||||
* Everything here is an addition. Without this script the field is still a file input with a
|
||||
* label: clicking it opens the file picker and the form submits the same way. The script never
|
||||
@@ -9,19 +9,19 @@ const zone = document.querySelector('[data-dropzone]');
|
||||
const input = zone?.querySelector('input[type="file"]');
|
||||
const chosen = document.querySelector('[data-chosen]');
|
||||
if (zone && input && chosen) {
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
|
||||
const describe = (files, ignored = 0) => {
|
||||
if (!files.length) {
|
||||
chosen.textContent = ignored
|
||||
? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.`
|
||||
? `Keine passende Datei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG, WebP und PDF.`
|
||||
: 'Noch keine Datei gewählt.';
|
||||
return;
|
||||
}
|
||||
const names = [...files].map(file => file.name).join(', ');
|
||||
const size = [...files].reduce((total, file) => total + file.size, 0);
|
||||
chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}`
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : '');
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG, WebP und PDF.` : '');
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => describe(input.files));
|
||||
|
||||
+70
-3
@@ -40,7 +40,8 @@ export function createMediaPreview(initial = {}) {
|
||||
return { element: wrap, update };
|
||||
}
|
||||
|
||||
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
/** needsAlt: false for images that never appear as content, such as the site icon. */
|
||||
export function openMedia({ multiple = false, onChoose, needsAlt = true } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'media-title');
|
||||
@@ -128,15 +129,81 @@ export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
}
|
||||
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
|
||||
});
|
||||
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
|
||||
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose || !needsAlt;
|
||||
$('#media-insert').addEventListener('click', () => {
|
||||
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
|
||||
const decorative = $('#media-decorative').checked;
|
||||
if (!decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
|
||||
if (needsAlt && !decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
|
||||
onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
|
||||
});
|
||||
dialog.showModal(); lock(true);
|
||||
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF aus der Mediathek wählen - oder eines hochladen und gleich wählen.
|
||||
*
|
||||
* Ein eigener, schmaler Dialog statt einer Betriebsart des Bilderdialogs: ein PDF hat keine
|
||||
* Vorschau, keinen Alternativtext und keine Mehrfachauswahl. Gewählt wird die Adresse, die dann
|
||||
* im Linkziel steht.
|
||||
*/
|
||||
export function openDocuments({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog document-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'document-title');
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">DOKUMENTE</p><h2 id="document-title">PDF wählen</h2></div><button class="secondary" id="document-close">Schließen</button></div>
|
||||
<p class="panel-hint">PDF bis 20 MB. Es wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar – nichts Vertrauliches hochladen. Ohne „In neuem Tab öffnen“ zeigt die Website es in einem Betrachter über der Seite.</p>
|
||||
<div class="media-tools"><div><label for="document-search">PDFs suchen</label><input id="document-search" type="search" placeholder="Titel"></div><div><label for="document-upload">PDF hochladen</label><input id="document-upload" type="file" accept="application/pdf,.pdf" disabled></div></div>
|
||||
<p id="document-status" role="status" aria-live="polite">Wird geladen …</p><p id="document-error" class="media-error" role="alert" hidden></p>
|
||||
<div id="document-list" class="document-list" role="group" aria-label="PDF auswählen"></div>
|
||||
<div class="media-footer"><span></span><button id="document-insert" class="primary" disabled>PDF übernehmen</button></div>`;
|
||||
document.body.append(dialog);
|
||||
const $ = selector => dialog.querySelector(selector);
|
||||
let items = [], chosen = null, token;
|
||||
const error = message => { $('#document-error').textContent = message; $('#document-error').hidden = !message; };
|
||||
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
|
||||
const base = document.querySelector('meta[name="admin-base"]').content + '/api/dokumente';
|
||||
function render() {
|
||||
const query = $('#document-search').value.toLocaleLowerCase('de');
|
||||
const visible = items.filter(item => !item.archived && item.title.toLocaleLowerCase('de').includes(query));
|
||||
$('#document-list').replaceChildren();
|
||||
for (const item of visible) {
|
||||
const card = element('button', '', 'document-card'); card.type = 'button';
|
||||
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
|
||||
card.append(element('span', 'PDF', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
|
||||
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
|
||||
card.addEventListener('dblclick', () => { chosen = item; $('#document-insert').click(); });
|
||||
$('#document-list').append(card);
|
||||
}
|
||||
if (!visible.length) $('#document-list').append(element('p', items.length ? 'Kein PDF mit diesem Titel.' : 'Noch keine PDFs. Lade oben eines hoch.', 'muted'));
|
||||
$('#document-status').textContent = `${items.filter(item => !item.archived).length} PDFs${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
|
||||
$('#document-insert').disabled = !chosen;
|
||||
}
|
||||
$('#document-search').addEventListener('input', render);
|
||||
$('#document-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
$('#document-upload').addEventListener('change', async () => {
|
||||
const file = $('#document-upload').files[0]; if (!file) return;
|
||||
error('');
|
||||
if (file.size > 20 * 1024 * 1024) { error(`${file.name}: größer als 20 MB`); $('#document-upload').value = ''; return; }
|
||||
$('#document-status').textContent = `Wird hochgeladen: ${file.name}`;
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
|
||||
items.unshift(data.item); chosen = data.item;
|
||||
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||
$('#document-upload').value = ''; render();
|
||||
});
|
||||
$('#document-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
|
||||
dialog.showModal();
|
||||
fetch(base, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('PDFs konnten nicht geladen werden. Bitte neu anmelden.');
|
||||
// Hochladen erst mit Token - sonst weist der Server eine zu früh gewählte Datei ab.
|
||||
const data = await response.json(); items = data.items; token = data.csrfToken; $('#document-upload').disabled = false; render();
|
||||
}).catch(err => { $('#document-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||
@@ -11,7 +11,8 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
input.removeAttribute('aria-activedescendant'); current = -1;
|
||||
};
|
||||
const select = item => {
|
||||
value.value = item?.id ?? '';
|
||||
// Most fields store the page id; a setting that keeps an address asks for the path instead.
|
||||
value.value = item?.[picker.dataset.value || 'id'] ?? '';
|
||||
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
|
||||
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
|
||||
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
|
||||
|
||||
@@ -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}}
|
||||
+115
-18
@@ -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}
|
||||
@@ -44,6 +44,11 @@ p{hyphens:auto}
|
||||
|
||||
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
|
||||
.block-icon{flex-shrink:0;display:block}
|
||||
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
|
||||
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
|
||||
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
|
||||
.button-arrow,.nav-arrow{display:inline-flex;align-items:center}
|
||||
.button-arrow>svg,.nav-arrow>svg{width:1.15em;height:1.15em}
|
||||
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
||||
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
|
||||
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
|
||||
@@ -64,7 +69,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}
|
||||
@@ -102,23 +107,67 @@ p{hyphens:auto}
|
||||
.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:650px){.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}}
|
||||
@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 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}
|
||||
@@ -129,7 +178,7 @@ 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
|
||||
@@ -190,6 +239,11 @@ body.consent-open{padding-bottom:320px}
|
||||
.embed-title{margin:0 0 8px;font-weight:600}
|
||||
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:var(--muted)}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
|
||||
/* Eigenes HTML: nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt. Alles
|
||||
Weitere bestimmt der Code selbst - das ist der Zweck des Bausteins. */
|
||||
.block-html{max-width:100%}
|
||||
.block-html img,.block-html video,.block-html table{max-width:100%}
|
||||
.block-html table{border-collapse:collapse}
|
||||
|
||||
/* Header and footer hold block documents of their own (ADR 026). They are therefore ordinary
|
||||
block containers - as flex containers they shrank the section inside them until a single word
|
||||
@@ -263,7 +317,7 @@ body.consent-open{padding-bottom:320px}
|
||||
.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)}
|
||||
@@ -273,7 +327,7 @@ body.consent-open{padding-bottom:320px}
|
||||
.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)}
|
||||
@@ -387,11 +441,16 @@ body.consent-open{padding-bottom:320px}
|
||||
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:650px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
@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. */
|
||||
.block-section>.section-inner{flex:1 1 auto}
|
||||
/* Ein Abschnitt, der ein Flex-Kasten ist, reiht seinen Inhaltskasten als Flex-Element ein.
|
||||
Wachsen darf der dabei nicht: die Laufrichtung ist voreingestellt die Zeile, und dort frisst
|
||||
das Wachsen die Randluft des Containers. Auf dem Telefon stand der Text eines Hero-Abschnitts
|
||||
damit hart an der Kante, waehrend Kopf und alle uebrigen Abschnitte ihre 18 px hatten - am
|
||||
Schreibtisch fiel es nicht auf, weil dort die Hoechstbreite des Designs vorher bremst.
|
||||
Die Hoehe braucht das Wachsen nicht: quer zur Laufrichtung dehnt sich ein Flex-Element
|
||||
ohnehin ueber die volle Hoehe, und steht "Ausrichtung: Mitte", ist die Mitte gewollt. */
|
||||
.block-section>.section-inner{flex:0 1 auto}
|
||||
|
||||
/* Menu kinds. Horizontal is the default; vertical stacks; burger is a details element, so it
|
||||
opens without a script - a menu that needs one would not open on a page that runs none. */
|
||||
@@ -423,9 +482,13 @@ body.consent-open{padding-bottom:320px}
|
||||
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}
|
||||
/* Die Flaeche malt ihren eigenen Untergrund, also setzt sie auch ihre eigene Schriftfarbe.
|
||||
Ohne das erbte sie die Farbe des Kopfbereichs: bei einem Kopf, der ueber einem dunklen Bild
|
||||
schwebt und deshalb weiss schreibt, stand das aufgeklappte Menue weiss auf weiss. */
|
||||
.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;
|
||||
background:var(--paper);box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
|
||||
background:var(--paper);color:var(--ink);
|
||||
box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
|
||||
.site-header .menu-burger-body .menu-list{gap:0}
|
||||
.site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px}
|
||||
/* Visibility never overwrites the block's grid/flex/display settings. */
|
||||
@@ -434,6 +497,13 @@ body.consent-open{padding-bottom:320px}
|
||||
@media (min-width:1025px){.visibility-tablet,.visibility-mobile,.visibility-hide-desktop{display:none!important}}
|
||||
@media (min-width:768px) and (max-width:1024px){.visibility-desktop,.visibility-mobile,.visibility-hide-tablet{display:none!important}}
|
||||
@media (max-width:767px){.visibility-desktop,.visibility-tablet,.visibility-hide-mobile{display:none!important}}
|
||||
/* Das Sprungziel des Knopfs „nach oben“, ein leerer <span> am Anfang von <body>.
|
||||
WebKit springt nicht zu einem Ziel, dessen Box inline ist und keine Flaeche hat - der Tipp
|
||||
kam an, #seitenanfang stand in der Adresse, und die Seite blieb stehen. Auf dem iPhone,
|
||||
und ebenso in Safari auf dem Mac; Chromium ist toleranter, deshalb fiel es am Schreibtisch
|
||||
nie auf. Ein leerer Block ohne Hoehe reicht WebKit, es ist die Inline-Box, die stoert.
|
||||
Nachgestellt in Playwright-WebKit mit iPhone-Profil: inline bleibt bei 1500, block springt. */
|
||||
#seitenanfang{display:block}
|
||||
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
|
||||
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
|
||||
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
|
||||
@@ -487,9 +557,36 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
|
||||
.zoom-prev{left:8px}.zoom-next{right:8px}
|
||||
}
|
||||
/* Der PDF-Betrachter: fast bildschirmfüllend, mit einer schmalen Leiste darüber. Den Rest macht
|
||||
der Betrachter des Browsers - Blättern, Zoom, Drucken. */
|
||||
.pdf-dialog{border:0;padding:0;width:min(96vw,1200px);height:94vh;max-width:none;max-height:none;background:#1b212c;color:#fff;border-radius:12px;overflow:hidden}
|
||||
.pdf-dialog[open]{display:flex;flex-direction:column}
|
||||
.pdf-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
.pdf-bar{display:flex;align-items:center;gap:10px;padding:10px 10px 10px 18px}
|
||||
.pdf-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#fff}
|
||||
.pdf-action{color:#fff;font-size:13px;text-decoration:none;padding:9px 14px;border-radius:999px;background:rgba(255,255,255,.12);white-space:nowrap}
|
||||
.pdf-action:hover{background:rgba(255,255,255,.24)}
|
||||
.pdf-close{display:grid;place-items:center;width:40px;height:40px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:24px;line-height:1;cursor:pointer}
|
||||
.pdf-close:hover{background:rgba(255,255,255,.26)}
|
||||
.pdf-action:focus-visible,.pdf-close:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||
.pdf-frame{flex:1;width:100%;border:0;background:#525659}
|
||||
|
||||
.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none}
|
||||
|
||||
/* 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}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Legt auf der bestehenden DEEG-Installation die Seite „Angebote“ an und verweist von der
|
||||
* Navigation und vom Angebote-Band der Startseite dorthin.
|
||||
*
|
||||
* Die Seite zeigt die Ergebnisliste des DEEG-Portals bei ImmobilienScout24 als eingebetteten
|
||||
* Rahmen – Baustein „Externer Inhalt“, nicht eigenes HTML: nur er fragt vorher um Einwilligung
|
||||
* und trägt den Anbieter in die Sicherheitsrichtlinie der Seite ein. Kopfabschnitt, Schriftgrößen
|
||||
* und Farben sind dieselben wie bei Impressum und Datenschutz (siehe seed-immobilien-deeg.php).
|
||||
*
|
||||
* Läuft nur gegen die DEEG-Datenbank und nur, solange es die Seite noch nicht gibt.
|
||||
*/
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
if (!str_contains($url, 'immobilien-deeg')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$db = ConnectionFactory::create($url);
|
||||
$media = new MediaLibrary($db, str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']));
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
|
||||
// Die Seite war im Editor schon angelegt (Starterabschnitt plus Rahmen mit Platzhaltertexten).
|
||||
// Sie wird überschrieben, nicht verdoppelt; der bisherige Stand bleibt als Fassung erhalten und
|
||||
// lässt sich im Editor unter „Fassungen“ zurückholen.
|
||||
$vorhanden = $db->fetchOne('SELECT id FROM page WHERE path = ?', ['angebote']);
|
||||
|
||||
$SCHEMA = 24;
|
||||
$portal = 'https://portal.immobilienscout24.de/ergebnisliste/16200281';
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
|
||||
// Der Kopf wie auf Impressum und Datenschutz: helle Fläche, Kleinzeile, große leichte Überschrift.
|
||||
$kopf = ['id' => 'angebote-kopf', 'type' => 'section', 'children' => [
|
||||
['id' => 'angebote-label', 'type' => 'text', 'props' => ['text' => 'AKTUELLE OBJEKTE'], 'style' => [
|
||||
'desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'margin' => ['bottom' => '25px']],
|
||||
'mobile' => ['fontSize' => '11px', 'margin' => ['bottom' => '18px']],
|
||||
], 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
['id' => 'angebote-h1', 'type' => 'heading', 'props' => ['text' => 'Unsere Angebote', 'level' => 1], 'style' => [
|
||||
'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']]],
|
||||
['id' => 'angebote-vorspann', 'type' => 'text', 'props' => ['text' => 'Wohnungen, Häuser und Grundstücke aus unserem Bestand in Freudenstadt und Umgebung. Die Liste wird laufend aktualisiert – sehen Sie sich um und sprechen Sie uns an.'], 'style' => [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
], 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'medium']]],
|
||||
], 'style' => [
|
||||
'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']],
|
||||
]];
|
||||
|
||||
// Die Liste selbst. Der Rahmen bleibt hoch genug, dass mehrere Objekte ohne Scrollen im Rahmen
|
||||
// sichtbar sind; darunter der Weg zum Portal für alle, die lieber dort suchen.
|
||||
$hinweisText = 'Nichts Passendes dabei? <a href="'.$portal.'" target="_blank" rel="noopener">Alle Objekte direkt bei ImmobilienScout24 ansehen ↗</a> oder <a href="/#kontakt">sprechen Sie uns an</a> – wir kennen den Markt vor Ort und wissen oft von Objekten, bevor sie inseriert sind.';
|
||||
$liste = ['id' => 'angebote-liste', 'type' => 'section', 'children' => [
|
||||
['id' => 'angebote-rahmen', 'type' => 'embed', 'props' => [
|
||||
'group' => 'external',
|
||||
'title' => 'Aktuelle Immobilienangebote der Deeg Hausverwaltung und Immobilien GmbH bei ImmobilienScout24',
|
||||
'provider' => 'ImmobilienScout24',
|
||||
'url' => $portal,
|
||||
'hint' => 'Die Angebotsliste wird von ImmobilienScout24 geladen. Dabei erfährt der Anbieter Ihre IP-Adresse und welche Seite Sie ansehen.',
|
||||
'height' => 1000,
|
||||
], 'style' => ['desktop' => ['margin' => ['bottom' => '26px']]], 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']],
|
||||
// Der Rahmen selbst und der Platzhalter davor tragen den Zuschnitt der Vorlage: eckig,
|
||||
// helle Fläche, Logo-Blau auf dem Knopf. Was IM Rahmen steht, gehört ImmobilienScout24 -
|
||||
// daran kommt kein CSS dieser Website heran, und das ist keine Einstellung, sondern die
|
||||
// Trennung zwischen zwei Herkünften im Browser.
|
||||
'css' => "& iframe{border:1px solid #dbe1e3;border-radius:0;background:#f5fafd}
|
||||
&.embed-blocked{border:1px solid #dbe1e3;border-radius:0;background:#f5fafd;padding:56px 24px;text-align:left}
|
||||
& .embed-title{font-size:20px;font-weight:700;color:#343a40;margin-bottom:12px}
|
||||
& .embed-hint{max-width:640px;font-size:15px;line-height:1.7;color:#58666d}
|
||||
& .embed-provider{font-size:13px;color:#66747b}
|
||||
& .consent-button{border-radius:0;background:#0076b6;border-color:#0076b6;color:#fff;flex:0 0 auto;padding:16px 24px}
|
||||
& .consent-button:hover{background:#006aa3;border-color:#006aa3}",
|
||||
],
|
||||
['id' => 'angebote-hinweis', 'type' => 'text', 'props' => ['text' => $hinweisText], 'style' => [
|
||||
'desktop' => ['maxWidth' => '760px', 'fontSize' => '16px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
], 'css' => "& a{color:#006aa3;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#343a40}",
|
||||
'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
|
||||
], 'style' => [
|
||||
'desktop' => ['padding' => ['top' => '72px', 'bottom' => '108px']],
|
||||
'tablet' => ['padding' => ['top' => '56px', 'bottom' => '80px']],
|
||||
'mobile' => ['padding' => ['top' => '40px', 'bottom' => '65px']],
|
||||
]];
|
||||
|
||||
$dokumentAngebote = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Angebote',
|
||||
'description' => 'Aktuelle Wohnungen, Häuser und Grundstücke der Deeg Hausverwaltung und Immobilien GmbH in Freudenstadt und Umgebung.',
|
||||
'blocks' => [$kopf, $liste],
|
||||
];
|
||||
if (false !== $vorhanden) {
|
||||
$id = (string) $vorhanden;
|
||||
$revision = $pages->saveDraft($id, $dokumentAngebote, (int) $pages->findForEditor($id)['version']);
|
||||
$pages->publish($id, $revision, (int) $pages->findForEditor($id)['version']);
|
||||
echo "Seite überarbeitet und veröffentlicht: /angebote\n";
|
||||
} else {
|
||||
$id = $pages->createPage('angebote', null, 'page', $dokumentAngebote);
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
echo "Seite angelegt und veröffentlicht: /angebote\n";
|
||||
}
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', ['Aktuelle Angebote | Deeg Hausverwaltung und Immobilien GmbH', $id]);
|
||||
|
||||
// „Immobilien“ in der Navigation zeigte auf den Sprungpunkt der Startseite; jetzt gibt es die
|
||||
// Seite, auf die dieser Abschnitt ohnehin nur verwiesen hat.
|
||||
$header = $menus->all()['header'];
|
||||
$eintraege = [];
|
||||
foreach ($header['items'] as $eintrag) {
|
||||
if ('Immobilien' === $eintrag['label']) {
|
||||
$eintrag['pageId'] = $id;
|
||||
$eintrag['anchor'] = '';
|
||||
}
|
||||
$eintraege[] = $eintrag;
|
||||
}
|
||||
$menus->save('header', $header['name'], $eintraege, (int) $header['version']);
|
||||
echo "Navigation: „Immobilien“ zeigt auf /angebote\n";
|
||||
|
||||
// Und der Knopf im Angebote-Band der Startseite, der bisher auf die alte Website führte.
|
||||
$home = $db->fetchAssociative('SELECT id FROM page WHERE path = ?', ['home']);
|
||||
$dokument = $pages->findForEditor($home['id'])['document'];
|
||||
$getroffen = 0;
|
||||
$walk = function (array &$blocks) use (&$walk, &$getroffen): void {
|
||||
foreach ($blocks as &$block) {
|
||||
// Der Knopf im Angebote-Band ist ein Textbaustein mit eigenem CSS, kein Button-Baustein -
|
||||
// deshalb wird die Adresse im Text ersetzt, nicht in den Eigenschaften.
|
||||
foreach (['href', 'text'] as $feld) {
|
||||
$wert = (string) ($block['props'][$feld] ?? '');
|
||||
if ('' !== $wert && str_contains($wert, 'immobiliendeeg.de/immobilien')) {
|
||||
$block['props'][$feld] = str_replace(['https://www.immobiliendeeg.de/immobilien/', 'https://www.immobiliendeeg.de/immobilien'], '/angebote', $wert);
|
||||
unset($block['props']['newTab']);
|
||||
++$getroffen;
|
||||
}
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$walk($block['children']);
|
||||
}
|
||||
}
|
||||
};
|
||||
$walk($dokument['blocks']);
|
||||
if ($getroffen > 0) {
|
||||
$stand = $pages->findForEditor($home['id']);
|
||||
$revision = $pages->saveDraft($home['id'], $dokument, $stand['version']);
|
||||
$pages->publish($home['id'], $revision, $pages->findForEditor($home['id'])['version']);
|
||||
}
|
||||
echo "Startseite: $getroffen Knopf/Knöpfe zeigen jetzt auf /angebote\n";
|
||||
|
||||
// Ein fremder Rahmen ohne Einwilligung lädt beim Seitenaufruf mit und gibt dabei IP-Adresse und
|
||||
// Seite an den Anbieter weiter. Mit der Gruppe „Externe Inhalte“ steht davor der Platzhalter, und
|
||||
// erst der Knopf lädt die Liste. Der Balken trägt die Farben der Website.
|
||||
$aktuell = $settings->load();
|
||||
$settings->save([
|
||||
...$aktuell,
|
||||
// Gespeichert als Liste, erwartet als Textfeld - wie es der Einstellungsbildschirm schickt.
|
||||
'sameAs' => implode("\n", $aktuell['sameAs']),
|
||||
'consentBanner' => true,
|
||||
'consentGroups' => ['external'],
|
||||
'consentShape' => 'eckig',
|
||||
'consentAccent' => '@blau-tief',
|
||||
'consentAccentInk' => '@papier',
|
||||
'consentColor' => '@papier',
|
||||
'consentInk' => '@anthrazit',
|
||||
]);
|
||||
echo "Einwilligung eingeschaltet: Gruppe „Externe Inhalte“, Balken eckig in Logo-Blau\n";
|
||||
@@ -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'));
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Zeichnet die Bildkarten des Solarchecks von EB Solar und rastert sie zu PNG.
|
||||
*
|
||||
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
|
||||
* Mediathek hat - und die Mediathek nimmt JPEG, PNG und WebP, kein SVG. Deshalb entstehen die
|
||||
* Symbole hier als SVG (leicht zu ändern, alles in einer Datei) und werden mit Chromium auf
|
||||
* 800 × 600 gerastert, dem Seitenverhältnis 4:3 der Karte. Stil wie die übrigen Symbole der
|
||||
* Website: dünne Linie in Tinte, runde Enden, ein roter Akzent, helle Fläche dahinter.
|
||||
*
|
||||
* node scripts/build-ebsolar-options.mjs → var/ebsolar-assets/options/*.png und *.svg
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ziel = process.argv[2] || 'var/ebsolar-assets/options';
|
||||
const TINTE = '#252b30', ROT = '#b52c2c', HELL = '#f5f6f6', PAPIER = '#ffffff', GRAU = '#c9ced0';
|
||||
|
||||
/** Ein Bild: 800 × 600, helle Fläche, Inhalt in der Mitte. */
|
||||
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
|
||||
<rect width="800" height="600" fill="${HELL}"/>
|
||||
<g fill="none" stroke="${TINTE}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
|
||||
</svg>`;
|
||||
|
||||
/** Eine Person: Kopf und Schultern. */
|
||||
const person = (x, y, s = 1, farbe = TINTE) => `
|
||||
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
|
||||
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
|
||||
|
||||
/** Ein Haus: Wände plus Dach nach Angabe; das Dach ist der Akzent. */
|
||||
const haus = (dach) => `
|
||||
<path d="M190 520V300M610 520V300M150 520h500"/>
|
||||
<rect x="360" y="410" width="80" height="110" rx="6"/>
|
||||
${dach}`;
|
||||
|
||||
/** Ein Dach als schräge Fläche mit Modulen im Raster. */
|
||||
const module = (spalten, zeilen) => {
|
||||
const x0 = 160, y0 = 150, b = 480, h = 300, scher = 90;
|
||||
const zellen = [];
|
||||
for (let z = 0; z < zeilen; z++) {
|
||||
for (let s = 0; s < spalten; s++) {
|
||||
const bx = b / spalten, bh = h / zeilen;
|
||||
const x = x0 + s * bx + (z * bh) * (scher / h), y = y0 + z * bh;
|
||||
zellen.push(`<path d="M${x + 8} ${y + 8}h${bx - 16}l${(bh - 16) * scher / h} ${bh - 16}h-${bx - 16}z" fill="${ROT}" fill-opacity=".14" stroke="${ROT}" stroke-width="8"/>`);
|
||||
}
|
||||
}
|
||||
return `<path d="M${x0} ${y0}h${b}l${scher} ${h}h-${b}z"/>${zellen.join('')}`;
|
||||
};
|
||||
|
||||
const fragezeichen = (x, y, s = 1) => `<path d="M${x - 40 * s} ${y - 50 * s}a${40 * s} ${40 * s} 0 1 1 ${58 * s} ${36 * s}c-${14 * s} ${9 * s}-${18 * s} ${18 * s}-${18 * s} ${34 * s}" stroke="${ROT}"/><circle cx="${x}" cy="${y + 65 * s}" r="9" fill="${ROT}" stroke="none"/>`;
|
||||
|
||||
const batterie = (inhalt, farbe = TINTE) => `
|
||||
<rect x="190" y="200" width="380" height="200" rx="26" stroke="${farbe}"/>
|
||||
<path d="M570 260h30a20 20 0 0 1 20 20v40a20 20 0 0 1-20 20h-30" stroke="${farbe}"/>
|
||||
${inhalt}`;
|
||||
|
||||
const bilder = {
|
||||
// Wie viele Personen leben im Haushalt?
|
||||
'personen-1': person(400, 320, 1.3),
|
||||
'personen-2': person(300, 320, 1.15) + person(500, 320, 1.15),
|
||||
'personen-3-4': person(210, 330) + person(400, 330) + person(590, 330),
|
||||
'personen-4-plus': person(160, 330, .85) + person(320, 330, .85) + person(480, 330, .85) + person(640, 330, .85)
|
||||
+ `<path d="M715 140v70M680 175h70" stroke="${ROT}"/>`,
|
||||
|
||||
// Welche Form hat Ihr Dach?
|
||||
'dach-flach': haus(`<path d="M150 300h500" stroke="${ROT}"/><path d="M200 300v-40h400v40" stroke="${ROT}"/>`),
|
||||
'dach-sattel': haus(`<path d="M140 320 400 120l260 200" stroke="${ROT}"/>`),
|
||||
'dach-pult': haus(`<path d="M150 330 650 190" stroke="${ROT}"/><path d="M610 300v-95" />`),
|
||||
'dach-versetzt': `
|
||||
<path d="M120 520h560M160 520V370M440 520V270M660 520V330"/>
|
||||
<rect x="260" y="420" width="70" height="100" rx="6"/>
|
||||
<path d="M120 380 290 250l150 120" stroke="${ROT}"/><path d="M440 290 560 180l130 140" stroke="${ROT}"/>`,
|
||||
'dach-sonstiges': haus(`<path d="M140 320 400 120l260 200" stroke="${GRAU}"/>`) + fragezeichen(400, 250, 1.1),
|
||||
|
||||
// Wie groß ist Ihre Dachfläche?
|
||||
'flaeche-50': module(2, 1),
|
||||
'flaeche-100': module(3, 2),
|
||||
'flaeche-200': module(5, 3),
|
||||
'flaeche-200-plus': module(7, 4),
|
||||
'flaeche-unsicher': `<path d="M160 150h480l90 300H160z" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
|
||||
|
||||
// Aus welchem Material besteht die Dacheindeckung?
|
||||
'eindeckung-ziegel': [0, 1, 2, 3].map(z => [0, 1, 2, 3, 4].map(s => {
|
||||
if (z % 2 === 1 && s === 4) return '';
|
||||
const x = 150 + s * 100 + (z % 2) * 50, y = 170 + z * 80;
|
||||
return `<path d="M${x} ${y}a50 50 0 0 0 100 0" stroke="${z === 2 && s === 2 ? ROT : TINTE}"/>`;
|
||||
}).join('')).join(''),
|
||||
'eindeckung-bitumen': `
|
||||
<path d="M130 440h540v50H130z"/>
|
||||
<circle cx="250" cy="360" r="70"/><circle cx="250" cy="360" r="26"/>
|
||||
<path d="M320 400h340" stroke="${ROT}" stroke-width="22"/>
|
||||
<path d="M250 430h410" stroke="${ROT}" stroke-width="22" stroke-opacity=".35"/>`,
|
||||
'eindeckung-blech': `
|
||||
<path d="M120 340h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30"/>
|
||||
<path d="M120 420h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30" stroke="${GRAU}"/>
|
||||
<path d="M120 340v80M680 340v80" stroke="${GRAU}"/>
|
||||
<path d="M350 340l30-70h60l30 70" stroke="${ROT}"/>`,
|
||||
'eindeckung-anderes': `<rect x="200" y="150" width="400" height="300" rx="20" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
|
||||
|
||||
// Wünschen Sie einen Stromspeicher?
|
||||
'speicher-ja': batterie(`<path d="M420 230 350 310h60l-30 70 70-80h-60z" fill="${ROT}" stroke="${ROT}" stroke-width="10"/>`),
|
||||
'speicher-nein': batterie('', GRAU) + `<path d="M190 470 610 130" stroke="${ROT}"/>`,
|
||||
};
|
||||
|
||||
await mkdir(ziel, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
|
||||
for (const [name, inhalt] of Object.entries(bilder)) {
|
||||
const svg = bild(inhalt);
|
||||
await writeFile(`${ziel}/${name}.svg`, svg);
|
||||
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
|
||||
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
|
||||
console.log('gezeichnet:', name);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Schneidet aus dem gelieferten Banner (kantarci-banner.jpeg, 901 × 458) die Bilder der
|
||||
* KANTARCI-Landingpage zu: das Logo, das Hausmotiv und ein quadratisches Seitensymbol.
|
||||
* Die Mediathek nimmt kein SVG, deshalb entsteht das Symbol als PNG.
|
||||
*
|
||||
* Aufruf: php scripts/build-kantarci-assets.php <banner.jpeg> <zielordner>
|
||||
*/
|
||||
|
||||
[$_, $banner, $ziel] = $argv + [null, null, null];
|
||||
if (!is_file((string) $banner) || null === $ziel) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-kantarci-assets.php <banner.jpeg> <zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel)) {
|
||||
mkdir($ziel, 0777, true);
|
||||
}
|
||||
$quelle = imagecreatefromjpeg($banner);
|
||||
|
||||
$zuschnitt = static function (int $x, int $y, int $b, int $h, int $faktor = 1) use ($quelle) {
|
||||
$bild = imagecreatetruecolor($b * $faktor, $h * $faktor);
|
||||
imagecopyresampled($bild, $quelle, 0, 0, $x, $y, $b * $faktor, $h * $faktor, $b, $h);
|
||||
|
||||
return $bild;
|
||||
};
|
||||
|
||||
// Logo: Bildmarke und Schriftzug oben links, auf Weiß.
|
||||
imagepng($zuschnitt(22, 8, 396, 104), $ziel.'/logo-kantarci.png', 9);
|
||||
|
||||
// Hausmotiv: der Fotoanteil rechts, oberhalb des goldenen Bands.
|
||||
imagejpeg($zuschnitt(500, 0, 401, 334), $ziel.'/haus.jpg', 90);
|
||||
|
||||
// Seitensymbol: die Bildmarke mittig auf Weiß, 256 × 256.
|
||||
$symbol = imagecreatetruecolor(256, 256);
|
||||
imagefill($symbol, 0, 0, imagecolorallocate($symbol, 255, 255, 255));
|
||||
imagecopyresampled($symbol, $quelle, 28, 28, 26, 12, 200, 200, 80, 80);
|
||||
imagepng($symbol, $ziel.'/symbol-kantarci.png', 9);
|
||||
|
||||
echo "Bilder geschrieben nach $ziel\n";
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Zeichnet die Bildkarten der KANTARCI-Mietanfrage und rastert sie zu PNG.
|
||||
*
|
||||
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
|
||||
* Mediathek hat - und die Mediathek nimmt kein SVG. Deshalb entstehen die Symbole hier als SVG
|
||||
* und werden mit Chromium auf 800 × 600 (4:3 wie die Karte) gerastert. Stil der Landingpage:
|
||||
* dünne Linie in Navy, runde Enden, ein goldener Akzent, warme helle Fläche dahinter.
|
||||
*
|
||||
* node scripts/build-kantarci-options.mjs → var/kantarci-immobilien-assets/options/*.png und *.svg
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ziel = process.argv[2] || 'var/kantarci-immobilien-assets/options';
|
||||
const NAVY = '#142535', GOLD = '#b49a66', HELL = '#f5f3ee', PAPIER = '#ffffff';
|
||||
|
||||
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
|
||||
<rect width="800" height="600" fill="${HELL}"/>
|
||||
<g fill="none" stroke="${NAVY}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
|
||||
</svg>`;
|
||||
|
||||
/** Eine Person: Kopf und Schultern. */
|
||||
const person = (x, y, s = 1, farbe = NAVY) => `
|
||||
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
|
||||
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
|
||||
|
||||
/** Ein Grundriss: Außenwand, dazu Innenwände; der jeweils letzte Raum ist golden hinterlegt. */
|
||||
const grundriss = (waende, akzent) => `
|
||||
${akzent ? `<path d="${akzent}" fill="${GOLD}" fill-opacity=".22" stroke="none"/>` : ''}
|
||||
<rect x="170" y="130" width="460" height="340" rx="8"/>
|
||||
${waende}`;
|
||||
|
||||
const bilder = {
|
||||
// Wer zieht ein?
|
||||
'haushalt-allein': person(400, 330, 1.3),
|
||||
'haushalt-zu-zweit': person(275, 330, 1.1) + person(525, 330, 1.1, GOLD),
|
||||
'haushalt-familie': person(250, 320, 1.1) + person(550, 320, 1.1) + person(400, 400, .7, GOLD),
|
||||
'haushalt-wg': person(200, 340, .9) + person(400, 340, .9, GOLD) + person(600, 340, .9),
|
||||
|
||||
// Wie viele Zimmer?
|
||||
'zimmer-1': grundriss('<path d="M340 470v-50" />', 'M177 137h446v326H177z'),
|
||||
'zimmer-2': grundriss('<path d="M400 130v230M400 420v50"/>', 'M407 137h216v326H407z'),
|
||||
'zimmer-3': grundriss('<path d="M360 130v230M360 420v50M360 300h270"/>', 'M367 307h256v156H367z'),
|
||||
'zimmer-4': grundriss('<path d="M400 130v340M170 300h170M460 300h170"/>', 'M407 307h216v156H407z')
|
||||
+ `<path d="M690 110v80M650 150h80" stroke="${GOLD}"/>`,
|
||||
|
||||
// Was ist Ihnen wichtig?
|
||||
'ausstattung-balkon': `
|
||||
<path d="M250 110h300v230H250z"/><path d="M400 110v230"/>
|
||||
<path d="M170 340h460M170 340v150M630 340v150M170 490h460" stroke="${GOLD}"/>
|
||||
<path d="M245 340v150M320 340v150M400 340v150M480 340v150M555 340v150" stroke="${GOLD}" stroke-width="10"/>`,
|
||||
'ausstattung-aufzug': `
|
||||
<rect x="230" y="100" width="340" height="400" rx="12"/><path d="M400 100v400"/>
|
||||
<path d="M620 210l40-50 40 50z" fill="${GOLD}" stroke="${GOLD}"/><path d="M620 300l40 50 40-50z" fill="${GOLD}" stroke="${GOLD}"/>`,
|
||||
'ausstattung-stellplatz': `
|
||||
<rect x="230" y="110" width="340" height="380" rx="40"/>
|
||||
<path d="M340 420V180h80a60 60 0 0 1 0 120h-80" stroke="${GOLD}" stroke-width="22"/>`,
|
||||
'ausstattung-kueche': `
|
||||
<rect x="180" y="200" width="440" height="290" rx="10"/><path d="M180 270h440"/>
|
||||
<circle cx="300" cy="235" r="14" fill="${NAVY}" stroke="none"/><circle cx="360" cy="235" r="14" fill="${NAVY}" stroke="none"/>
|
||||
<rect x="250" y="320" width="300" height="120" rx="8" stroke="${GOLD}"/>
|
||||
<path d="M300 140c0-30 30-30 30-60M400 140c0-30 30-30 30-60M500 140c0-30 30-30 30-60" stroke="${GOLD}" stroke-width="10"/>`,
|
||||
'ausstattung-barrierefrei': `
|
||||
<circle cx="380" cy="130" r="36" stroke="${GOLD}"/>
|
||||
<path d="M380 180v130h120l50 110" stroke="${GOLD}"/><path d="M380 240h100"/>
|
||||
<path d="M330 260a120 120 0 1 0 170 150"/>`,
|
||||
'ausstattung-haustier': `
|
||||
<path d="M400 330c-70 0-130 70-130 120 0 40 40 50 70 40s50-10 60-10 30 0 60 10 70 0 70-40c0-50-60-120-130-120z" fill="${GOLD}" fill-opacity=".25" stroke="${GOLD}"/>
|
||||
<ellipse cx="260" cy="270" rx="42" ry="55"/><ellipse cx="350" cy="190" rx="42" ry="58"/>
|
||||
<ellipse cx="450" cy="190" rx="42" ry="58"/><ellipse cx="540" cy="270" rx="42" ry="55"/>`,
|
||||
};
|
||||
|
||||
await mkdir(ziel, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
|
||||
for (const [name, inhalt] of Object.entries(bilder)) {
|
||||
const svg = bild(inhalt);
|
||||
await writeFile(`${ziel}/${name}.svg`, svg);
|
||||
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
|
||||
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
|
||||
console.log('gezeichnet:', name);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -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', '.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 {
|
||||
|
||||
+164
-2
@@ -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
|
||||
@@ -355,6 +360,22 @@ try {
|
||||
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
|
||||
return Boolean(previous?.classList.contains('button'));
|
||||
}, headingId);
|
||||
// Ein PDF als Linkziel: im Dialog hochladen, übernehmen, die Adresse steht im Feld.
|
||||
await page.getByRole('button', { name: 'PDF aus Mediathek wählen', exact: true }).click();
|
||||
const documentDialog = page.locator('dialog.document-dialog');
|
||||
await documentDialog.waitFor();
|
||||
await documentDialog.locator('#document-upload:enabled').waitFor({ state: 'attached' });
|
||||
await documentDialog.locator('#document-upload').setInputFiles({ name: 'Preisliste.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF\n') });
|
||||
await documentDialog.locator('.document-card[aria-pressed="true"]').waitFor();
|
||||
assert.equal(await documentDialog.locator('#document-error').isHidden(), true, 'the PDF upload reports no error');
|
||||
const pdfAxe = await new AxeBuilder({ page }).include('dialog.document-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(pdfAxe.violations.map(v => v.id), [], 'the PDF dialog has no accessibility violations');
|
||||
await documentDialog.getByRole('button', { name: 'PDF übernehmen', exact: true }).click();
|
||||
await documentDialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/);
|
||||
await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? ''));
|
||||
checks.push({ name: 'a PDF from the library as a link target', passed: true });
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
@@ -839,6 +860,144 @@ 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 });
|
||||
|
||||
// --- Der Baustein „Eigenes HTML“ (ADR 106) -------------------------------------------------
|
||||
// Markup des Betreibers, unveraendert bis auf die oeffentliche Seite. Geprueft wird der Weg,
|
||||
// den ein Administrator geht: einfuegen, eintippen, Vorschau, veroeffentlichen - und dass
|
||||
// <script> und <iframe> mit einer Meldung abgewiesen werden statt still zu verschwinden.
|
||||
await page.goto(origin + '/admin/pages/test-home');
|
||||
await treeSelect('headline');
|
||||
await addFromLibrary('[data-add="html"]');
|
||||
await page.locator('#properties-title').filter({ hasText: 'Eigenes HTML' }).waitFor();
|
||||
await page.getByLabel('HTML', { exact: true }).fill('<table class="preise"><tbody><tr><td>Miete</td><td>ab 8 €/m²</td></tr></tbody></table>');
|
||||
// Erst der Fokuswechsel schickt den Wert ins Dokument; die Vorschau folgt kurz darauf.
|
||||
await page.locator('#preview-status').click();
|
||||
await page.waitForFunction(() => Boolean(document.querySelector('#preview').contentDocument.querySelector('.block-html table.preise')), null, { timeout: 20000 });
|
||||
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 html = await (await page.request.get(origin + '/')).text();
|
||||
assert.match(html, /class="block-html"><table class="preise">/, 'Eigenes HTML unverändert auf der Website');
|
||||
// Der Baustein selbst bringt kein Skript auf die Seite - die Tabelle steht da, wie sie
|
||||
// eingegeben wurde, und sonst nichts. (Dass eine Seite ohne Skript weiterhin
|
||||
// script-src 'none' sendet, prüft CmsTest; hier trägt das Plugin von vorhin schon eines bei.)
|
||||
assert.equal(html.includes('<table class="preise"><tbody><tr><td>Miete</td>'), true, 'Markup Zeichen für Zeichen');
|
||||
}
|
||||
for (const [markup, meldung] of [
|
||||
['<script>alert(1)</script>', /Eigenes HTML führt keine Skripte aus/],
|
||||
['<iframe src="https://example.com"></iframe>', /Externer Inhalt/],
|
||||
]) {
|
||||
await page.getByLabel('HTML', { exact: true }).fill(markup);
|
||||
await page.locator('#preview-status').click();
|
||||
await page.locator('#save').click();
|
||||
// Abgewiesen wird schon die Vorschau, und das Speichern meldet es in der Leiste über dem
|
||||
// Editor - die Seite bleibt, wie sie veröffentlicht ist.
|
||||
await page.locator('#message').filter({ hasText: meldung }).waitFor();
|
||||
}
|
||||
await page.getByLabel('HTML', { exact: true }).fill('<p class="notiz">Wieder gut.</p>');
|
||||
await page.locator('#preview-status').click();
|
||||
await page.locator('#save').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/own-html.png', fullPage: true });
|
||||
checks.push({ name: 'own HTML block: inserted, previewed, published unchanged, script and iframe refused', 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 [
|
||||
@@ -871,7 +1030,10 @@ try {
|
||||
// looked at from a visitor's browser: the banner has to appear, and the embed must not load.
|
||||
await page.goto(origin + '/admin/settings');
|
||||
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check();
|
||||
await page.getByLabel('Adresse der Datenschutzseite', { exact: true }).fill('grundlagen');
|
||||
// The privacy page is chosen by search, the way the 404 page is: type, then take the hit.
|
||||
await page.getByLabel('Datenschutzseite', { exact: true }).fill('grundlagen');
|
||||
await page.locator('#privacyPath-results [role=option]').filter({ hasText: '/grundlagen' }).first().click();
|
||||
assert.equal(await page.locator('.content-picker:has(#privacyPath) .picker-value').inputValue(), 'grundlagen');
|
||||
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Wirft die EB-Solar-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/ebsolar.db"
|
||||
export MEDIA_DIRECTORY="$root/var/ebsolar-media"
|
||||
rm -f "$root/var/ebsolar.db"
|
||||
rm -rf "$root/var/ebsolar-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/ebsolar-assets}"
|
||||
# Die Vorlage nutzt Arial; es gibt keine Schrift zu holen.
|
||||
"$php" -d memory_limit=512M scripts/seed-ebsolar.php "$assets"
|
||||
@@ -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,17 @@
|
||||
#!/bin/sh
|
||||
# Wirft die KANTARCI-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/kantarci-immobilien.db"
|
||||
export MEDIA_DIRECTORY="$root/var/kantarci-immobilien-media"
|
||||
rm -f "$root/var/kantarci-immobilien.db"
|
||||
rm -rf "$root/var/kantarci-immobilien-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/kantarci-immobilien-assets}"
|
||||
# Logo, Hausmotiv und Symbol sind Zuschnitte aus dem gelieferten Banner, die Bildkarten
|
||||
# der Mietanfrage zeichnet Chromium (Playwright).
|
||||
"$php" scripts/build-kantarci-assets.php "$assets/kantarci-banner.jpeg" "$assets"
|
||||
node scripts/build-kantarci-options.mjs "$assets/options"
|
||||
# Die Vorlage nutzt Arial und Georgia; es gibt keine Schrift zu holen.
|
||||
"$php" -d memory_limit=512M scripts/seed-kantarci-immobilien.php "$assets"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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, 'imageRadius' => '0px'],
|
||||
['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,733 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut die Landingpage „KANTARCI Immobilien – Mietanfrage“ als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist das gelieferte Paket „KANTARCI-Immobilien-Landingpage.zip“ (18.09.2026): eine
|
||||
* Next.js-Seite (app/page.tsx, app/globals.css) mit Kopf, dunklem Hero, Werteleiste, Mietanfrage
|
||||
* und Fuß. Wortlaut, Farben (Navy #102333, Gold #b49a66), Georgia für Überschriften und Arial
|
||||
* für den Text sind daraus übernommen. Gebaut ist alles aus gewöhnlichen Bausteinen – kein
|
||||
* eigener Baustein, kein Plugin, keine externe Schrift.
|
||||
*
|
||||
* Bewusste Abweichungen:
|
||||
* - Die Mietanfrage ist das serverseitige Formular „mietanfrage“ dieses CMS statt der
|
||||
* Cloudflare-D1-Anbindung: Eingänge landen in der Verwaltung und per E-Mail. Auf Umuts Wunsch
|
||||
* in drei Schritten mit Bildkarten (Haushalt, Zimmer, Ausstattung) und Zusammenfassung statt
|
||||
* einer langen Seite. Der Einzug ist eine Auswahl statt eines Datumsfelds (der Baukasten
|
||||
* kennt kein Datum).
|
||||
* - E-Mail überall info@kantarci-immo.de: die Domain der Vorlage gibt es nicht.
|
||||
* - Neu sind Impressum und Datenschutz, beide Pflicht: Die Seite ist ein geschäftlicher
|
||||
* Auftritt (§ 5 DDG) und das Formular verarbeitet personenbezogene Daten (Art. 13 DSGVO).
|
||||
* Anschrift, Geschäftsführer und E-Mail hat Umut am 18.09.2026 genannt; was offen ist
|
||||
* (Register, Telefon, § 34c), steht als „NOCH ZU PRÜFEN“ im Text.
|
||||
* - Logo und Hausmotiv sind Zuschnitte aus dem Banner (scripts/build-kantarci-assets.php).
|
||||
* - Die Symbole der Werteleiste kommen aus dem Symbolsatz des Kerns (kein Blatt, kein Herz).
|
||||
*
|
||||
* 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-kantarci-immobilien.sh).
|
||||
* Erstes Argument: der Ordner mit logo-kantarci.png, haus.jpg und symbol-kantarci.png.
|
||||
*/
|
||||
|
||||
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, 'kantarci-immobilien')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die KANTARCI-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 = 'KANTARCI Immobilien GmbH';
|
||||
// Die Vorlage nennt immo@kantarci-immobilien.de; die Domain gibt es nicht (18.09.2026, kein
|
||||
// DNS-Eintrag). Umut hat info@kantarci-immo.de für Impressum und Datenschutz genannt, die Domain
|
||||
// hat Mailserver - deshalb steht sie überall, auch als Empfänger der Mietanfrage.
|
||||
$mail = 'info@kantarci-immo.de';
|
||||
$gf = 'Ridvan Kantarci';
|
||||
$strasse = 'Ooser Hauptstraße 51';
|
||||
$ort = '76532 Baden-Baden';
|
||||
$SCHEMA = 24;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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'];
|
||||
|
||||
$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;
|
||||
};
|
||||
|
||||
$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, Werteleiste, Kopf, 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 $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('all' !== $sichtbarkeit) {
|
||||
$block['layout'] = ['visibility' => $sichtbarkeit];
|
||||
}
|
||||
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 Goldstrich vor der Kleinzeile, der Verlauf über dem Hausbild. */
|
||||
$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 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.
|
||||
$kleinStil = ['desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.17em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '20px']], 'mobile' => ['fontSize' => '11px']];
|
||||
$linkCss = "& a{color:#102333;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#88703f}";
|
||||
// Die Mietanfrage in drei Schritten: volle Breite statt der 640 px des Kerns, zwei Spalten dort,
|
||||
// wo zwei kurze Felder nebeneinander passen. Eigenes CSS kennt kein @media: die Raster füllen
|
||||
// deshalb mit auto-fill, am Telefon bleibt so eine Spalte. Felder über `data-label`, weil es kein
|
||||
// :nth-child kennt; Deckkraft der Nebentexte auf 1, sonst fallen sie unter 4,5:1.
|
||||
$formularCss = '& .block-form{max-width:none}
|
||||
& .form-step{grid-template-columns:repeat(auto-fill,minmax(190px,1fr));column-gap:16px;row-gap:18px}
|
||||
& .form-step>.form-field{grid-column:1/-1}
|
||||
& .form-step>.form-step-title{grid-column:1/-1}
|
||||
& .form-step>.form-step-intro{grid-column:1/-1}
|
||||
& .form-step>[data-label="Vorname"]{grid-column:span 1}
|
||||
& .form-step>[data-label="Nachname"]{grid-column:span 1}
|
||||
& .form-step>[data-label="Einzugstermin"]{grid-column:span 1}
|
||||
& .form-step>[data-label="Max. Warmmiete pro Monat"]{grid-column:span 1}
|
||||
& .form-step-title{font-family:Georgia,serif;font-size:22px;font-weight:400;color:#142535;margin-bottom:4px}
|
||||
& .form-step-intro{font-size:14px;color:#5f6b74;opacity:1}
|
||||
& .form-progress-step{opacity:1;color:#5f6b74}
|
||||
& .form-progress-step[aria-current]{color:#142535}
|
||||
& .form-field{align-content:start}
|
||||
& .form-field label{font-size:14px;font-weight:600;color:#142535}
|
||||
& .form-field input{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535}
|
||||
& .form-field select{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535}
|
||||
& .form-field textarea{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535;min-height:110px}
|
||||
& .form-choice legend{font-size:14px;font-weight:600;color:#142535}
|
||||
& .form-choice-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
& [data-label="Ausstattung"] .form-choice-grid{grid-template-columns:repeat(auto-fill,minmax(125px,1fr));gap:10px}
|
||||
& .form-step>.form-summary-body{grid-column:1/-1}
|
||||
& .form-summary-title{font-family:Arial,sans-serif;font-size:15px;font-weight:700;letter-spacing:0}
|
||||
& .form-summary-step{border-color:#dce0e2;border-radius:6px}
|
||||
& .form-choice-card label{border-color:#dce0e2;border-radius:6px;font-size:14px}
|
||||
& .field-note{font-size:13px;color:#5f6b74;opacity:1}
|
||||
& .required-mark{color:#88703f}
|
||||
& .check-row label{font-weight:400;font-size:14px;line-height:1.65;color:#576570}
|
||||
& .cms-button{justify-content:center;background:#142535;color:#fff;border-radius:3px}
|
||||
& .cms-button:hover{background:#234256;color:#fff}';
|
||||
|
||||
/** Die Kleinzeile in Versalien, wahlweise mit dem kurzen Goldstrich davor. */
|
||||
$kleinzeile = static function (string $id, string $inhalt, string $farbe = '@gold-text', bool $strich = false) use ($text, $zeile, $flaeche, $kleinStil): array {
|
||||
$zeilenText = $text($id.'-text', $inhalt, ['desktop' => [...$kleinStil['desktop'], 'color' => $farbe, 'margin' => ['bottom' => '0px']], 'mobile' => $kleinStil['mobile']]);
|
||||
if (!$strich) {
|
||||
return [...$zeilenText, 'id' => $id, 'style' => ['desktop' => [...$kleinStil['desktop'], 'color' => $farbe], 'mobile' => $kleinStil['mobile']]];
|
||||
}
|
||||
|
||||
return $zeile($id, [
|
||||
$flaeche($id.'-strich', ['desktop' => ['width' => '27px', 'height' => '1px', 'backgroundColor' => '@gold', 'flexShrink' => 0]], 'min-height:0'),
|
||||
$zeilenText,
|
||||
], '12px', ['margin' => ['top' => '0px', 'bottom' => '20px']]);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien, Formular
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'KANTARCI Immobilien', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'navy', 'name' => 'Navy', 'value' => '#102333'],
|
||||
['key' => 'tinte', 'name' => 'Schrift', 'value' => '#142535'],
|
||||
['key' => 'gold', 'name' => 'Gold (Linien, Flächen)', 'value' => '#b49a66'],
|
||||
['key' => 'gold-knopf', 'name' => 'Gold als Knopf', 'value' => '#bda16a'],
|
||||
['key' => 'gold-hell', 'name' => 'Gold auf Navy', 'value' => '#cbb17b'],
|
||||
['key' => 'gold-text', 'name' => 'Gold als Schrift auf Weiß', 'value' => '#88703f'],
|
||||
['key' => 'text', 'name' => 'Fließtext', 'value' => '#5f6b74'],
|
||||
['key' => 'text-navy', 'name' => 'Fließtext auf Navy', 'value' => '#d1d8de'],
|
||||
['key' => 'hell', 'name' => 'Helle Fläche', 'value' => '#f1f3f3'],
|
||||
['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#e1e4e5'],
|
||||
['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
// Die Vorlage lädt keine Webschrift: Arial für Text, Georgia für Überschriften.
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Georgia', 'source' => 'system', 'weights' => [400], 'fallback' => 'serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@tinte', 'containerWidth' => '1140px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
|
||||
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@gold-knopf', 'color' => '@navy', 'hoverBackgroundColor' => '#d0b582', 'hoverColor' => '@navy'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo-kantarci.png', 'KANTARCI Immobilien GmbH'],
|
||||
'haus' => ['haus.jpg', 'Modernes Wohnhaus mit begrünten Balkonen'],
|
||||
'symbol' => ['symbol-kantarci.png', 'Bildmarke von KANTARCI Immobilien'],
|
||||
// Die Bildkarten der Mietanfrage (scripts/build-kantarci-options.mjs).
|
||||
'haushalt-allein' => ['options/haushalt-allein.png', 'Symbol: eine Person'],
|
||||
'haushalt-zu-zweit' => ['options/haushalt-zu-zweit.png', 'Symbol: zwei Personen'],
|
||||
'haushalt-familie' => ['options/haushalt-familie.png', 'Symbol: zwei Erwachsene und ein Kind'],
|
||||
'haushalt-wg' => ['options/haushalt-wg.png', 'Symbol: drei Personen'],
|
||||
'zimmer-1' => ['options/zimmer-1.png', 'Grundriss mit einem Zimmer'],
|
||||
'zimmer-2' => ['options/zimmer-2.png', 'Grundriss mit zwei Zimmern'],
|
||||
'zimmer-3' => ['options/zimmer-3.png', 'Grundriss mit drei Zimmern'],
|
||||
'zimmer-4' => ['options/zimmer-4.png', 'Grundriss mit vier Zimmern'],
|
||||
'ausstattung-balkon' => ['options/ausstattung-balkon.png', 'Symbol: Balkon'],
|
||||
'ausstattung-aufzug' => ['options/ausstattung-aufzug.png', 'Symbol: Aufzug'],
|
||||
'ausstattung-stellplatz' => ['options/ausstattung-stellplatz.png', 'Symbol: Parkplatz'],
|
||||
'ausstattung-kueche' => ['options/ausstattung-kueche.png', 'Symbol: Küche'],
|
||||
'ausstattung-barrierefrei' => ['options/ausstattung-barrierefrei.png', 'Symbol: Rollstuhl'],
|
||||
'ausstattung-haustier' => ['options/ausstattung-haustier.png', 'Symbol: Pfote'],
|
||||
] 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, basename($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";
|
||||
}
|
||||
|
||||
// Drei Schritte: wer einzieht, wie gewohnt werden soll, wie wir Sie erreichen. Haushalt, Zimmer
|
||||
// und Ausstattung sind Bildkarten; die Personenzahl fragt das Formular nur, wo sie offen ist.
|
||||
$karten = static fn (array $zuordnung): array => array_map(static fn (string $schluessel): string => $medien[$schluessel]['id'], $zuordnung);
|
||||
$forms->save('mietanfrage', 'Mietanfrage', [
|
||||
['name' => 'schritt_haushalt', 'type' => 'step', 'label' => 'Ihr Haushalt', 'help' => 'Wer zieht in die neue Wohnung ein?'],
|
||||
['name' => 'haushalt', 'type' => 'radio', 'label' => 'Wer zieht ein?', 'required' => true, 'options' => ['Allein', 'Zu zweit', 'Familie mit Kindern', 'Wohngemeinschaft'],
|
||||
'images' => $karten(['Allein' => 'haushalt-allein', 'Zu zweit' => 'haushalt-zu-zweit', 'Familie mit Kindern' => 'haushalt-familie', 'Wohngemeinschaft' => 'haushalt-wg'])],
|
||||
['name' => 'personen', 'type' => 'number', 'label' => 'Personen im Haushalt', 'required' => true, 'min' => 2, 'max' => 30,
|
||||
'showIf' => ['field' => 'haushalt', 'values' => ['Familie mit Kindern', 'Wohngemeinschaft']]],
|
||||
|
||||
['name' => 'schritt_wohnen', 'type' => 'step', 'label' => 'Ihre Wohnwünsche', 'help' => 'Alles hier ist freiwillig – je mehr wir wissen, desto besser passt unser Vorschlag.'],
|
||||
['name' => 'zimmer', 'type' => 'radio', 'label' => 'Wie viele Zimmer?', 'required' => false, 'options' => ['1 Zimmer', '2 Zimmer', '3 Zimmer', '4 und mehr'],
|
||||
'images' => $karten(['1 Zimmer' => 'zimmer-1', '2 Zimmer' => 'zimmer-2', '3 Zimmer' => 'zimmer-3', '4 und mehr' => 'zimmer-4'])],
|
||||
['name' => 'ausstattung', 'type' => 'checkboxes', 'label' => 'Ausstattung', 'required' => false, 'help' => 'Was ist Ihnen wichtig? Mehrfachauswahl möglich.',
|
||||
'options' => ['Balkon oder Terrasse', 'Aufzug', 'Stellplatz', 'Einbauküche', 'Barrierefrei', 'Haustier erlaubt'],
|
||||
'images' => $karten(['Balkon oder Terrasse' => 'ausstattung-balkon', 'Aufzug' => 'ausstattung-aufzug', 'Stellplatz' => 'ausstattung-stellplatz', 'Einbauküche' => 'ausstattung-kueche', 'Barrierefrei' => 'ausstattung-barrierefrei', 'Haustier erlaubt' => 'ausstattung-haustier'])],
|
||||
['name' => 'einzug', 'type' => 'select', 'label' => 'Einzugstermin', 'required' => false, 'options' => ['Ab sofort', 'In 1 bis 3 Monaten', 'In 3 bis 6 Monaten', 'Später oder flexibel']],
|
||||
['name' => 'warmmiete', 'type' => 'number', 'label' => 'Max. Warmmiete pro Monat', 'required' => false, 'min' => 1, 'max' => 100000, 'unit' => '€'],
|
||||
['name' => 'objekt', 'type' => 'text', 'label' => 'Gewünschte Wohnung / Objektadresse', 'required' => false, 'help' => 'Noch kein bestimmtes Objekt? Lassen Sie das Feld einfach frei.'],
|
||||
|
||||
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Damit wir uns persönlich bei Ihnen melden können.'],
|
||||
['name' => 'vorname', 'type' => 'text', 'label' => 'Vorname', 'required' => true],
|
||||
['name' => 'nachname', 'type' => 'text', 'label' => 'Nachname', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefonnummer', 'required' => false, 'help' => 'Für eine persönliche Rückmeldung'],
|
||||
['name' => 'anschrift', 'type' => 'text', 'label' => 'Aktuelle Anschrift', 'required' => false, 'help' => 'Straße, Hausnummer, PLZ und Ort'],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => false, 'help' => 'Was ist Ihnen beim Wohnen sonst noch wichtig?'],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich bin einverstanden, dass KANTARCI meine Angaben zur Bearbeitung meiner Mietanfrage speichert und mich kontaktiert.'],
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Mietanfrage über die Landingpage', 'success' => 'Vielen Dank für Ihr Interesse. Ihre Mietanfrage ist angekommen – wir melden uns persönlich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Mietanfrage absenden', 'summary' => true], 0);
|
||||
echo "Formular angelegt: mietanfrage\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopf- und Fußbereich
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf', [
|
||||
$zeile('kopf-zeile', [
|
||||
$column('kopf-marke', [
|
||||
$bild('kopf-logo', $medien['logo'], 'KANTARCI Immobilien GmbH – zur Startseite', [
|
||||
'desktop' => ['maxWidth' => '300px', 'margin' => $seiten('0px')],
|
||||
'mobile' => ['maxWidth' => '220px'],
|
||||
], '/'),
|
||||
], ['desktop' => ['flexShrink' => 1, 'minWidth' => '0']]),
|
||||
$column('kopf-kontakt', [
|
||||
$zeile('kopf-kontakt-zeile', [
|
||||
$symbol('kopf-kontakt-symbol', 'mail', 20, ['desktop' => ['color' => '@navy', 'flexShrink' => 0]]),
|
||||
$text('kopf-kontakt-link', '<a href="mailto:'.$mail.'">Kontakt aufnehmen</a>', ['desktop' => ['fontSize' => '14px', 'fontWeight' => '600', 'margin' => ['bottom' => '0px']]], "& a{color:#102333}\n& a:hover{color:#88703f}"),
|
||||
], '10px'),
|
||||
], ['desktop' => ['flexShrink' => 0]], 'hide-mobile'),
|
||||
], '24px', ['justifyContent' => 'space-between']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'padding' => ['top' => '22px', 'bottom' => '22px']],
|
||||
'mobile' => ['padding' => ['top' => '16px', 'bottom' => '16px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$zeile('fuss-oben', [
|
||||
$column('fuss-marke', [
|
||||
$text('fuss-name', 'KANTARCI', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'letterSpacing' => '0.14em', 'lineHeight' => 1.3, 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('fuss-zusatz', 'IMMOBILIEN GMBH', ['desktop' => ['fontSize' => '10px', 'letterSpacing' => '0.22em', 'color' => '@gold-hell', 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
$column('fuss-spruch', [
|
||||
$text('fuss-spruch-text', 'Lebensraum schaffen. Zukunft bauen.', ['desktop' => ['fontSize' => '13px', 'color' => '#bac4cd', 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
$column('fuss-mail', [
|
||||
$text('fuss-mail-text', '<a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '13px', 'margin' => ['bottom' => '0px']]], "& a{color:#bac4cd;overflow-wrap:anywhere}\n& a:hover{color:#fff;text-decoration:underline}"),
|
||||
]),
|
||||
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'padding' => ['top' => '34px', 'bottom' => '26px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#243a4c'],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']),
|
||||
$zeile('fuss-unten', [
|
||||
$text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '12px', 'color' => '#bac4cd', 'margin' => ['bottom' => '0px']]]),
|
||||
$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' => '12px', 'gap' => '4px', 'color' => '#bac4cd']],
|
||||
'css' => "& a{color:#bac4cd}\n& a:hover{color:#fff}"],
|
||||
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '12px', 'margin' => ['bottom' => '0px']]], "& a{color:#bac4cd}\n& a:hover{color:#fff}"),
|
||||
], '22px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
|
||||
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'padding' => ['top' => '20px', 'bottom' => '26px']],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@navy', 'color' => '#bac4cd', '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 und Vorspann auf Navy, dann Absätze mit Zwischenüberschriften. */
|
||||
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array {
|
||||
$kopf = $section($praefix.'-kopf', [
|
||||
$kleinzeile($praefix.'-label', $label, '@gold-hell', true),
|
||||
$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => ['fontSize' => '52px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '20px']],
|
||||
'mobile' => ['fontSize' => '38px'],
|
||||
]),
|
||||
$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text-navy', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '72px', 'bottom' => '64px']],
|
||||
'mobile' => ['padding' => ['top' => '44px', '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' => ['fontFamily' => 'body', 'fontSize' => '16px', 'fontWeight' => '700', 'lineHeight' => 1.5, 'letterSpacing' => '0', 'color' => '@tinte', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['flexBasis' => '260px', 'flexShrink' => 0],
|
||||
'tablet' => ['flexBasis' => '200px'],
|
||||
'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'], 'mobile' => ['flexBasis' => 'auto']]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '44px', 'margin' => ['top' => '0px'],
|
||||
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px', 'bottom' => '26px']],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '20px', 'bottom' => '20px']],
|
||||
]);
|
||||
}
|
||||
|
||||
$inhalt = $section($praefix, [
|
||||
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '920px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '48px', 'bottom' => '90px']], 'mobile' => ['padding' => ['top' => '24px', 'bottom' => '56px']]]);
|
||||
|
||||
return [$kopf, $inhalt];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$wertStil = ['desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '12px', 'textAlign' => 'center', 'lineHeight' => 1.5]];
|
||||
$wert = static fn (string $id, string $icon, string $label): array => $zeile('wert-'.$id, [
|
||||
$symbol('wert-'.$id.'-symbol', $icon, 24, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]),
|
||||
$text('wert-'.$id.'-text', $label, $wertStil),
|
||||
], '13px', [], ['flexDirection' => 'column', 'gap' => '9px', 'flexGrow' => 1, 'flexBasis' => '0']);
|
||||
|
||||
$schritt = static fn (string $n, string $titel, string $inhalt): array => $zeile('schritt-'.$n, [
|
||||
$text('schritt-'.$n.'-nummer', $n, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'lineHeight' => 1.2, 'color' => '@gold-text', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#c9b58e', 'padding' => ['top' => '8px'], 'margin' => ['bottom' => '0px'], 'flexShrink' => 0]]),
|
||||
$text('schritt-'.$n.'-text', '<strong>'.$titel.'</strong>'.$inhalt, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.65, 'color' => '@text', 'maxWidth' => '290px', 'margin' => ['top' => '5px', 'bottom' => '0px']]],
|
||||
'& strong{display:block;color:#102333;font-size:15px;margin-bottom:4px}'),
|
||||
], '17px', ['alignItems' => 'flex-start', 'margin' => ['top' => '22px']]);
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Mietanfrage',
|
||||
'description' => 'Ihr neues Zuhause beginnt hier. Melden Sie Ihr Interesse an einer Mietwohnung bei KANTARCI Immobilien an – persönlich und unverbindlich.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Navy, Text links, das Haus rechts mit Verlauf und Bildzeile.
|
||||
$section('hero', [
|
||||
$row('hero-zeile', [
|
||||
$column('hero-text', [
|
||||
$kleinzeile('hero-label', 'KANTARCI IMMOBILIEN', '#d1bb90', true),
|
||||
$heading('hero-titel', 'Hier beginnt Ihr<br><em>neues Zuhause.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '62px', 'lineHeight' => 1.09, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '25px']],
|
||||
'tablet' => ['fontSize' => '45px'],
|
||||
'mobile' => ['fontSize' => '42px'],
|
||||
], '& em{font-style:normal;color:#cbb17b}'),
|
||||
$text('hero-intro', 'Ein Ort zum Ankommen. Raum für Ihr Leben. Entdecken Sie neue Mietwohnungen und teilen Sie uns mit, wie Sie wohnen möchten.', [
|
||||
'desktop' => ['maxWidth' => '450px', 'fontSize' => '16px', 'lineHeight' => 1.85, 'color' => '@text-navy', 'margin' => ['bottom' => '30px']],
|
||||
'mobile' => ['fontSize' => '15px'],
|
||||
]),
|
||||
$button('hero-knopf', 'Jetzt Mietinteresse anmelden', '#mietanfrage', ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
$text('hero-notiz', 'Unverbindlich. Persönlich. In wenigen Minuten.', ['desktop' => ['fontSize' => '12px', 'color' => '#b1bec7', 'margin' => ['top' => '14px', 'bottom' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'justifyContent' => 'center', 'padding' => ['top' => '64px', 'bottom' => '60px']],
|
||||
'tablet' => ['padding' => ['top' => '44px', 'bottom' => '44px']],
|
||||
'mobile' => ['padding' => ['top' => '40px', 'bottom' => '8px']],
|
||||
]),
|
||||
$column('hero-visual', [
|
||||
$bild('hero-bild', $medien['haus'], 'Modernes Wohnhaus mit begrünten Balkonen', [
|
||||
'desktop' => ['imageHeight' => '480px', 'objectFit' => 'cover', 'objectPosition' => 'top left', 'margin' => $seiten('0px')],
|
||||
'tablet' => ['imageHeight' => '460px'],
|
||||
'mobile' => ['imageHeight' => '280px'],
|
||||
], null, '& img{display:block;width:100%}'),
|
||||
$flaeche('hero-verlauf', ['desktop' => ['position' => 'absolute', 'left' => '0px', 'right' => '0px', 'bottom' => '0px', 'width' => 'auto', 'height' => 'auto', 'zIndex' => 1]], 'top:45%;min-height:0;background:linear-gradient(transparent,rgba(16,35,51,.88));pointer-events:none'),
|
||||
$column('hero-bildzeile', [
|
||||
$text('hero-bildzeile-klein', 'RAUM FÜR MORGEN', ['desktop' => ['fontSize' => '11px', 'letterSpacing' => '0.18em', 'color' => '#f2e4c6', 'margin' => ['bottom' => '4px']]]),
|
||||
$text('hero-bildzeile-satz', 'Wohnen. Wohlfühlen. Ankommen.', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '25px', 'lineHeight' => 1.3, 'color' => '@papier', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '21px']]),
|
||||
], ['desktop' => ['position' => 'absolute', 'left' => '32px', 'right' => '32px', 'bottom' => '28px', 'zIndex' => 2], 'mobile' => ['left' => '20px', 'bottom' => '18px']]),
|
||||
], [
|
||||
'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'backgroundColor' => '#d9dde0', 'minHeight' => '480px'],
|
||||
'tablet' => ['minHeight' => '460px'],
|
||||
'mobile' => ['minHeight' => '280px'],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '56px', 'alignItems' => 'stretch', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
'mobile' => ['gap' => '24px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '0px', 'bottom' => '0px']],
|
||||
'mobile' => ['padding' => ['top' => '0px', 'bottom' => '24px']],
|
||||
], 'start'),
|
||||
|
||||
// 2 – Die Werteleiste aus dem Banner.
|
||||
$section('werte', [
|
||||
$zeile('werte-zeile', [
|
||||
$wert('modern', 'star', 'Modern & nachhaltig'),
|
||||
$wert('komfort', 'home', 'Zeitgemäßer Wohnkomfort'),
|
||||
$wert('leben', 'people', 'Zuhause für jedes Leben'),
|
||||
], '20px', ['justifyContent' => 'space-between', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e4e6e7', 'padding' => ['top' => '26px', 'bottom' => '26px']],
|
||||
['alignItems' => 'flex-start', 'gap' => '14px', 'padding' => ['top' => '22px', 'bottom' => '22px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '0px']]]),
|
||||
|
||||
// 3 – Mietanfrage: Einleitung links, Formularkarte rechts.
|
||||
$section('anfrage', [
|
||||
$row('anfrage-zeile', [
|
||||
$column('anfrage-text', [
|
||||
$kleinzeile('anfrage-label', 'IHRE MIETANFRAGE'),
|
||||
$heading('anfrage-h2', 'Ein paar Angaben.<br>Viele Möglichkeiten.', 2, [
|
||||
'desktop' => ['fontSize' => '38px', 'lineHeight' => 1.2, 'letterSpacing' => '-0.02em', 'margin' => ['bottom' => '22px']],
|
||||
'tablet' => ['fontSize' => '33px'],
|
||||
'mobile' => ['fontSize' => '34px'],
|
||||
]),
|
||||
$text('anfrage-intro', 'Sie interessieren sich für eine bestimmte Wohnung oder suchen noch das passende Zuhause? Erzählen Sie uns kurz von Ihren Wünschen.', [
|
||||
'desktop' => ['maxWidth' => '350px', 'fontSize' => '16px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '12px']],
|
||||
'mobile' => ['maxWidth' => '100%'],
|
||||
]),
|
||||
$schritt('01', 'Anfrage ausfüllen', 'Haushalt, Wohnwünsche und Kontaktdaten – in drei kurzen Schritten.'),
|
||||
$schritt('02', 'Persönlich ins Gespräch kommen', 'Wir melden uns bei Ihnen, um die Möglichkeiten zu besprechen.'),
|
||||
$zeile('anfrage-direkt', [
|
||||
$symbol('anfrage-direkt-symbol', 'mail', 24, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]),
|
||||
$text('anfrage-direkt-text', '<strong>Lieber direkt schreiben?</strong><a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]],
|
||||
"& strong{display:block;color:#102333}\n& a{color:#5f6b74;overflow-wrap:anywhere}\n& a:hover{color:#88703f;text-decoration:underline}"),
|
||||
], '15px', ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px'], 'margin' => ['top' => '38px']], ['margin' => ['top' => '26px'], 'padding' => ['top' => '20px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '14px']], 'mobile' => ['padding' => ['top' => '0px']]]),
|
||||
$column('anfrage-karte', [
|
||||
$zeile('anfrage-karte-kopf', [
|
||||
$column('anfrage-karte-titel', [
|
||||
$heading('anfrage-karte-h3', 'Ihr Weg zum neuen Zuhause', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '20px', 'fontWeight' => '600', 'letterSpacing' => '-0.015em', 'lineHeight' => 1.35, 'margin' => ['bottom' => '5px']]]),
|
||||
$text('anfrage-karte-pflicht', 'In drei kurzen Schritten. Pflichtfelder sind mit * markiert.', ['desktop' => ['fontSize' => '13px', 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
$symbol('anfrage-karte-symbol', 'home', 32, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]),
|
||||
], '16px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px', 'bottom' => '28px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#edf0f0', 'padding' => ['bottom' => '22px']]),
|
||||
['id' => 'anfrage-formular', 'type' => 'form', 'props' => ['form' => 'mietanfrage'], 'css' => $formularCss],
|
||||
$text('anfrage-hinweis', 'Ihre Angaben werden nur für Ihre Mietanfrage verwendet – mehr dazu in den <a href="/datenschutz">Datenschutzhinweisen</a>. Bitte senden Sie hier keine Ausweiskopien oder anderen vertraulichen Unterlagen.', [
|
||||
'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '18px', 'bottom' => '0px']],
|
||||
], $linkCss),
|
||||
$text('anfrage-unverbindlich', '✓ Unverbindliche Anfrage – kein Mietvertragsabschluss.', ['desktop' => ['fontSize' => '12px', 'color' => '@text', 'textAlign' => 'center', 'margin' => ['top' => '12px', 'bottom' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e2e5e6', 'borderRadius' => ['topLeft' => '7px', 'topRight' => '7px', 'bottomRight' => '7px', 'bottomLeft' => '7px'], 'padding' => ['top' => '32px', 'right' => '34px', 'bottom' => '32px', 'left' => '34px'], 'shadowY' => '12px', 'shadowBlur' => '40px', 'shadowColor' => '#14253510'],
|
||||
'tablet' => ['padding' => ['top' => '26px', 'right' => '24px', 'bottom' => '26px', 'left' => '24px']],
|
||||
'mobile' => ['padding' => ['top' => '25px', 'right' => '20px', 'bottom' => '25px', 'left' => '20px']],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '65px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '32px'],
|
||||
'mobile' => ['gap' => '26px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['padding' => ['top' => '74px', 'bottom' => '84px']],
|
||||
'tablet' => ['padding' => ['top' => '55px', 'bottom' => '60px']],
|
||||
'mobile' => ['padding' => ['top' => '44px', 'bottom' => '48px']],
|
||||
], 'mietanfrage'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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 Text des Hero spielt beim Laden, das Hausbild bleibt ohne Einblendung (LCP).
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-label' => ['fade'],
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-intro' => ['rise', 'medium'],
|
||||
'hero-knopf' => ['rise', 'long'],
|
||||
'hero-notiz' => ['fade', 'long'],
|
||||
'anfrage-text' => ['rise'],
|
||||
'anfrage-karte' => ['rise', 'short'],
|
||||
]);
|
||||
|
||||
$anlegen('home', $startseite, 'Mietanfrage | KANTARCI Immobilien GmbH', $medien['haus']['id']);
|
||||
|
||||
$pruefen = static fn (string $was): string => '<strong>NOCH ZU PRÜFEN:</strong> '.$was;
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben der KANTARCI Immobilien GmbH.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('impressum', 'RECHTLICHE ANGABEN', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
|
||||
['Anbieterin', $firma.'<br>'.$strasse.'<br>'.$ort],
|
||||
['Vertreten durch', $gf.', Geschäftsführer'],
|
||||
['Registereintrag', $pruefen('Handelsregisternummer (HRB …) und Registergericht (Amtsgericht …).')],
|
||||
['Kontakt', "E-Mail: <a href=\"mailto:$mail\">$mail</a><br>".$pruefen('Telefonnummer ergänzen – das Gesetz verlangt neben der E-Mail einen zweiten schnellen Kontaktweg.')],
|
||||
['Umsatzsteuer', $pruefen('Umsatzsteuer-Identifikationsnummer nach § 27a UStG, falls vorhanden.')],
|
||||
['Aufsichtsbehörde', $pruefen('Wer Wohnungen für Dritte vermittelt oder verwaltet, braucht eine Erlaubnis nach § 34c GewO; dann die zuständige Erlaubnisbehörde hier eintragen. Vermietet die GmbH nur eigene Wohnungen, entfällt die Angabe.')],
|
||||
['Verantwortlich für den Inhalt', $gf.', 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. Bei Bekanntwerden von Rechtsverletzungen entfernen wir diese Inhalte 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.'],
|
||||
['Bildnachweis', 'Logo und Hausmotiv: '.$firma.'. '.$pruefen('Urheber und Lizenz des Hausfotos eintragen, falls es nicht von KANTARCI selbst stammt.')],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | '.$firma);
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Wie die KANTARCI Immobilien GmbH Daten beim Besuch der Website und bei einer Mietanfrage verarbeitet.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('datenschutz', 'IHRE DATEN', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Mietanfrage verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortliche', "$firma, $strasse, $ort, vertreten durch den Geschäftsführer $gf. E-Mail <a href=\"mailto:$mail\">$mail</a>."],
|
||||
['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>'.$pruefen('Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen und die Speicherdauer der Serverprotokolle eintragen.')],
|
||||
['3. Cookies und 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. Mietanfrage', 'Wenn Sie das Formular absenden, verarbeiten wir Ihre Kontaktdaten (Name, E-Mail, auf Wunsch Telefon und Anschrift) und Ihre Wohnwünsche (etwa Haushaltsgröße, Zimmerzahl, Einzug, Mietobergrenze und Ihre Nachricht), um Ihre Anfrage zu bearbeiten und Ihnen passende Wohnungen anzubieten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Mietvertrags). Pflichtangaben sind nur Name, E-Mail und Haushaltsgröße; ohne sie können wir die Anfrage nicht bearbeiten. Das Formular schützt 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 Bearbeitung nicht mehr nötig sind – spätestens sechs Monate nach der letzten Kontaktaufnahme, wenn kein Mietverhältnis entsteht. Entsteht ein Mietverhältnis, gelten für die dann anfallenden Unterlagen die gesetzlichen Aufbewahrungsfristen.<br><br>'.$pruefen('Frist mit der Praxis abgleichen.')],
|
||||
['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.<br><br>'.$pruefen('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' => [
|
||||
$section('fehler', [
|
||||
$kleinzeile('fehler-label', 'FEHLER 404', '@gold-hell', true),
|
||||
$heading('fehler-titel', 'Diese Seite<br><em>gibt es nicht.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '52px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '22px']],
|
||||
'mobile' => ['fontSize' => '38px'],
|
||||
], '& em{font-style:normal;color:#cbb17b}'),
|
||||
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
|
||||
'desktop' => ['maxWidth' => '460px', 'fontSize' => '17px', 'lineHeight' => 1.8, 'color' => '@text-navy', 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '110px', 'bottom' => '130px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Einstellungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => $firma,
|
||||
'description' => 'KANTARCI Immobilien GmbH: neue Mietwohnungen – modern, nachhaltig, mit zeitgemäßem Wohnkomfort. Mietinteresse unverbindlich anmelden.',
|
||||
'email' => $mail,
|
||||
'street' => $strasse, 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'DE',
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
'faviconMediaId' => $medien['symbol']['id'],
|
||||
'stickyHeader' => false, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
|
||||
'consentBanner' => false, 'scrollTop' => false, 'postMetaHeader' => false,
|
||||
]);
|
||||
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,99 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut das Favicon der DEEG-Installation aus dem Logo und setzt es.
|
||||
*
|
||||
* Bisher zeigte das Symbol auf das Logo selbst. Das Logo ist 209x100 – der Kern schneidet daraus
|
||||
* mittig ein Quadrat, und übrig blieb das „EE“ aus dem Schriftzug. Ein Favicon wird bei 16 px
|
||||
* gelesen, also braucht es ein eigenes, quadratisches Zeichen: ein Dachgiebel aus dem Logo, weiß
|
||||
* auf Logo-Blau. Das Dach ist das Markenzeichen und bleibt auch klein eine Silhouette; der
|
||||
* flächige blaue Grund trägt es auf hellen wie dunklen Browserleisten.
|
||||
*
|
||||
* Gerendert wird aus `logo-deeg.png`, damit das Symbol bei einem neuen Logo neu entsteht statt
|
||||
* als Fremdkörper in der Mediathek zu liegen. Läuft nur gegen die DEEG-Datenbank.
|
||||
*/
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
if (!str_contains($url, 'immobilien-deeg')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$quelle = $argv[1] ?? $root.'/var/immobilien-deeg-assets/logo-deeg.png';
|
||||
if (!is_file($quelle)) {
|
||||
fwrite(STDERR, "Logo nicht gefunden: $quelle\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Der linke Giebel des Doppeldachs im Logo. Die Maße stammen aus dem Bild selbst: das Dach liegt
|
||||
// über dem Schriftzug, x 3-203, y 0-40; die Hälfte davon ist ein Giebel.
|
||||
$AUSSCHNITT = ['x1' => 3, 'y1' => 0, 'x2' => 103, 'y2' => 40];
|
||||
$BLAU = [0, 129, 199]; // #0081c7, das Logo-Blau
|
||||
$KANTE = 512; // Vorlage für alle Ableitungen des Kerns (16 bis 180)
|
||||
$RAND = 0.06; // wenig Luft: klein gelesen zählt jede Fläche
|
||||
|
||||
$logo = imagecreatefrompng($quelle);
|
||||
$w = $AUSSCHNITT['x2'] - $AUSSCHNITT['x1'] + 1;
|
||||
$h = $AUSSCHNITT['y2'] - $AUSSCHNITT['y1'] + 1;
|
||||
$zeichen = imagecreatetruecolor($w, $h);
|
||||
imagealphablending($zeichen, false);
|
||||
imagesavealpha($zeichen, true);
|
||||
imagefilledrectangle($zeichen, 0, 0, $w, $h, imagecolorallocatealpha($zeichen, 0, 0, 0, 127));
|
||||
for ($y = 0; $y < $h; ++$y) {
|
||||
for ($x = 0; $x < $w; ++$x) {
|
||||
// Das Logo ist ein Palettenbild; imagecolorsforindex beantwortet Palette und Truecolor.
|
||||
$c = imagecolorsforindex($logo, imagecolorat($logo, $AUSSCHNITT['x1'] + $x, $AUSSCHNITT['y1'] + $y));
|
||||
$hell = $c['red'] > 230 && $c['green'] > 230 && $c['blue'] > 230;
|
||||
if ($c['alpha'] > 100 || $hell) {
|
||||
continue;
|
||||
}
|
||||
imagesetpixel($zeichen, $x, $y, imagecolorallocate($zeichen, 255, 255, 255));
|
||||
}
|
||||
}
|
||||
imagedestroy($logo);
|
||||
|
||||
$symbol = imagecreatetruecolor($KANTE, $KANTE);
|
||||
imagealphablending($symbol, false);
|
||||
imagesavealpha($symbol, true);
|
||||
imagefilledrectangle($symbol, 0, 0, $KANTE, $KANTE, imagecolorallocate($symbol, $BLAU[0], $BLAU[1], $BLAU[2]));
|
||||
imagealphablending($symbol, true);
|
||||
$platz = (int) round($KANTE * (1 - 2 * $RAND));
|
||||
$faktor = min($platz / $w, $platz / $h);
|
||||
$zw = (int) round($w * $faktor);
|
||||
$zh = (int) round($h * $faktor);
|
||||
imagecopyresampled($symbol, $zeichen, (int) (($KANTE - $zw) / 2), (int) (($KANTE - $zh) / 2), 0, 0, $zw, $zh, $w, $h);
|
||||
imagedestroy($zeichen);
|
||||
|
||||
$datei = sys_get_temp_dir().'/favicon-deeg.png';
|
||||
imagepng($symbol, $datei, 9);
|
||||
imagedestroy($symbol);
|
||||
echo "Symbol gerendert: {$KANTE}x{$KANTE}\n";
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
$media = new MediaLibrary($db, str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']));
|
||||
$settings = new SiteSettings($db, $media);
|
||||
|
||||
$eintrag = $media->upload(new UploadedFile($datei, 'favicon-deeg.png', null, null, true));
|
||||
$media->update($eintrag['id'], [
|
||||
'title' => 'Favicon DEEG',
|
||||
'alt' => 'Dachgiebel aus dem DEEG-Logo, weiß auf Blau',
|
||||
'archived' => false,
|
||||
'version' => $eintrag['version'],
|
||||
]);
|
||||
@unlink($datei);
|
||||
echo "In der Mediathek: {$eintrag['id']}\n";
|
||||
|
||||
$aktuell = $settings->load();
|
||||
$settings->save([
|
||||
...$aktuell,
|
||||
// Gespeichert als Liste, erwartet als Textfeld - wie es der Einstellungsbildschirm schickt.
|
||||
'sameAs' => implode("\n", $aktuell['sameAs']),
|
||||
'faviconMediaId' => $eintrag['id'],
|
||||
]);
|
||||
echo "Favicon gesetzt; der Kern hat 32 px (Browserleiste) und 180 px (Apple-Touch) daraus gerendert.\n";
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Räumt Texte auf, die vor 1.20.1 bei jedem Speichern eine Kodierungsstufe dazubekommen haben.
|
||||
*
|
||||
* `RichText::sanitise()` hat Text ohne ein einziges Tag nur escaped, statt ihn vorher zu
|
||||
* dekodieren. Aus „Freudenstadt & Umgebung“ wurde deshalb `&amp;`, dann `&amp;amp;`
|
||||
* – bis die Entity sichtbar auf der Seite stand. Der Fehler ist behoben; dieser Befehl bringt in
|
||||
* Ordnung, was schon in der Datenbank liegt.
|
||||
*
|
||||
* Angefasst wird, was in Gebrauch ist: die veröffentlichte und die Entwurfsfassung jeder Seite
|
||||
* und die Bereiche. Ältere Fassungen bleiben Historie – wer eine zurückholt, speichert sie
|
||||
* danach einmal, und der reparierte Kern schreibt sie richtig.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:repair:entities', description: 'Repariert doppelt kodierte Texte (&amp;) aus Fassungen vor 1.20.1.')]
|
||||
final class RepairEntitiesCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('schreiben', null, InputOption::VALUE_NONE, 'Die Korrekturen wirklich speichern. Ohne diese Option wird nur gezeigt, was sich ändern würde.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$schreiben = (bool) $input->getOption('schreiben');
|
||||
$gesamt = 0;
|
||||
$zeilen = [];
|
||||
|
||||
// Die aktiven Fassungen jeder Seite: veröffentlicht und Entwurf.
|
||||
$aktive = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page') as $seite) {
|
||||
foreach (['published_revision_id' => 'veröffentlicht', 'draft_revision_id' => 'Entwurf'] as $spalte => $was) {
|
||||
if (!empty($seite[$spalte])) {
|
||||
$aktive[(string) $seite[$spalte]] = '/'.$seite['path'].' ('.$was.')';
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($aktive as $revision => $bezeichnung) {
|
||||
$roh = $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]);
|
||||
if (!is_string($roh)) {
|
||||
continue;
|
||||
}
|
||||
$dokument = json_decode($roh, true, 512, JSON_THROW_ON_ERROR);
|
||||
$zahl = 0;
|
||||
$beispiele = [];
|
||||
$dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele);
|
||||
if (0 === $zahl) {
|
||||
continue;
|
||||
}
|
||||
$gesamt += $zahl;
|
||||
$zeilen[] = [$bezeichnung, $zahl, implode(' · ', $beispiele)];
|
||||
if ($schreiben) {
|
||||
// Geprüft wie beim Speichern: eine Korrektur darf kein ungültiges Dokument hinterlassen.
|
||||
$this->validator->validate($dokument, writing: true);
|
||||
$this->db->executeStatement('UPDATE revision SET document = ? WHERE id = ?', [$this->json($dokument), $revision]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, kind, name, document FROM partial') as $bereich) {
|
||||
$dokument = json_decode((string) $bereich['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$zahl = 0;
|
||||
$beispiele = [];
|
||||
$dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele);
|
||||
if (0 === $zahl) {
|
||||
continue;
|
||||
}
|
||||
$gesamt += $zahl;
|
||||
$zeilen[] = ['Bereich '.$bereich['kind'].' „'.$bereich['name'].'“', $zahl, implode(' · ', $beispiele)];
|
||||
if ($schreiben) {
|
||||
// Ein Bereich traegt keine Ueberschrift - genau wie im PartialRepository.
|
||||
$this->validator->validate($dokument, requireHeading: false, writing: true);
|
||||
$this->db->executeStatement('UPDATE partial SET document = ? WHERE id = ?', [$this->json($dokument), $bereich['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === $gesamt) {
|
||||
$io->success('Nichts zu reparieren: kein Text trägt eine doppelt kodierte Entity.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->table(['Wo', 'Stellen', 'Beispiel'], $zeilen);
|
||||
if ($schreiben) {
|
||||
$io->success(sprintf('%d Stelle(n) repariert.', $gesamt));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->warning(sprintf('%d Stelle(n) gefunden. Mit --schreiben anwenden.', $gesamt));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $liste */
|
||||
private function bausteine(array $liste, int &$zahl, array &$beispiele): array
|
||||
{
|
||||
foreach ($liste as $index => $block) {
|
||||
foreach (['text', 'caption', 'label', 'title', 'hint', 'provider'] as $feld) {
|
||||
$wert = $block['props'][$feld] ?? null;
|
||||
if (!is_string($wert) || '' === $wert) {
|
||||
continue;
|
||||
}
|
||||
$neu = $this->entschachteln($wert);
|
||||
if ($neu !== $wert) {
|
||||
$liste[$index]['props'][$feld] = $neu;
|
||||
++$zahl;
|
||||
if (count($beispiele) < 3) {
|
||||
$beispiele[] = mb_substr($wert, 0, 40).' → '.mb_substr($neu, 0, 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$liste[$index]['children'] = $this->bausteine($block['children'], $zahl, $beispiele);
|
||||
}
|
||||
}
|
||||
|
||||
return $liste;
|
||||
}
|
||||
|
||||
/** Entschachtelt so oft wie nötig: aus &amp;amp; wird &. Ein einzelnes & ist richtig und bleibt. */
|
||||
private function entschachteln(string $text): string
|
||||
{
|
||||
$vorher = null;
|
||||
while ($vorher !== $text) {
|
||||
$vorher = $text;
|
||||
$text = preg_replace('/&(amp|lt|gt|quot|apos|nbsp|#\d{1,6}|#x[0-9a-fA-F]{1,6});/', '&$1;', $text) ?? $text;
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $dokument */
|
||||
private function json(array $dokument): string
|
||||
{
|
||||
return json_encode($dokument, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,23 @@ namespace App\Content;
|
||||
use App\Domain\InvalidDocument;
|
||||
|
||||
/**
|
||||
* Own CSS and own script per element (document schema v13).
|
||||
* Own CSS and own script per element (document schema v13), and the raw HTML of the „Eigenes
|
||||
* HTML“ block (schema v24).
|
||||
*
|
||||
* The two are not comparable in what they can do, so they are not treated alike.
|
||||
* The three are not comparable in what they can do, so they are not treated alike.
|
||||
*
|
||||
* **CSS** is parsed and written out again by this class, scoped to the block. A declaration list
|
||||
* and nested rules starting with `&` are accepted; anything else is refused. Because the output is
|
||||
* re-serialised, a stray `}` cannot escape the block, and `@import` or a `javascript:` URL never
|
||||
* gets through. Everyone with the editor role may use it.
|
||||
*
|
||||
* **HTML** is markup that goes onto the page unchanged. It cannot be made harmless either - it
|
||||
* can load images from anywhere and point a form at a foreign address - so it follows the same
|
||||
* rule as a script: administrators only, checked against what is already stored. Two tags are
|
||||
* refused rather than silently swallowed by the content security policy: `<script>`, which
|
||||
* belongs in the script field, and `<iframe>`, which belongs in the „Externer Inhalt“ block,
|
||||
* because only that one asks for consent and puts the host into `frame-src`.
|
||||
*
|
||||
* **Script** is arbitrary JavaScript on the published site. There is no way to make that harmless:
|
||||
* whoever may set it can run code in the browser of every visitor and every administrator. It is
|
||||
* therefore reserved for administrators, checked on the way in by comparing against what is
|
||||
@@ -24,6 +32,7 @@ final class CustomCode
|
||||
{
|
||||
public const MAX_CSS = 4000;
|
||||
public const MAX_SCRIPT = 8000;
|
||||
public const MAX_HTML = 20000;
|
||||
|
||||
public static function supports(mixed $schemaVersion): bool
|
||||
{
|
||||
@@ -62,6 +71,66 @@ final class CustomCode
|
||||
return $script;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the markup of an „Eigenes HTML“ block and returns it unchanged.
|
||||
*
|
||||
* @throws InvalidDocument
|
||||
*/
|
||||
public function checkHtml(string $html): string
|
||||
{
|
||||
if (mb_strlen($html) > self::MAX_HTML) {
|
||||
throw new InvalidDocument(sprintf('Eigenes HTML ist auf %d Zeichen begrenzt.', self::MAX_HTML));
|
||||
}
|
||||
// Beide Tags wuerden von der Sicherheitsrichtlinie der Seite ohnehin nicht ausgefuehrt
|
||||
// bzw. nicht geladen. Eine Meldung mit dem richtigen Weg ist besser als ein Baustein,
|
||||
// der etwas annimmt und nachher nichts tut.
|
||||
if (1 === preg_match('/<\s*script/i', $html)) {
|
||||
throw new InvalidDocument('Eigenes HTML führt keine Skripte aus. Dafür gibt es unter „Erweitert“ das Feld „Eigenes Skript“.');
|
||||
}
|
||||
if (1 === preg_match('/<\s*iframe/i', $html)) {
|
||||
throw new InvalidDocument('Für einen eingebetteten Rahmen gibt es den Baustein „Externer Inhalt“: nur er fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein.');
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw HTML of every „Eigenes HTML“ block, keyed by block ID.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function htmlOf(array $document): array
|
||||
{
|
||||
$found = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$found): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('html' === ($block['type'] ?? '') && is_string($block['props']['html'] ?? null) && is_string($block['id'] ?? null)) {
|
||||
$found[$block['id']] = $block['props']['html'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a write that would add, change or remove raw HTML without the administrator role.
|
||||
*
|
||||
* @throws InvalidDocument
|
||||
*/
|
||||
public function assertHtmlUnchanged(array $incoming, array $stored): void
|
||||
{
|
||||
$before = $this->htmlOf($stored);
|
||||
$after = $this->htmlOf($incoming);
|
||||
ksort($before);
|
||||
ksort($after);
|
||||
if ($before !== $after) {
|
||||
throw new InvalidDocument('Eigenes HTML darf nur von einem Administrator geändert werden. Die übrigen Änderungen wurden nicht gespeichert.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns own CSS into rules scoped to one block.
|
||||
*
|
||||
@@ -124,17 +193,34 @@ final class CustomCode
|
||||
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]);
|
||||
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
|
||||
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
|
||||
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
|
||||
// wo `& .post-card{border:0}` und `& .post-card{transform:none}` nebeneinander
|
||||
// standen und der Rahmen des Kerns wieder auftauchte.
|
||||
$rules[$suffix] = $this->verbinden($rules[$suffix] ?? '', $this->declarations($match[2][0]));
|
||||
$rest = substr_replace($rest, '', $match[0][1], strlen($match[0][0]));
|
||||
}
|
||||
if (str_contains($rest, '{') || str_contains($rest, '}')) {
|
||||
throw new InvalidDocument('Eigenes CSS besteht aus Eigenschaften für dieses Element und aus verschachtelten Regeln, die mit & beginnen. Andere Selektoren sind nicht vorgesehen.');
|
||||
}
|
||||
$rules[''] = $this->declarations($rest);
|
||||
// Die Eigenschaften des Elements selbst stehen ueblicherweise vor den verschachtelten
|
||||
// Regeln; eine Regel `&{…}` schreibt sie aber in denselben Schluessel.
|
||||
$rules[''] = $this->verbinden($this->declarations($rest), $rules['']);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** Zwei Deklarationslisten desselben Selektors, in der Reihenfolge, in der sie standen. */
|
||||
private function verbinden(string $erste, string $zweite): string
|
||||
{
|
||||
if ('' === $erste || '' === $zweite) {
|
||||
return '' === $erste ? $zweite : $erste;
|
||||
}
|
||||
|
||||
return $erste.';'.$zweite;
|
||||
}
|
||||
|
||||
/** @throws InvalidDocument */
|
||||
private function declarations(string $block): string
|
||||
{
|
||||
|
||||
+67
-19
@@ -122,34 +122,75 @@ final class DemoContent
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
/**
|
||||
* The start page of a fresh installation - not demo content.
|
||||
*
|
||||
* Short on purpose. Somebody who has just installed should find a page they can edit, not a
|
||||
* brochure about the CMS they have to delete first. Exactly one H1, as every page requires.
|
||||
* A website that is already reachable but not yet written needs a page that says exactly that:
|
||||
* the name of the site, the notice that something is being built here, and nothing else. No
|
||||
* instructions for the owner - those stand on the last screen of the installer and in the
|
||||
* admin area, not on a page every visitor sees. Exactly one H1, as every page requires.
|
||||
*/
|
||||
public static function starterPage(string $siteName): array
|
||||
{
|
||||
return [
|
||||
'schemaVersion' => 16,
|
||||
'schemaVersion' => 24,
|
||||
'title' => $siteName,
|
||||
'description' => 'NOCH AUSZUFÜLLEN: ein Satz darüber, was es hier gibt. Er erscheint in Suchergebnissen.',
|
||||
'description' => 'Hier entsteht eine neue Webpräsenz. Diese Seite wird gerade eingerichtet.',
|
||||
'blocks' => [
|
||||
self::section('start-hero', [
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
self::text('start-intro', 'Diese Seite ist der Anfang. Melde dich an, öffne sie im Editor und ersetze diesen Text durch deinen eigenen.'),
|
||||
], 'mint'),
|
||||
self::section('start-body', [
|
||||
self::heading('start-next-heading', 'Die nächsten Schritte', 2),
|
||||
self::text('start-next', "Impressum und Datenschutzerklärung sind bereits angelegt und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.\n\nUnter „Design“ legst du Farben, Schriften und die Breite fest, unter „Menüs“ die Navigation, unter „Einstellungen“ den Namen und die Angaben zum Unternehmen."),
|
||||
], 'paper', 'narrow'),
|
||||
[
|
||||
'id' => 'start-hero',
|
||||
'type' => 'section',
|
||||
'layout' => ['tone' => 'mint', 'width' => 'narrow'],
|
||||
'style' => [
|
||||
// Eine Seite ohne Inhalt: der Abschnitt füllt den Bildschirm und stellt das
|
||||
// Wenige, das dasteht, in die Mitte, statt es oben in eine Ecke zu setzen.
|
||||
'desktop' => [
|
||||
'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center',
|
||||
'minHeight' => '70vh', 'textAlign' => 'center',
|
||||
'padding' => ['top' => '96px', 'bottom' => '96px'],
|
||||
'gradientFrom' => '@paper', 'gradientTo' => '@mint', 'gradientAngle' => 165,
|
||||
],
|
||||
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
],
|
||||
'children' => [
|
||||
[
|
||||
'id' => 'start-logo',
|
||||
'type' => 'image',
|
||||
'props' => ['src' => '/assets/kantsites-logo.svg', 'alt' => 'KantSites', 'decorative' => false, 'width' => 128, 'height' => 128],
|
||||
'style' => ['desktop' => ['maxWidth' => '86px', 'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '30px']]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-label',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'In Arbeit'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '2px',
|
||||
'textTransform' => 'uppercase', 'color' => '@accent', 'margin' => ['bottom' => '16px'],
|
||||
]],
|
||||
],
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
[
|
||||
'id' => 'start-intro',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'Hier entsteht eine neue Webpräsenz.'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '21px', 'maxWidth' => '38ch',
|
||||
'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '0px'],
|
||||
]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-rule',
|
||||
'type' => 'divider',
|
||||
'style' => ['desktop' => ['width' => '72px', 'margin' => ['top' => '40px', 'right' => 'auto', 'bottom' => '22px', 'left' => 'auto']]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-note',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'Erstellt mit KantSites'],
|
||||
'style' => ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.4px', 'margin' => ['bottom' => '0px']]],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -164,6 +205,13 @@ final class DemoContent
|
||||
return $areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
public static function areas(): array
|
||||
{
|
||||
$row = static fn (string $id, array $children): array => [
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* PDFs neben den Bildern der Mediathek.
|
||||
*
|
||||
* Ein Bild wird beim Hochladen neu berechnet und nur in dieser Fassung ausgeliefert - was auch
|
||||
* immer in der Datei steckte, ist danach weg. Bei einem PDF geht das nicht: es wird so
|
||||
* ausgeliefert, wie es kam. Deshalb die Pruefung am Anfang der Datei statt an der Endung, und
|
||||
* beim Ausliefern Kopfzeilen, die den Browser nichts anderes darin sehen lassen als ein PDF.
|
||||
*
|
||||
* Die Dateien liegen im Medienordner, als <id>.pdf - so gehen sie mit jeder Sicherung mit.
|
||||
*/
|
||||
final class DocumentLibrary
|
||||
{
|
||||
/** Ein Prospekt oder eine Preisliste, kein Katalog mit tausend Fotos. */
|
||||
public const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/** Die Adresse eines Dokuments. So steht sie auch in einem Link-Feld. */
|
||||
public const PATH = '#^/media/([a-f0-9]{32})\.pdf$#D';
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> neueste zuerst */
|
||||
public function list(bool $withArchived = true): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM document'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC',
|
||||
));
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
return null;
|
||||
}
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM document WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file): array
|
||||
{
|
||||
if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) {
|
||||
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 20 MB pro PDF; das PHP-Hosting kann ein kleineres Limit setzen.');
|
||||
}
|
||||
// Ein PDF beginnt mit %PDF- - in den ersten 1024 Bytes, so lesen es auch die Betrachter.
|
||||
// Die Endung oder der Typ, den der Browser mitschickt, sagen darueber nichts.
|
||||
$head = (string) file_get_contents($file->getPathname(), false, null, 0, 1024);
|
||||
if (!str_contains($head, '%PDF-')) {
|
||||
throw new \InvalidArgumentException('Das ist kein PDF. Bitte eine PDF-Datei wählen.');
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$path = $this->path($id);
|
||||
if (!@copy($file->getPathname(), $path) || !is_file($path)) {
|
||||
throw new \RuntimeException('Das Dokument konnte nicht gespeichert werden.');
|
||||
}
|
||||
try {
|
||||
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Dokument', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('document', $row);
|
||||
} catch (\Throwable $error) {
|
||||
unlink($path);
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $this->present($row);
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE document SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
|
||||
return $this->find($id) ?? throw new StaleWrite();
|
||||
}
|
||||
|
||||
/** Die Datei eines Dokuments, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */
|
||||
public function file(string $id): ?string
|
||||
{
|
||||
if (null === $this->find($id)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->path($id);
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
private function path(string $id): string
|
||||
{
|
||||
return $this->directory.'/'.$id.'.pdf';
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => $row['id'],
|
||||
'title' => $row['title'],
|
||||
'bytes' => (int) $row['bytes'],
|
||||
'archived' => (bool) $row['archived'],
|
||||
'version' => (int) $row['version'],
|
||||
'created_at' => $row['created_at'],
|
||||
'src' => '/media/'.$row['id'].'.pdf',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,20 @@
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
|
||||
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
|
||||
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
|
||||
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
||||
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home', 'arrow-right', 'arrow-left', 'arrow-up-right'];
|
||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
||||
public const V7_TYPES = ['tabs', 'tabitem'];
|
||||
@@ -35,16 +38,25 @@ 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'];
|
||||
public const V24_TYPES = ['html'];
|
||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||
public const SCHEMA_VERSION = 24;
|
||||
|
||||
/**
|
||||
* @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.');
|
||||
@@ -87,7 +99,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -112,6 +124,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 +267,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.');
|
||||
}
|
||||
@@ -263,6 +292,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.
|
||||
@@ -283,7 +316,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.');
|
||||
@@ -338,7 +371,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.');
|
||||
@@ -355,10 +390,19 @@ final class DocumentValidator
|
||||
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
|
||||
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
|
||||
}
|
||||
if ('html' === $type) {
|
||||
// Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein
|
||||
// unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern
|
||||
// stillschweigend zu verschwinden. Wer es setzen darf, ist eine Frage der Rolle und
|
||||
// wird im Controller beantwortet - wie beim eigenen Skript.
|
||||
$this->require(is_string($props['html'] ?? null), 'Eigenes HTML ist Text.');
|
||||
$this->code->checkHtml($props['html']);
|
||||
}
|
||||
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.');
|
||||
@@ -432,7 +476,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.');
|
||||
}
|
||||
}
|
||||
|
||||
+232
-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,18 @@ 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',
|
||||
];
|
||||
public const MAX_FIELDS = 25;
|
||||
/** 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',
|
||||
@@ -56,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'],
|
||||
];
|
||||
}
|
||||
@@ -114,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'] ?? '')));
|
||||
@@ -130,34 +149,149 @@ 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'] ?? ''));
|
||||
@@ -184,6 +318,9 @@ final class FormRepository
|
||||
'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.
|
||||
@@ -198,6 +335,21 @@ final class FormRepository
|
||||
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.
|
||||
*
|
||||
@@ -217,7 +369,7 @@ final class FormRepository
|
||||
}
|
||||
$values[] = $variable.':'.$value;
|
||||
if ('buttonColor' === $field) {
|
||||
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,#000)';
|
||||
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,var(--ink))';
|
||||
$button = true;
|
||||
}
|
||||
if ('buttonInk' === $field) {
|
||||
@@ -243,6 +395,10 @@ final class FormRepository
|
||||
/**
|
||||
* 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
|
||||
@@ -250,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
|
||||
{
|
||||
|
||||
@@ -215,18 +215,27 @@ final class MediaLibrary
|
||||
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
|
||||
}
|
||||
|
||||
/** Die Größen, die ein Favicon braucht: der Reiter im Browser und das Symbol auf dem Startbildschirm. */
|
||||
public const FAVICON_SIZES = [32, 180];
|
||||
/**
|
||||
* Die Größen, die ein Favicon braucht: 32 für den Reiter im Browser, 180 für den
|
||||
* Startbildschirm, 48 und 192 für die Google-Suche - Google nimmt nur Vielfache von 48 und
|
||||
* behielt sonst das zuerst gefundene Symbol, bei Elektro Bernhard das mitgelieferte K.
|
||||
*/
|
||||
public const FAVICON_SIZES = [32, 48, 180, 192];
|
||||
|
||||
/**
|
||||
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
|
||||
*
|
||||
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
|
||||
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein mittiger quadratischer
|
||||
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein quadratischer
|
||||
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
|
||||
* Einstellung, nicht bei jedem Aufruf.
|
||||
*
|
||||
* $crop ist der im Zuschnitt gewählte Ausschnitt als [links, oben, Kante]: links und oben als
|
||||
* Anteil von Breite und Höhe, die Kante als Anteil der kürzeren Seite. Ohne Angabe die Mitte.
|
||||
*
|
||||
* @param array{0: float, 1: float, 2: float}|null $crop
|
||||
*/
|
||||
public function renderFavicon(string $mediaId): void
|
||||
public function renderFavicon(string $mediaId, ?array $crop = null): void
|
||||
{
|
||||
// Ueber die Varianten, die dieses Bild tatsaechlich hat, nicht ueber die vier Regelbreiten:
|
||||
// ein 192 px breites Zeichen hat genau eine Variante mit 192 - und die Suche nach 480
|
||||
@@ -244,10 +253,18 @@ final class MediaLibrary
|
||||
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
|
||||
}
|
||||
try {
|
||||
// Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke.
|
||||
$kante = min(imagesx($source), imagesy($source));
|
||||
$links = (int) ((imagesx($source) - $kante) / 2);
|
||||
$oben = (int) ((imagesy($source) - $kante) / 2);
|
||||
// Quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke. Die Anteile
|
||||
// gelten für jede Variante gleich; gerechnet wird in der größten, die es gibt.
|
||||
$breite = imagesx($source);
|
||||
$hoehe = imagesy($source);
|
||||
$kante = min($breite, $hoehe);
|
||||
$links = (int) (($breite - $kante) / 2);
|
||||
$oben = (int) (($hoehe - $kante) / 2);
|
||||
if (null !== $crop) {
|
||||
$kante = max(1, min($kante, (int) round($crop[2] * min($breite, $hoehe))));
|
||||
$links = max(0, min($breite - $kante, (int) round($crop[0] * $breite)));
|
||||
$oben = max(0, min($hoehe - $kante, (int) round($crop[1] * $hoehe)));
|
||||
}
|
||||
foreach (self::FAVICON_SIZES as $size) {
|
||||
$target = imagecreatetruecolor($size, $size);
|
||||
try {
|
||||
|
||||
@@ -32,7 +32,7 @@ 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;
|
||||
@@ -334,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));
|
||||
@@ -459,8 +459,9 @@ final class PageRepository
|
||||
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
|
||||
$pages = max(1, (int) ceil($total / $limit));
|
||||
$page = max(1, min($pages, $page));
|
||||
$items = $qb->select('p.*', 'r.document')->orderBy('post' === $type ? 'p.created_at' : 'p.path', 'post' === $type ? 'DESC' : 'ASC')
|
||||
->addOrderBy('p.id')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
|
||||
$items = $qb->select('p.*', 'r.document')->orderBy('p.created_at', 'DESC')
|
||||
// Neueste zuerst, bei Seiten wie bei Beiträgen (Umut: „absteigend also neueste zuerst“).
|
||||
->addOrderBy('p.id', 'DESC')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
|
||||
|
||||
return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit];
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -50,7 +50,19 @@ final class RichText
|
||||
}
|
||||
if (!str_contains($html, '<')) {
|
||||
// Plain text: escape it and keep line breaks, so typing without formatting still works.
|
||||
return nl2br(htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), false);
|
||||
//
|
||||
// Erst dekodieren: Was aus dem Editor kommt, ist HTML - auch ohne ein einziges Tag.
|
||||
// „Freudenstadt & Umgebung“ meint ein kaufmaennisches Und. Ohne diesen Schritt
|
||||
// wuerde daraus bei jedem Speichern eine Entity mehr (&amp;, &amp;amp; ...),
|
||||
// bis sie sichtbar auf der Seite steht. Der Zweig mit Tags macht es ueber den DOM
|
||||
// laengst richtig; dieser hier zog nach.
|
||||
$text = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Der Umbruch wird ersetzt, nicht ergaenzt: nl2br() liess den Zeilenumbruch
|
||||
// hinter dem <br> stehen, und beim naechsten Speichern machte breakLines()
|
||||
// daraus ein zweites <br> - aus einem Absatz wurden zwei. So enden beide Zweige
|
||||
// bei derselben Darstellung.
|
||||
return str_replace(["\r\n", "\r", "\n"], '<br>', htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||
}
|
||||
|
||||
$document = new \DOMDocument();
|
||||
|
||||
@@ -50,6 +50,10 @@ final class SiteSettings
|
||||
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
|
||||
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
|
||||
'faviconMediaId' => '',
|
||||
// Der Zuschnitt dazu als "links,oben,kante" in Anteilen, leer heißt: die Mitte. Die Version
|
||||
// hängt am Verweis, damit ein neuer Ausschnitt im Browser ankommt.
|
||||
'faviconCrop' => '',
|
||||
'faviconVersion' => '',
|
||||
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
|
||||
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
|
||||
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
|
||||
@@ -229,6 +233,14 @@ final class SiteSettings
|
||||
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||||
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
|
||||
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
|
||||
$crop = '' === $favicon ? '' : trim((string) ($input['faviconCrop'] ?? ''));
|
||||
$anteile = null;
|
||||
if ('' !== $crop) {
|
||||
$anteile = array_map('floatval', explode(',', $crop));
|
||||
if (1 !== preg_match('/^[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?$/D', $crop) || max($anteile) > 1 || $anteile[2] < 0.02) {
|
||||
throw new InvalidPage('Der Ausschnitt für das Symbol ist ungültig. Bitte das Bild neu zuschneiden.');
|
||||
}
|
||||
}
|
||||
if ('' !== $favicon) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
|
||||
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
|
||||
@@ -237,12 +249,15 @@ final class SiteSettings
|
||||
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
|
||||
// wäre schlimmer als das alte Symbol.
|
||||
try {
|
||||
$this->media->renderFavicon($favicon);
|
||||
$this->media->renderFavicon($favicon, $anteile);
|
||||
} catch (\RuntimeException $error) {
|
||||
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
|
||||
}
|
||||
}
|
||||
$values['faviconMediaId'] = $favicon;
|
||||
$values['faviconCrop'] = $crop;
|
||||
// Ohne Zuschnitt die Kennung wie bisher, damit bestehende Verweise gleich bleiben.
|
||||
$values['faviconVersion'] = '' === $favicon ? '' : ('' === $crop ? substr($favicon, 0, 8) : substr(md5($favicon.'|'.$crop), 0, 8));
|
||||
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
|
||||
$wert = trim((string) ($input[$key] ?? ''));
|
||||
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
|
||||
|
||||
+32
-1
@@ -68,6 +68,10 @@ final class Theme
|
||||
'linkColor' => '@link',
|
||||
'containerWidth' => '1240px',
|
||||
'headingScale' => 1.25,
|
||||
// Die Ecken jedes Bildes auf der Website. Stand bis 1.20.2 fest im Stylesheet -
|
||||
// sichtbar gerundet, aber in keinem Feld zu finden. 12px bleibt die Vorgabe,
|
||||
// damit bestehende Websites gleich aussehen; 0px macht Bilder eckig.
|
||||
'imageRadius' => '12px',
|
||||
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
|
||||
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
|
||||
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
|
||||
@@ -229,7 +233,7 @@ final class Theme
|
||||
}
|
||||
$base[$field] = $value;
|
||||
}
|
||||
foreach (['fontSize', 'containerWidth'] as $field) {
|
||||
foreach (['fontSize', 'containerWidth', 'imageRadius'] as $field) {
|
||||
$value = trim((string) ($input[$field] ?? $base[$field]));
|
||||
if (1 !== preg_match(StyleSchema::LENGTH, $value)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist keine Größenangabe wie 18px oder 70rem.', $value));
|
||||
@@ -311,6 +315,21 @@ final class Theme
|
||||
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 = [];
|
||||
@@ -323,6 +342,7 @@ final class Theme
|
||||
}
|
||||
$base = $theme['base'];
|
||||
$variables[] = sprintf('--k-container:%s', $base['containerWidth']);
|
||||
$variables[] = sprintf('--k-image-radius:%s', $base['imageRadius']);
|
||||
|
||||
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
|
||||
|
||||
@@ -344,6 +364,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 = '';
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -18,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;
|
||||
@@ -29,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 FontLibrary $fonts, 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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -290,6 +292,7 @@ final class EditorController extends AbstractController
|
||||
// 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->code->assertHtmlUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||||
}
|
||||
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
@@ -419,7 +422,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 = [];
|
||||
@@ -436,12 +439,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), 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($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();
|
||||
@@ -460,9 +489,12 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($body['document'], $page['document']);
|
||||
$this->code->assertHtmlUnchanged($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;
|
||||
|
||||
@@ -476,6 +508,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.');
|
||||
@@ -483,6 +542,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()];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -14,7 +15,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class FormAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private FormRepository $forms, private Theme $theme)
|
||||
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -30,6 +31,8 @@ final class FormAdminController extends AbstractController
|
||||
'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.
|
||||
@@ -56,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 {
|
||||
@@ -70,6 +81,7 @@ final class FormAdminController extends AbstractController
|
||||
'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,6 +88,14 @@ 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
|
||||
@@ -122,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.
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -26,7 +29,7 @@ final class MediaController extends AbstractController
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query];
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||
@@ -52,16 +55,18 @@ final class MediaController extends AbstractController
|
||||
$done = 0;
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$this->media->upload($file);
|
||||
// Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der
|
||||
// Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt.
|
||||
$this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file);
|
||||
++$done;
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Bild(er) davor wurden hochgeladen.', $done) : ''));
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : ''));
|
||||
} catch (\RuntimeException) {
|
||||
return $this->back($request, 'Das Bild konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.');
|
||||
return $this->back($request, sprintf('%s konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.', $file->getClientOriginalName()));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
|
||||
return $this->back($request, sprintf('%d Datei(en) hochgeladen.', $done), 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
@@ -90,6 +95,38 @@ final class MediaController extends AbstractController
|
||||
return $this->back($request, 'Die Bildangaben wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/dokument/{id}', name: 'admin_document_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdateDocument(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$this->documents->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
try {
|
||||
$this->documents->update($id, [
|
||||
'title' => $request->request->getString('title'),
|
||||
'archived' => $request->request->getBoolean('archived'),
|
||||
'version' => $request->request->getInt('version'),
|
||||
]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->back($request, 'Das Dokument wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
private function isPdf(UploadedFile $file): bool
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
|
||||
return 'application/pdf' === $mime || (!str_starts_with((string) $mime, 'image/') && 'pdf' === strtolower($file->getClientOriginalExtension()));
|
||||
}
|
||||
|
||||
/** Back to the page the form was on, so a change does not jump to the first page. */
|
||||
private function back(Request $request, string $message, string $type = 'error'): Response
|
||||
{
|
||||
@@ -111,6 +148,53 @@ final class MediaController extends AbstractController
|
||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
/** Die PDFs fuer die Auswahl im Link-Feld. Wenige genug, um sie auf einmal zu schicken. */
|
||||
#[Route('/%kant.admin_path%/api/dokumente', methods: ['GET'])]
|
||||
public function listDocuments(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
return $this->json(['items' => $this->documents->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/dokumente/upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/dokumente/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function writeDocument(Request $request, ?string $id = null): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
|
||||
}
|
||||
try {
|
||||
if (null === $id) {
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->documents->upload($file)], 201);
|
||||
}
|
||||
if (!$this->documents->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
if (strlen($request->getContent()) > 4096) {
|
||||
throw new \InvalidArgumentException('Angaben zum Dokument sind zu groß.');
|
||||
}
|
||||
$data = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data)) {
|
||||
throw new \InvalidArgumentException('Ungültige Angaben zum Dokument.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->documents->update($id, $data)]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->json(['error' => 'Das Dokument wurde inzwischen geändert. Die Auswahl bitte neu öffnen.'], 409);
|
||||
} catch (\InvalidArgumentException|\JsonException $error) {
|
||||
return $this->json(['error' => $error->getMessage()], 422);
|
||||
} catch (\RuntimeException) {
|
||||
return $this->json(['error' => 'Das Dokument konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
@@ -159,21 +243,79 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Symbol der Website, in den beiden Größen, die Browser abfragen.
|
||||
* Das Symbol der Website, in den Größen, die Browser und Suchmaschinen abfragen.
|
||||
*
|
||||
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
|
||||
* favicon.svg, auf das die Seiten weiterhin verweisen.
|
||||
* Zeichen unter /favicon.svg. /favicon.ico fragen Crawler auf gut Glück ab; es bekommt das
|
||||
* 48er-PNG, das liest jeder Browser auch unter diesem Namen.
|
||||
*/
|
||||
#[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])]
|
||||
#[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])]
|
||||
public function favicon(Request $request): Response
|
||||
#[Route('/favicon.png', name: 'favicon_png', defaults: ['size' => 32], methods: ['GET'])]
|
||||
#[Route('/favicon-192.png', name: 'favicon_192', defaults: ['size' => 192], methods: ['GET'])]
|
||||
#[Route('/favicon.ico', name: 'favicon_ico', defaults: ['size' => 48], methods: ['GET'])]
|
||||
#[Route('/apple-touch-icon.png', name: 'favicon_touch', defaults: ['size' => 180], methods: ['GET'])]
|
||||
public function favicon(int $size): Response
|
||||
{
|
||||
$size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32;
|
||||
$path = $this->media->faviconFile($size) ?? throw $this->createNotFoundException();
|
||||
$path = $this->faviconFile($size);
|
||||
if (null === $path) {
|
||||
if (48 === $size) {
|
||||
return $this->redirect('/favicon.svg');
|
||||
}
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das mitgelieferte Zeichen - aber nur, solange kein eigenes Symbol gesetzt ist.
|
||||
*
|
||||
* Früher lag es als Datei in public/ und kam damit immer, auch nach dem Wechsel. Google hatte
|
||||
* es beim ersten Besuch gefunden und zeigte es weiter. Jetzt führt die Adresse zum eigenen
|
||||
* Symbol, vorübergehend (302), damit ein Zurückwechseln im Browser wieder ankommt.
|
||||
*/
|
||||
#[Route('/favicon.svg', name: 'favicon_svg', methods: ['GET'])]
|
||||
public function faviconSvg(): Response
|
||||
{
|
||||
if (null !== $this->faviconFile(192)) {
|
||||
$response = $this->redirect('/favicon-192.png?v='.$this->siteSettings->load()['faviconVersion'], 302);
|
||||
$response->headers->set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
return new BinaryFileResponse($this->getParameter('kernel.project_dir').'/public/assets/favicon.svg', headers: ['Content-Type' => 'image/svg+xml', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=3600']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Symboldatei, oder null ohne eigenes Symbol.
|
||||
*
|
||||
* Die Größen 48 und 192 kamen später dazu. Wer sein Symbol vorher gesetzt hat, hat nur 32
|
||||
* und 180 - die fehlenden entstehen beim ersten Abruf aus derselben Einstellung, statt dass
|
||||
* erst jemand die Einstellungen neu speichern muss.
|
||||
*/
|
||||
private function faviconFile(int $size): ?string
|
||||
{
|
||||
// Zuerst die Einstellung: wird das Symbol entfernt, bleiben die Dateien liegen, gelten
|
||||
// aber nicht mehr.
|
||||
$settings = $this->siteSettings->load();
|
||||
$favicon = (string) ($settings['faviconMediaId'] ?? '');
|
||||
if ('' === $favicon) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->media->faviconFile($size);
|
||||
if (null !== $path || !in_array($size, MediaLibrary::FAVICON_SIZES, true) || null === $this->media->find($favicon)) {
|
||||
return $path;
|
||||
}
|
||||
$crop = (string) ($settings['faviconCrop'] ?? '');
|
||||
try {
|
||||
$this->media->renderFavicon($favicon, '' === $crop ? null : array_map('floatval', explode(',', $crop)));
|
||||
} catch (\RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->media->faviconFile($size);
|
||||
}
|
||||
|
||||
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
|
||||
public function serve(string $id, int $width): Response
|
||||
{
|
||||
@@ -181,4 +323,29 @@ final class MediaController extends AbstractController
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF, so wie es hochgeladen wurde.
|
||||
*
|
||||
* inline, damit der Browser es anzeigt statt es abzulegen - mit dem Titel als Dateinamen fuer
|
||||
* den Fall, dass jemand es speichert. Einbetten darf es nur die eigene Website: der
|
||||
* Betrachter zeigt es in einem Rahmen, eine fremde Seite soll das nicht koennen.
|
||||
*/
|
||||
#[Route('/media/{id}.pdf', name: 'document_serve', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function serveDocument(string $id): Response
|
||||
{
|
||||
$path = $this->documents->file($id) ?? throw $this->createNotFoundException();
|
||||
$name = trim(preg_replace('/[\\\\\/\x00-\x1F\x7F"]+/u', ' ', $this->documents->find($id)['title'])).'.pdf';
|
||||
$fallback = trim(preg_replace('/[^A-Za-z0-9 ._-]+/', '-', (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name)), '- ');
|
||||
$response = new BinaryFileResponse($path, headers: [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Content-Security-Policy' => "frame-ancestors 'self'",
|
||||
'X-Robots-Tag' => 'noindex',
|
||||
]);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $name, str_ends_with($fallback, '.pdf') && '.pdf' !== $fallback ? $fallback : 'dokument.pdf');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -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,10 +27,13 @@ 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)
|
||||
{
|
||||
}
|
||||
|
||||
/** Dieselbe Groessenangabe, die `blocks/block.html.twig` einem Bild ohne feste Breite gibt. */
|
||||
private const IMAGE_SIZES = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px';
|
||||
|
||||
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
|
||||
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
|
||||
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
|
||||
@@ -47,6 +52,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 +71,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);
|
||||
@@ -75,13 +106,34 @@ final class PageController extends AbstractController
|
||||
$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));
|
||||
// Ein Link auf ein PDF der Mediathek - in einem Button, einem Bild oder im Fliesstext.
|
||||
// Gesucht wird im gespeicherten Dokument als Text: so findet sich auch ein Link, den
|
||||
// jemand in einen formatierten Absatz geschrieben hat.
|
||||
$needsDocument = (bool) array_filter($documents, static fn (array $doc): bool => 1 === preg_match('#/media/[a-f0-9]{32}\.pdf#', json_encode($doc, JSON_UNESCAPED_SLASHES) ?: ''));
|
||||
// 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.
|
||||
@@ -91,7 +143,10 @@ final class PageController extends AbstractController
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
// Die Farben jedes Formulars auf der Seite - nach dem Theme, damit sie es uebersteuern.
|
||||
$forms = $this->formStates($page, $request->query->getString('gesendet'), $request);
|
||||
$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']])) {
|
||||
@@ -104,11 +159,31 @@ final class PageController extends AbstractController
|
||||
// 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,
|
||||
@@ -116,6 +191,9 @@ final class PageController extends AbstractController
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'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,
|
||||
@@ -124,9 +202,13 @@ final class PageController extends AbstractController
|
||||
// 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' => $needsZoom,
|
||||
// Der PDF-Betrachter, nach demselben Muster und im selben Skript: ohne es ein
|
||||
// gewoehnlicher Link.
|
||||
'needsDocument' => $needsDocument,
|
||||
// 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',
|
||||
'areaScripts' => $areaScripts,
|
||||
'forms' => $forms,
|
||||
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
@@ -137,10 +219,14 @@ final class PageController extends AbstractController
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
'eagerImage' => $eagerImage = $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'heroBackground' => $heroBackground = $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
// Ist es doch ein gewoehnliches Bild, wird auch das vorab geholt - sonst findet der
|
||||
// Browser es erst, wenn das Stylesheet steht, und diese Rundreise geht direkt in die
|
||||
// LCP-Zeit. Nur eines von beiden: zwei grosse Bilder vorab kosten mehr, als sie bringen.
|
||||
'heroImage' => [] === $heroBackground ? $this->heroImage($page['document'], $eagerImage) : null,
|
||||
'pageStyles' => $styles,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
@@ -148,8 +234,8 @@ final class PageController extends AbstractController
|
||||
|
||||
// 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 || $needsTabs || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $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 || $needsDocument || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
@@ -159,7 +245,7 @@ final class PageController extends AbstractController
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, $frames);
|
||||
return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,13 +255,13 @@ 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.
|
||||
* @param bool $frameSelf whether the page may frame its own PDFs (the viewer)
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
@@ -202,20 +288,18 @@ final class PageController extends AbstractController
|
||||
implode(' ', $scriptSources),
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
|
||||
// Der PDF-Betrachter zeigt eine Datei dieser Website in einem Rahmen - nur dort, wo
|
||||
// eine Seite auf ein PDF verlinkt, und nur von hier.
|
||||
[] === $frameHosts && !$frameSelf ? "'none'" : implode(' ', [...($frameSelf ? ["'self'"] : []), ...array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)]),
|
||||
));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -223,7 +307,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 = [];
|
||||
@@ -232,8 +316,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,
|
||||
@@ -246,16 +333,46 @@ 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.
|
||||
@@ -290,6 +407,15 @@ final class PageController extends AbstractController
|
||||
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) {
|
||||
@@ -508,27 +634,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']);
|
||||
// Palette und Hinweisfarben wie auf jeder Seite - das Archiv trug bisher weder das eine
|
||||
// noch das andere und zeigte den Einwilligungshinweis in den Standardfarben.
|
||||
$theme = $this->theme->load();
|
||||
$styles = $this->theme->css($theme).$this->consent->styles($theme);
|
||||
// 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', [
|
||||
'pageStyles' => $styles,
|
||||
'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, $styles, $frames);
|
||||
return $this->renderPage($request, $seite, $seite['path']);
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
@@ -553,7 +688,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 {
|
||||
@@ -576,7 +711,9 @@ final class PageController extends AbstractController
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
foreach ($documents as $document) {
|
||||
$walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
@@ -652,6 +789,55 @@ final class PageController extends AbstractController
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Das vorgezogene Bild als Vorabladung im Kopf der Seite.
|
||||
*
|
||||
* Die Angaben sind dieselben, die `blocks/block.html.twig` an das `img` schreibt - Quelle,
|
||||
* Satz der Varianten und die Groessenangabe. Weicht eines davon ab, waehlt der Browser fuer
|
||||
* die Vorabladung eine andere Variante als fuer das Bild und laedt zweimal.
|
||||
*
|
||||
* @return array{src: string, srcset: string, sizes: string}|null
|
||||
*/
|
||||
private function heroImage(array $document, ?string $eagerImage): ?array
|
||||
{
|
||||
if (null === $eagerImage) {
|
||||
return null;
|
||||
}
|
||||
$block = $this->blockById($document['blocks'] ?? [], $eagerImage);
|
||||
$props = $block['props'] ?? [];
|
||||
if (null === $block || 'image' !== ($block['type'] ?? null) || '' === (string) ($props['src'] ?? '')) {
|
||||
return null;
|
||||
}
|
||||
$varianten = [];
|
||||
foreach (is_array($props['sources'] ?? null) ? $props['sources'] : [] as $quelle) {
|
||||
if (is_array($quelle) && isset($quelle['src'], $quelle['width'])) {
|
||||
$varianten[] = $quelle['src'].' '.$quelle['width'].'w';
|
||||
}
|
||||
}
|
||||
$feste = (string) ($block['style']['desktop']['maxWidth'] ?? '');
|
||||
|
||||
return [
|
||||
'src' => (string) $props['src'],
|
||||
'srcset' => implode(', ', $varianten),
|
||||
'sizes' => str_ends_with($feste, 'px') ? $feste : self::IMAGE_SIZES,
|
||||
];
|
||||
}
|
||||
|
||||
private function blockById(array $blocks, string $id): ?array
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
if ($id === ($block['id'] ?? null)) {
|
||||
return $block;
|
||||
}
|
||||
$inside = $this->blockById($block['children'] ?? [], $id);
|
||||
if (null !== $inside) {
|
||||
return $inside;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function firstImage(array ...$documents): ?string
|
||||
{
|
||||
foreach ($documents as $document) {
|
||||
@@ -714,9 +900,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
|
||||
@@ -731,12 +921,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
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -12,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;
|
||||
@@ -38,6 +40,8 @@ final class PartialController extends AbstractController
|
||||
private CustomCode $code,
|
||||
private FontLibrary $fonts,
|
||||
private CsrfTokenManagerInterface $csrf,
|
||||
private PluginRegistry $plugins,
|
||||
private FormRepository $forms,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -132,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);
|
||||
}
|
||||
@@ -141,9 +145,11 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($document, $partial['document']);
|
||||
$this->code->assertHtmlUnchanged($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));
|
||||
@@ -172,6 +178,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' => '',
|
||||
@@ -182,8 +211,8 @@ final class PartialController extends AbstractController
|
||||
'postLists' => [],
|
||||
'postMeta' => null,
|
||||
'needsTabs' => false,
|
||||
'forms' => [],
|
||||
'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document),
|
||||
'forms' => $forms,
|
||||
'pageStyles' => $styles,
|
||||
'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : [],
|
||||
]);
|
||||
}
|
||||
@@ -201,6 +230,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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
@@ -20,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme, private NotFoundPage $notFoundPage)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -31,8 +30,22 @@ final class SettingsController extends AbstractController
|
||||
// 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');
|
||||
$settings = $this->settings->load();
|
||||
$choosable = [];
|
||||
// Datenschutz und Impressum werden per Suche gewählt; das Feld zeigt Titel und Adresse der
|
||||
// gespeicherten Seite. Eine Adresse ohne Seite bleibt sichtbar, statt still zu verschwinden.
|
||||
$legal = ['privacyPath' => null, 'imprintPath' => null];
|
||||
foreach (array_keys($legal) as $key) {
|
||||
if ('' !== $settings[$key]) {
|
||||
$legal[$key] = ['path' => $settings[$key], 'title' => $settings[$key]];
|
||||
}
|
||||
}
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
foreach ($legal as $key => $selection) {
|
||||
if (null !== $selection && $selection['path'] === $page['path']) {
|
||||
$legal[$key]['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['path'];
|
||||
}
|
||||
}
|
||||
// Only a published page can serve as the root; a draft would answer every visit with 404.
|
||||
if ('page' === $page['type'] && null !== $page['published_revision_id']) {
|
||||
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -41,15 +54,14 @@ final class SettingsController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $this->settings->load(),
|
||||
'settings' => $settings,
|
||||
'legalSelection' => $legal,
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
'choosableHome' => $choosable,
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
'missingLegal' => $this->settings->missingLegalPages(),
|
||||
'consentGroupHelp' => Consent::GROUP_HELP,
|
||||
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
|
||||
'images' => $this->media->page(1, false, 96)['items'],
|
||||
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||||
'palette' => $this->theme->load()['colors'],
|
||||
'socialNetworks' => SocialProfiles::NETWORKS,
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Command\SiteOriginCommand;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -21,7 +24,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, private string $siteOrigin)
|
||||
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, private OriginCheck $originCheck)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -55,6 +58,7 @@ final class SystemController extends AbstractController
|
||||
// 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),
|
||||
'originRejected' => $request->getSession()->remove('origin_rejected'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -258,6 +262,62 @@ final class SystemController extends AbstractController
|
||||
: 'Der Wartungszustand ist aufgehoben.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Adresse der Website, wie „WordPress-Adresse“ in WordPress: sie landet in .env als
|
||||
* SITE_ORIGIN und gilt ab der nächsten Anfrage.
|
||||
*
|
||||
* Vorher fragt die Installation sich selbst unter der neuen Adresse an. Eine Umstellung auf
|
||||
* www ohne DNS-Eintrag leitete sonst jede Seite ins Leere, die Verwaltung eingeschlossen.
|
||||
* Wer weiß, was er tut (Server erreicht sich selbst nicht, http im lokalen Betrieb), kann die
|
||||
* Prüfung ausdrücklich übergehen.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/adresse', name: 'admin_system_origin', methods: ['POST'])]
|
||||
public function origin(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ('.env' !== $this->environmentSource('SITE_ORIGIN')) {
|
||||
return $this->back('Die Adresse ist in der Server-Umgebung gesetzt und hat dort Vorrang. Sie lässt sich nur beim Hoster ändern.');
|
||||
}
|
||||
try {
|
||||
$neu = SiteOriginCommand::normalize($request->request->getString('origin'));
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($error->getMessage());
|
||||
}
|
||||
if ($neu === rtrim(trim($this->siteOrigin), '/')) {
|
||||
return $this->back('Die Adresse ist unverändert.', 'success');
|
||||
}
|
||||
if (!$request->request->getBoolean('force') && null !== $problem = $this->originCheck->problem($neu)) {
|
||||
$request->getSession()->set('origin_rejected', $neu);
|
||||
|
||||
return $this->back($problem.' Nichts geändert.');
|
||||
}
|
||||
try {
|
||||
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
|
||||
} catch (\RuntimeException $error) {
|
||||
return $this->back($error->getMessage());
|
||||
}
|
||||
|
||||
// Die Anmeldung gilt nur für die Schreibweise, unter der sie entstand. Wechselt die
|
||||
// Verwaltung dadurch den Host, ist dort eine neue Anmeldung fällig - das sagt die Meldung,
|
||||
// solange sie noch auf dem alten Host steht.
|
||||
return $this->back(sprintf(
|
||||
'Die Website ist jetzt eingetragen als %s. Canonical, Sitemap und Feed nennen diese Adresse, die Schreibweise %s www leitet dorthin um.%s',
|
||||
$neu,
|
||||
str_starts_with((string) parse_url($neu, PHP_URL_HOST), 'www.') ? 'ohne' : 'mit',
|
||||
strtolower($request->getHost()) !== strtolower((string) parse_url($neu, PHP_URL_HOST)) ? ' Unter der neuen Adresse ist eine neue Anmeldung nötig.' : '',
|
||||
), 'success');
|
||||
}
|
||||
|
||||
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
|
||||
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||||
public function originProbe(): Response
|
||||
{
|
||||
return new Response($this->originCheck->token(), 200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Robots-Tag' => 'noindex']);
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon)#', $path)) {
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) {
|
||||
return;
|
||||
}
|
||||
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,16 @@ final class ArchiveUpdate
|
||||
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
|
||||
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
|
||||
|
||||
/**
|
||||
* Was frühere Fassungen in public/ mitbrachten und jetzt die Anwendung beantwortet.
|
||||
*
|
||||
* Das Zusammenführen kopiert nur hinzu. Bliebe favicon.svg liegen, lieferte der Webserver
|
||||
* sie weiter selbst aus - das mitgelieferte Zeichen, auch wenn ein eigenes Symbol gesetzt ist.
|
||||
* Gelöscht wird in finish(): Das Kopieren erledigt noch der Code der alten Fassung, der
|
||||
* diese Liste nicht kennt.
|
||||
*/
|
||||
private const ENTFALLEN_IN_PUBLIC = ['favicon.svg'];
|
||||
|
||||
/**
|
||||
* Die Marke zwischen den beiden Schritten.
|
||||
*
|
||||
@@ -127,6 +137,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 +172,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 +231,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 +316,12 @@ final class ArchiveUpdate
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) {
|
||||
@unlink($this->root.'/public/'.$eintrag);
|
||||
}
|
||||
$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 +361,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:')) {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Prüft, ob eine neue Adresse der Website wirklich bei dieser Installation ankommt.
|
||||
*
|
||||
* Die Adresse entscheidet, wohin die Schreibweise mit oder ohne www umgeleitet wird - für die
|
||||
* Website und für die Verwaltung. Stellt jemand auf www um, und www hat keinen DNS-Eintrag oder
|
||||
* kein Zertifikat, leitet danach jede Seite ins Leere, und in die Verwaltung kommt man nur noch
|
||||
* über FTP. Deshalb wird vor dem Speichern angefragt: Unter der neuen Adresse muss eine Kennung
|
||||
* zurückkommen, die nur diese Installation kennt - abgeleitet aus APP_SECRET, ohne es zu verraten.
|
||||
*/
|
||||
class OriginCheck
|
||||
{
|
||||
public const PATH = '/.well-known/kantsites-origin';
|
||||
|
||||
private Download $download;
|
||||
|
||||
public function __construct(private string $secret, ?Download $download = null)
|
||||
{
|
||||
// Kurz: die Anfrage geht an den eigenen Server und hält die Verwaltung so lange auf.
|
||||
$this->download = $download ?? new Download(5, 10);
|
||||
}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
return hash_hmac('sha256', 'site-origin', $this->secret);
|
||||
}
|
||||
|
||||
/** Null, wenn die Adresse hier ankommt - sonst der Grund in einem Satz. */
|
||||
public function problem(string $origin): ?string
|
||||
{
|
||||
if (!str_starts_with($origin, 'https://')) {
|
||||
return 'Geprüft werden nur HTTPS-Adressen. Eine Adresse mit http:// lässt sich nur mit „Trotzdem übernehmen“ speichern.';
|
||||
}
|
||||
$antwort = $this->download->get($origin.self::PATH);
|
||||
if (null === $antwort) {
|
||||
$grund = $this->download->reason();
|
||||
|
||||
return 'Unter '.$origin.' antwortet diese Website nicht'.('' !== $grund ? ': '.rtrim($grund, '.').'.' : '.').' DNS-Eintrag und Zertifikat für diese Domain prüfen.';
|
||||
}
|
||||
if (!hash_equals($this->token(), trim($antwort))) {
|
||||
return 'Unter '.$origin.' antwortet eine andere Website. Zeigt die Domain auf diesen Server?';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+70
-1
@@ -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 = 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];
|
||||
@@ -350,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);
|
||||
@@ -398,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Was eine Plugin-Vorlage vom Kern bekommt: die Definition ihres Bausteins, die geprüften
|
||||
* Eigenschaften und Bilder aus der Mediathek. Mehr nicht - keine Datenbank, keine Einstellungen.
|
||||
*/
|
||||
final class PluginExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private PluginRegistry $plugins, private MediaLibrary $media, FilesystemLoader $loader)
|
||||
{
|
||||
// Der Namensraum @plugins zeigt auf den Pluginordner - aber nur, wenn es ihn gibt. Ein
|
||||
// fester Pfad in twig.yaml liesse eine Installation ohne plugins/ mit einem Ladefehler
|
||||
// stehen, bevor sie eine einzige Seite ausgeliefert hat.
|
||||
$directory = $plugins->directory();
|
||||
if (is_dir($directory) && !in_array($directory, $loader->getPaths('plugins'), true)) {
|
||||
$loader->addPath($directory, 'plugins');
|
||||
}
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('kant_plugin_block', $this->plugins->block(...)),
|
||||
new TwigFunction('kant_plugin_props', $this->props(...)),
|
||||
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
|
||||
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Die geprüften Eigenschaften eines Bausteins - oder null, wenn sie nicht ins Schema passen. */
|
||||
public function props(array $block): ?array
|
||||
{
|
||||
try {
|
||||
return $this->plugins->validateProps((string) ($block['type'] ?? ''), is_array($block['props'] ?? null) ? $block['props'] : []);
|
||||
} catch (InvalidDocument) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bild der Mediathek für eine Vorlage: Kennung, Titel, Alternativtext und die Fassungen
|
||||
* (src, width, height), die kleinste zuerst.
|
||||
*/
|
||||
public function mediaItem(string $id): ?array
|
||||
{
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
return null;
|
||||
}
|
||||
$item = $this->media->find($id);
|
||||
if (null === $item || ($item['archived'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['id' => $item['id'], 'title' => (string) ($item['title'] ?? ''), 'alt' => (string) ($item['alt'] ?? ''), 'variants' => $item['variants']];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{% set selected = selected|default(null) %}
|
||||
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}">
|
||||
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}>
|
||||
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
|
||||
<div class="picker-input">
|
||||
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
|
||||
<button type="button" class="subtle picker-clear" aria-label="{{ label|default('Seite') }}: Auswahl entfernen">✕</button>
|
||||
</div>
|
||||
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? selected.id : '' }}">
|
||||
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? attribute(selected, valueKey|default('id')) : '' }}">
|
||||
<div id="{{ id }}-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
|
||||
<p class="picker-status field-help" role="status"></p>
|
||||
</div>
|
||||
@@ -3,9 +3,9 @@
|
||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
|
||||
<div class="rows">
|
||||
{% for page in pages %}
|
||||
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
||||
an attribute here would simply be dropped and the tree would look flat. #}
|
||||
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
||||
{# Newest first, not as a tree: indenting subpages here would suggest a parent that is not
|
||||
the row above. The address in the row shows where a page sits. #}
|
||||
<article class="row row-stack">
|
||||
<div class="row-line">
|
||||
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
||||
<div class="row-main">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %}
|
||||
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
|
||||
{% block body_class %}editor-body{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block body %}
|
||||
{# The API base decides what is edited. An area is shared, so it has no draft and no
|
||||
|
||||
@@ -40,19 +40,50 @@
|
||||
</div>
|
||||
<div data-field-options>
|
||||
<label for="{{ id }}-options">Optionen der Auswahl</label>
|
||||
<input id="{{ id }}-options" name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" placeholder="Beratung, Angebot, Sonstiges" data-field-options-input>
|
||||
<input id="{{ id }}-options" name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|default([])|join(', ') }}" placeholder="Beratung, Angebot, Sonstiges" data-field-options-input>
|
||||
<p class="field-help">Durch Komma trennen, mindestens zwei.</p>
|
||||
<div class="field-card-images" data-field-images>
|
||||
<label for="{{ id }}-images">Bild je Option</label>
|
||||
<textarea id="{{ id }}-images" name="fields[{{ n }}][images]" rows="2" data-field-images-input>{% for option, kennung in row.images|default({}) %}{{ option }} = {{ kennung }}
|
||||
{% endfor %}</textarea>
|
||||
<p class="field-help">Eine Zeile je Option: <code>Option = Kennung aus der Mediathek</code>. Sobald eine Option ein Bild hat, wird die Auswahl auf der Website zur Bildauswahl – anklickbare Karten statt Kästchen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card-flags">
|
||||
<label class="check-label" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflichtfeld</label>
|
||||
</div>
|
||||
<div class="field-card-number" data-field-number>
|
||||
<div>
|
||||
<label for="{{ id }}-min">Untergrenze</label>
|
||||
<input id="{{ id }}-min" name="fields[{{ n }}][min]" inputmode="decimal" value="{{ row.min|default('') }}" placeholder="0">
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ id }}-max">Obergrenze</label>
|
||||
<input id="{{ id }}-max" name="fields[{{ n }}][max]" inputmode="decimal" value="{{ row.max|default('') }}" placeholder="1000">
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ id }}-unit">Einheit</label>
|
||||
<input id="{{ id }}-unit" name="fields[{{ n }}][unit]" maxlength="12" value="{{ row.unit|default('') }}" placeholder="m²" data-field-unit>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card-condition" data-field-condition>
|
||||
<div>
|
||||
<label for="{{ id }}-showif">Nur zeigen, wenn das Feld</label>
|
||||
<input id="{{ id }}-showif" name="fields[{{ n }}][showIfField]" maxlength="32" value="{{ row.showIf.field|default('') }}" pattern="[a-z][a-z0-9_]*" placeholder="immobilienart" data-field-showif>
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ id }}-showvalues">… einen dieser Werte hat</label>
|
||||
<input id="{{ id }}-showvalues" name="fields[{{ n }}][showIfValues]" maxlength="500" value="{{ row.showIf.values|default([])|join(', ') }}" placeholder="Wohnung, Haus" data-field-showvalues>
|
||||
</div>
|
||||
<p class="field-help">Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Bei einem Schritt gilt die Bedingung für alle seine Felder. Leer: das Feld gilt immer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as karte %}
|
||||
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}">
|
||||
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}" data-form-images="{{ images|json_encode|e('html_attr') }}">
|
||||
{% for form in forms|merge([null]) %}
|
||||
{% set new = form is null %}
|
||||
{% set f = loop.index %}
|
||||
@@ -114,6 +145,10 @@
|
||||
<label for="success-{{ f }}">Meldung nach dem Absenden</label>
|
||||
<textarea id="success-{{ f }}" name="success" maxlength="300" rows="2" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ settings.success|default('') }}</textarea>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<label class="check-label" for="summary-{{ f }}"><input id="summary-{{ f }}" type="checkbox" name="summary" value="1"{{ settings.summary|default(false) ? ' checked' : '' }}> Zusammenfassung vor dem Absenden zeigen</label>
|
||||
<p class="field-help">Wirkt bei Formularen mit Schritten: der letzte Schritt zeigt alle Angaben mit „Ändern“. Ohne Skript im Browser steht das Formular als Ganzes auf der Seite.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-fields-head">
|
||||
@@ -125,7 +160,7 @@
|
||||
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}
|
||||
</ol>
|
||||
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}</template>
|
||||
<p class="field-help" data-fields-help>Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert.</p>
|
||||
<p class="field-help" data-fields-help>Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Ein Feld vom Typ „Neuer Schritt“ eröffnet einen Abschnitt: seine Beschriftung ist der Titel, sein Hilfetext die Einleitung; alle Felder bis zum nächsten Schritt gehören dazu. Ein Feld oder ein Schritt kann von einer früheren Auswahl abhängen – dann verlangt der Server es nur, wenn die Bedingung zutrifft.</p>
|
||||
|
||||
<h3 class="form-part">Aussehen</h3>
|
||||
<div class="fields form-look">
|
||||
@@ -150,14 +185,22 @@
|
||||
<h3 class="form-part">Vorschau</h3>
|
||||
<div class="form-preview" data-form-preview>
|
||||
{% for row in rows %}
|
||||
<div class="form-preview-field">
|
||||
{% if row.type == 'step' %}
|
||||
<div class="form-preview-step"><span class="form-preview-step-title">{{ row.label }}</span>{% if row.help %}<span class="form-preview-help">{{ row.help }}</span>{% endif %}</div>
|
||||
{% else %}
|
||||
<div class="form-preview-field{{ row.showIf|default(null) ? ' form-preview-conditional' : '' }}">
|
||||
{% if row.type == 'consent' %}
|
||||
<span class="form-preview-check"><span class="form-preview-box" aria-hidden="true"></span>{{ row.label }} *</span>
|
||||
{% elseif row.type == 'radio' or row.type == 'checkboxes' %}
|
||||
<span class="form-preview-label">{{ row.label }}{% if row.required %} *{% endif %}</span>
|
||||
{% for option in row.options %}<span class="form-preview-check"><span class="form-preview-box{{ row.type == 'radio' ? ' form-preview-radio' : '' }}" aria-hidden="true"></span>{{ option }}{% if row.images[option]|default('') %} <span class="muted">(mit Bild)</span>{% endif %}</span>{% endfor %}
|
||||
{% else %}
|
||||
<span class="form-preview-label">{{ row.label }}{% if row.required %} *{% endif %}</span>
|
||||
<span class="form-preview-input{{ row.type == 'textarea' ? ' form-preview-area' : '' }}{{ row.type == 'select' ? ' form-preview-select' : '' }}" aria-hidden="true">{{ row.type == 'select' ? '— bitte wählen —' : '' }}</span>
|
||||
<span class="form-preview-input{{ row.type == 'textarea' ? ' form-preview-area' : '' }}{{ row.type == 'select' ? ' form-preview-select' : '' }}{{ row.type == 'number' ? ' form-preview-number' : '' }}" aria-hidden="true">{{ row.type == 'select' ? '— bitte wählen —' : (row.type == 'number' ? row.unit|default('') : '') }}</span>
|
||||
{% endif %}
|
||||
{% if row.showIf|default(null) %}<span class="form-preview-help">Nur wenn {{ row.showIf.field }} = {{ row.showIf.values|join(' oder ') }}</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<span class="cms-button b-desktop-shape-{{ schema.buttons.shape }} b-desktop-appearance-{{ schema.buttons.appearance }} b-desktop-size-{{ schema.buttons.size }}" data-preview-button>{{ settings.buttonLabel|default('Absenden') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
|
||||
tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2',
|
||||
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
|
||||
plugins: 'M9 3v3H6a1 1 0 0 0-1 1v3h3a2 2 0 1 1 0 4H5v4a1 1 0 0 0 1 1h4v-3a2 2 0 1 1 4 0v3h4a1 1 0 0 0 1-1v-4h-3a2 2 0 1 1 0-4h3V7a1 1 0 0 0-1-1h-3V3',
|
||||
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||
logout: 'M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5m5-12 4 4-4 4m4-4H9',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||
{% block lead %}Alle Bilder und PDFs, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus. PDFs bleiben, wie sie sind, und lassen sich in jedem Linkziel wählen.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
@@ -19,13 +19,13 @@
|
||||
<div class="dropzone" data-dropzone>
|
||||
<label class="dropzone-label" for="files">
|
||||
<span class="dropzone-icon" aria-hidden="true">▩</span>
|
||||
<strong>Bilder hierher ziehen</strong>
|
||||
<strong>Bilder oder PDFs hierher ziehen</strong>
|
||||
<span class="dropzone-hint">oder klicken, um Dateien zu wählen</span>
|
||||
</label>
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp" multiple class="dropzone-input">
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp,application/pdf,.pdf" multiple class="dropzone-input">
|
||||
</div>
|
||||
<p class="dropzone-chosen" data-chosen role="status">Noch keine Datei gewählt.</p>
|
||||
<p class="field-help">JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.</p>
|
||||
<p class="field-help">JPEG, PNG oder WebP bis 8 MB je Bild, PDF bis 20 MB. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen. Hochgeladene Dateien sind über ihre Adresse öffentlich – nichts Vertrauliches hochladen.</p>
|
||||
<button class="primary" type="submit">Hochladen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -38,4 +38,51 @@
|
||||
</form>
|
||||
{% include 'admin/search-feedback.html.twig' %}
|
||||
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
|
||||
|
||||
{# Die PDFs stehen unter den Bildern und ohne Suche: es sind wenige, und ein Titel reicht, um
|
||||
eines wiederzufinden. Der Schalter für das Archiv oben gilt auch hier. #}
|
||||
<section class="stack" aria-labelledby="documents-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="documents-heading">Dokumente <span class="count">{{ documents|length }}</span></h2>
|
||||
</div>
|
||||
{% if documents is empty %}
|
||||
<div class="empty">
|
||||
<h3>Keine PDFs in der Mediathek.</h3>
|
||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben ein PDF hoch. Danach lässt es sich in jedem Linkziel wählen – etwa für einen Button „Preisliste“.' : 'Ein Redakteur kann PDFs hochladen.' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rows">
|
||||
{% for item in documents %}
|
||||
<article class="row row-stack">
|
||||
<div class="row-line">
|
||||
<span class="row-icon row-icon-text" aria-hidden="true">PDF</span>
|
||||
<div class="row-main">
|
||||
<h3><a href="{{ item.src }}" target="_blank" rel="noopener">{{ item.title }}</a></h3>
|
||||
<p class="muted">{{ (item.bytes / 1048576)|number_format(1, ',') }} MB · <code>{{ item.src }}</code>{% if item.archived %} · archiviert{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_document_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="document-title-{{ item.id }}">Titel</label>
|
||||
<input id="document-title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<p class="field-help">Wird zum Dateinamen, wenn jemand das PDF speichert, und steht über dem Betrachter.</p>
|
||||
<label class="check-label" for="document-archived-{{ item.id }}"><input id="document-archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte PDFs erscheinen nicht mehr in der Auswahl, bleiben aber erreichbar – ein Link darauf geht nicht ins Leere.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'plugins' %}
|
||||
{% block title %}Plugins{% endblock %}
|
||||
{% block heading %}Plugins{% endblock %}
|
||||
{% block lead %}Erweiterungen bringen eigene Bausteine, Vorlagen und Adressen mit. Sie liegen als Ordner unter <code>plugins/</code>; hier schaltest du sie ein und aus und stellst sie ein. Ein ausgeschaltetes Plugin lässt seine Bausteine in den Seiten stehen, zeigt sie aber nicht mehr.{% endblock %}
|
||||
|
||||
{# Ein Feld aus dem Schema eines Plugins - dieselben sieben Arten wie im Editor. #}
|
||||
{% macro feld(plugin, key, schema, value) %}
|
||||
{% set id = 'plugin-' ~ plugin ~ '-' ~ key %}
|
||||
{% set name = 'settings[' ~ key ~ ']' %}
|
||||
{% if schema.kind == 'boolean' %}
|
||||
<div><label class="check-label" for="{{ id }}"><input id="{{ id }}" type="checkbox" name="{{ name }}" value="1"{{ value ? ' checked' : '' }}> {{ schema.label }}</label>{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<label for="{{ id }}">{{ schema.label }}</label>
|
||||
{% if schema.kind == 'textarea' %}<textarea id="{{ id }}" name="{{ name }}" rows="3" maxlength="{{ schema.maxLength }}">{{ value }}</textarea>
|
||||
{% elseif schema.kind == 'select' %}<select id="{{ id }}" name="{{ name }}">{% for wert, text in schema.choices %}<option value="{{ wert }}"{{ value == wert ? ' selected' : '' }}>{{ text }}</option>{% endfor %}</select>
|
||||
{% elseif schema.kind == 'number' %}<input id="{{ id }}" name="{{ name }}" type="number" step="any" min="{{ schema.min }}" max="{{ schema.max }}" value="{{ value }}">
|
||||
{% elseif schema.kind == 'media' %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" placeholder="Kennung aus der Mediathek" pattern="[a-f0-9]{32}">
|
||||
{% else %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" maxlength="{{ schema.maxLength }}">{% endif %}
|
||||
{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as f %}
|
||||
{% if plugins is empty %}
|
||||
<div class="empty"><h3>Keine Plugins gefunden.</h3><p>Ein Plugin ist ein Ordner unter <code>{{ directory }}</code> mit einer <code>plugin.json</code>. Wie eine aussieht, steht in <code>docs/PLUGINS.md</code>.</p></div>
|
||||
{% else %}
|
||||
<div class="rows">
|
||||
{% for plugin in plugins %}
|
||||
<article class="row row-top">
|
||||
<div class="row-main">
|
||||
<h2>{{ plugin.title }} <span class="count">{{ plugin.version }}</span></h2>
|
||||
{% if plugin.description %}<p>{{ plugin.description }}</p>{% endif %}
|
||||
{% if plugin.error %}
|
||||
<p class="field-error">Nicht ladbar: {{ plugin.error }}</p>
|
||||
{% else %}
|
||||
<p class="muted">Ordner <code>plugins/{{ plugin.name }}</code>{% if plugin.requires %} · braucht KantSites {{ plugin.requires }}{% endif %}{% if plugin.active and plugin.activatedVersion and plugin.activatedVersion != plugin.version %} · seit dem Einschalten aktualisiert (war {{ plugin.activatedVersion }}){% endif %}</p>
|
||||
<dl class="submission-data">
|
||||
{% for block in plugin.blocks %}
|
||||
<dt>{{ block.icon }} {{ block.label }}</dt>
|
||||
<dd><code>{{ block.type }}</code>{% if block.description %} – {{ block.description }}{% endif %} · {{ block.props|length }} {{ block.props|length == 1 ? 'Eigenschaft' : 'Eigenschaften' }}{% if block.container %} · Container{% if block.children %} für {{ block.children|join(', ') }}{% endif %}{% endif %}{% if block.css %} · Stylesheet{% endif %}{% if block.script %} · Skript{% endif %}</dd>
|
||||
{% endfor %}
|
||||
{% for preset in plugin.presets %}
|
||||
<dt>⬡ Vorlage „{{ preset.label }}“</dt>
|
||||
<dd>{{ preset.description ?: 'Seitenvorlage für den Editor' }} · {{ preset.blocks|length }} {{ preset.blocks|length == 1 ? 'Baustein' : 'Bausteine' }}</dd>
|
||||
{% endfor %}
|
||||
{% for route in plugin.routes %}
|
||||
<dt>↗ Adresse</dt>
|
||||
<dd>{% if plugin.active %}<a href="{{ route.path }}">{{ route.path }}</a>{% else %}<code>{{ route.path }}</code>{% endif %} · {{ route.type }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% if plugin.active and plugin.settings is not empty %}
|
||||
<form method="post" action="{{ path('admin_plugin_settings', {name: plugin.name}) }}" class="plugin-settings">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
|
||||
<h3 class="form-part">Einstellungen</h3>
|
||||
<div class="fields">
|
||||
{% for key, schema in plugin.settings %}{{ f.feld(plugin.name, key, schema, plugin.values[key]|default('')) }}{% endfor %}
|
||||
</div>
|
||||
<button class="secondary" type="submit">Einstellungen speichern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row-side">
|
||||
<span class="pill {{ plugin.active ? 'pill-live' : 'pill-off' }}">{{ plugin.active ? 'Eingeschaltet' : 'Aus' }}</span>
|
||||
{% if not plugin.error %}
|
||||
<form method="post" action="{{ path('admin_plugin_toggle', {name: plugin.name, state: plugin.active ? 'aus' : 'an'}) }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('plugins') }}">
|
||||
<button class="{{ plugin.active ? 'secondary' : 'primary' }}" type="submit">{{ plugin.active ? 'Ausschalten' : 'Einschalten' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="field-help">Plugin-Vorlagen laufen mit den Rechten der Website – es gibt keinen Sandkasten. Installiere nur, was du gelesen hast. Farben, Schriften und Buttons änderst du ausschließlich unter Design – ein Plugin fasst sie nicht an.</p>
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,8 @@
|
||||
{% block title %}Einstellungen{% endblock %}
|
||||
{% block heading %}Unternehmen und Adressen{% endblock %}
|
||||
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
@@ -129,15 +130,19 @@
|
||||
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
|
||||
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
|
||||
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
|
||||
<label for="faviconMediaId">Symbol der Website</label>
|
||||
<div class="favicon-field">
|
||||
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32">{% endif %}
|
||||
<select id="faviconMediaId" name="faviconMediaId">
|
||||
<option value="">Mitgeliefertes Zeichen</option>
|
||||
{% for image in images %}<option value="{{ image.id }}"{{ settings.faviconMediaId == image.id ? ' selected' : '' }}>{{ image.title }}</option>{% endfor %}
|
||||
</select>
|
||||
<div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div>
|
||||
<div class="favicon-field" data-favicon-field aria-labelledby="favicon-title" role="group">
|
||||
<input type="hidden" name="faviconMediaId" value="{{ settings.faviconMediaId }}">
|
||||
<input type="hidden" name="faviconCrop" value="{{ settings.faviconCrop }}">
|
||||
<div class="favicon-current">
|
||||
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconVersion ?: settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32"> <span>Eigenes Symbol</span>{% else %}<img class="favicon-preview" src="/favicon.svg" alt="" width="32" height="32"> <span>Mitgeliefertes Zeichen</span>{% endif %}
|
||||
</div>
|
||||
<div class="favicon-actions">
|
||||
<button type="button" class="secondary" data-favicon-choose>{{ settings.faviconMediaId ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
|
||||
<button type="button" class="text-button danger-text" data-favicon-remove{{ settings.faviconMediaId ? '' : ' hidden' }}>Mitgeliefertes Zeichen verwenden</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.</p>
|
||||
<p class="field-help">Das Bild kommt aus der Mediathek, dort kann es auch gleich hochgeladen werden. Danach das Quadrat auf den gewünschten Ausschnitt ziehen und die Größe einstellen. Beim Speichern entstehen daraus PNG-Dateien in 32 und 180 Pixeln.</p>
|
||||
<div class="section-head"><h2>Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
|
||||
<label class="check-label" for="scrollTop"><input id="scrollTop" name="scrollTop" type="checkbox" value="1"{{ settings.scrollTop ? ' checked' : '' }}> Knopf „nach oben" anzeigen</label>
|
||||
<p class="field-help">Springt an den Seitenanfang. Er braucht kein JavaScript: In Browsern mit Scroll-Zeitachse blendet er sich beim Scrollen ein, in den übrigen steht er von Anfang an da.</p>
|
||||
@@ -261,13 +266,11 @@
|
||||
</fieldset>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="privacyPath">Adresse der Datenschutzseite</label>
|
||||
<input id="privacyPath" name="privacyPath" maxlength="400" value="{{ settings.privacyPath }}" placeholder="datenschutz">
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'privacyPath', name: 'privacyPath', type: 'page', valueKey: 'path', label: 'Datenschutzseite', selected: legalSelection.privacyPath} only %}
|
||||
<p class="field-help">Pflicht, sobald der Hinweis an ist, und die Seite muss existieren.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="imprintPath">Adresse des Impressums</label>
|
||||
<input id="imprintPath" name="imprintPath" maxlength="400" value="{{ settings.imprintPath }}" placeholder="impressum">
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'imprintPath', name: 'imprintPath', type: 'page', valueKey: 'path', label: 'Impressum', selected: legalSelection.imprintPath} only %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter <code>/einwilligung</code> kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.</p>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<span class="shell-menu-label">{{ {
|
||||
'pages': 'Seiten', 'posts': 'Beiträge', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
|
||||
'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design',
|
||||
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer',
|
||||
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'plugins': 'Plugins', 'users': 'Benutzer',
|
||||
'account': 'Mein Konto'
|
||||
}[nav|default('')]|default('Verwaltung') }}</span>
|
||||
</summary>
|
||||
@@ -48,6 +48,7 @@
|
||||
{% if is_granted('ROLE_PUBLISHER') %}<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_plugins') }}"{{ nav|default('') == 'plugins' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'plugins'} only %}Plugins</a></li>{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
|
||||
<li><a class="nav-item nav-out" href="/">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Website ansehen</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -40,9 +40,28 @@
|
||||
{% if origin.passt %}
|
||||
<span class="muted">– Canonical, Sitemap und Feed nennen sie; die Schreibweise {{ origin.adresse matches '{^https?://www\.}' ? 'ohne' : 'mit' }} www leitet hierher um.</span>
|
||||
{% else %}
|
||||
<br><span class="check-bad"><strong>Diese Verwaltung läuft gerade unter <code>{{ origin.host }}</code>.</strong> Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr: <code>php bin/console cms:site:origin https://…</code> oder <code>SITE_ORIGIN</code> in <code>.env</code> ändern.</span>
|
||||
<br><span class="check-bad"><strong>Diese Verwaltung läuft gerade unter <code>{{ origin.host }}</code>.</strong> Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr, unten ändern.</span>
|
||||
{% endif %}
|
||||
{% if origin.quelle == '.env' %}
|
||||
{# Wie „WordPress-Adresse“: eine Stelle für mit oder ohne www. Vor dem Speichern fragt
|
||||
die Installation sich selbst unter der neuen Adresse an. #}
|
||||
{% set host = origin.adresse|split('://')|last|split(':')|first %}
|
||||
{% set twin = host starts with 'www.' ? host|slice(4) : 'www.' ~ host %}
|
||||
<form class="origin-form" action="{{ path('admin_system_origin') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label for="origin">Adresse der Website</label>
|
||||
<div class="origin-row">
|
||||
<input id="origin" name="origin" type="url" inputmode="url" maxlength="200" required placeholder="https://www.meine-firma.de" value="{{ originRejected ?: origin.adresse }}" aria-describedby="origin-help">
|
||||
<button class="secondary" type="submit">Adresse speichern</button>
|
||||
</div>
|
||||
{% if originRejected %}
|
||||
<label class="check-label" for="origin-force"><input id="origin-force" type="checkbox" name="force" value="1"> Trotzdem übernehmen – ich habe DNS und Zertifikat geprüft</label>
|
||||
{% endif %}
|
||||
<p class="field-help" id="origin-help">Nur Schema und Domain. Vor dem Speichern wird geprüft, ob die Website unter der neuen Adresse antwortet – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen.{% if origin.adresse and host matches '/^[a-z0-9-]+(\.[a-z0-9-]+)+$/' and not (host matches '/^[0-9.]+$/') %} Umstellen auf <button class="link-button" type="submit" name="origin" value="{{ origin.adresse|split('://')|first }}://{{ twin }}" formnovalidate>{{ twin }}</button>.{% endif %}</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<br><span class="muted">Eingestellt in der Server-Umgebung; sie hat Vorrang vor <code>.env</code> und lässt sich nur beim Hoster ändern.</span>
|
||||
{% endif %}
|
||||
<br><span class="muted">Eingestellt in <code>{{ origin.quelle }}</code>; Server-Umgebungsvariablen haben Vorrang.</span>
|
||||
</dd>
|
||||
{% if state.worktree %}
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
|
||||
@@ -162,6 +162,11 @@
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-image-radius">Bildecken</label>
|
||||
<input id="base-image-radius" name="base[imageRadius]" value="{{ theme.base.imageRadius }}" placeholder="12px">
|
||||
<p class="field-help">Gilt für jedes Bild auf der Website – auch in Galerien, Logoleisten und Beitragslisten. <code>0px</code> macht sie eckig. Ein einzelnes Bild lässt sich im Editor unter <strong>Design → Bild → Bildecken</strong> abweichend einstellen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de"{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ heading }}{% if result.page > 1 %} · Seite {{ result.page }}{% endif %} · Beiträge</title>
|
||||
<meta name="description" content="{{ term ? 'Beiträge zu ' ~ term.name : 'Alle Beiträge' }}.">
|
||||
<link rel="canonical" href="{{ canonical }}">
|
||||
{# Auch ein geteiltes Archiv soll einen Namen haben statt einer nackten Adresse. #}
|
||||
<meta property="og:title" content="{{ heading }}{% if result.page > 1 %} · Seite {{ result.page }}{% endif %}">
|
||||
<meta property="og:description" content="{{ term ? 'Beiträge zu ' ~ term.name : 'Alle Beiträge' }}.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ canonical }}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% if result.page > 1 %}<link rel="prev" href="{{ base }}{{ result.page > 2 ? '?seite=' ~ (result.page - 1) : '' }}">{% endif %}
|
||||
{% if result.page < result.pages %}<link rel="next" href="{{ base }}?seite={{ result.page + 1 }}">{% endif %}
|
||||
<link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
|
||||
<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}">
|
||||
<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">
|
||||
{% if pageStyles|default('') %}<style>{{ pageStyles|raw }}</style>{% endif %}
|
||||
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
</head>
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{% if areas.header|default(null) %}
|
||||
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}{{ kant_header_surface(areas.header) ? ' has-own-surface' : '' }}">
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</header>
|
||||
{% endif %}
|
||||
<main id="main" tabindex="-1">
|
||||
<section class="block-section tone-mint width-normal">
|
||||
<div class="shell section-inner">
|
||||
{% if term %}<p class="post-terms-label">{{ term.kind == 'category' ? 'Kategorie' : 'Schlagwort' }}</p>{% endif %}
|
||||
<h1>{{ heading }}</h1>
|
||||
<p class="block-text">{{ result.total }} {{ result.total == 1 ? 'Beitrag' : 'Beiträge' }}{% if result.pages > 1 %} · Seite {{ result.page }} von {{ result.pages }}{% endif %}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="block-section tone-paper width-normal">
|
||||
<div class="shell section-inner">
|
||||
{% include 'blocks/post-list.html.twig' with {posts: result.posts} only %}
|
||||
{% if result.pages > 1 %}
|
||||
<nav class="pagination" aria-label="Seiten">
|
||||
{% if result.page > 1 %}<a rel="prev" href="{{ base }}{{ result.page > 2 ? '?seite=' ~ (result.page - 1) : '' }}">← Neuere</a>{% endif %}
|
||||
<span>Seite {{ result.page }} von {{ result.pages }}</span>
|
||||
{% if result.page < result.pages %}<a rel="next" href="{{ base }}?seite={{ result.page + 1 }}">Ältere →</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{% if areas.footer|default(null) %}
|
||||
<footer class="site-footer">
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,7 +26,7 @@
|
||||
{% endif %}
|
||||
{% elseif type == 'button' %}
|
||||
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %}
|
||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span class="button-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-up-right'} only %}</span></a>
|
||||
{% elseif type == 'image' %}
|
||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||
@@ -39,7 +39,9 @@
|
||||
für Browser, die „auto“ noch nicht kennen (heute Safari). #}
|
||||
{% set annahme = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px' %}
|
||||
{% set imageSizes = festeBreite ends with 'px' ? festeBreite : (eager or inHeader|default(false) ? annahme : 'auto, ' ~ annahme) %}
|
||||
{% set klick = props.click|default(props.href|default('') ? 'link' : 'keine') %}
|
||||
{# Ohne eigene Wahl folgt ein Bild seiner Galerie: ist sie vergrößerbar, ist es das Bild auch.
|
||||
Eine eigene Wahl - auch „Nichts tun“ - geht vor, ebenso ein Link. #}
|
||||
{% set klick = props.click|default(props.href|default('') ? 'link' : (galleryZoom|default(false) ? 'vergroessern' : 'keine')) %}
|
||||
{% set imageLinked = klick == 'link' and props.href|default('') %}
|
||||
{% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %}
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">
|
||||
@@ -79,11 +81,15 @@
|
||||
<a class="button" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.buttonText }}</a>
|
||||
</div>
|
||||
{% elseif type == 'contact' %}
|
||||
{% if not (props.phone|default('') or props.email|default('') or props.address|default('')) %}
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Kontaktdaten: Telefon, E-Mail oder Adresse unter Inhalt eintragen.</p>{% endif %}
|
||||
{% else %}
|
||||
<address id="{{ block.id }}" class="block-contact{{ designClasses }}">
|
||||
{% if props.phone|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'phone'} only %}<a href="tel:{{ props.phone|replace({' ': '', '(': '', ')': '', '/': '', '-': ''}) }}">{{ props.phone }}</a></span>{% endif %}
|
||||
{% if props.email|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'mail'} only %}<a href="mailto:{{ props.email }}">{{ props.email }}</a></span>{% endif %}
|
||||
{% if props.address|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'pin'} only %}{{ props.address }}</span>{% endif %}
|
||||
</address>
|
||||
{% endif %}
|
||||
{% elseif type == 'divider' %}
|
||||
<hr id="{{ block.id }}" class="block-divider{{ designClasses }}">
|
||||
{% elseif type == 'spacer' %}
|
||||
@@ -114,7 +120,9 @@
|
||||
{# A linked section: its blocks come from the partial, resolved by the controller. One change
|
||||
there reaches every page that links it, which is the difference to a copied section. #}
|
||||
{% set section = sections[props.partial]|default(null) %}
|
||||
{% if section %}
|
||||
{% if props.partial|default('') == '' %}
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Verknüpfte Sektion: unter Inhalt die Adresse einer Sektion eintragen.</p>{% endif %}
|
||||
{% elseif section %}
|
||||
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
|
||||
{% for child in section.blocks %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
@@ -135,9 +143,13 @@
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% elseif type == 'form' %}
|
||||
{% if props.form|default('') == '' %}
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Formular: unter Inhalt die Adresse eines Formulars eintragen (siehe Verwaltung → Formulare).</p>{% endif %}
|
||||
{% else %}
|
||||
<div id="{{ block.id }}" class="block-formwrap{{ designClasses }}">
|
||||
{% include 'blocks/form.html.twig' with (forms[block.id]|default({form: null, sent: false, errors: {}, values: {}, blockId: block.id, page: {path: ''}, issued: 0, issuedSignature: ''})) only %}
|
||||
{% include 'blocks/form.html.twig' with (forms[block.id]|default({form: null, sent: false, errors: {}, values: {}, optionImages: {}, blockId: block.id, page: {path: ''}, issued: 0, issuedSignature: ''})) only %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elseif type == 'tabs' %}
|
||||
{# Without the script this is an anchor list followed by every panel: complete and readable.
|
||||
tabs.js turns it into a tab widget; it only changes presentation, never content. #}
|
||||
@@ -213,6 +225,9 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elseif type == 'embed' %}
|
||||
{% if props.url|default('') == '' %}
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Externer Inhalt: unter Inhalt Adresse, Titel und Anbieter eintragen.</p>{% endif %}
|
||||
{% else %}
|
||||
{# Content from a third party. Until the group is granted, nothing of it is requested - no
|
||||
iframe, no image, no preconnect. That is the whole point of the consent question: the
|
||||
placeholder is not a nicety, it is what keeps the promise.
|
||||
@@ -241,6 +256,15 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% elseif type == 'html' %}
|
||||
{# Markup des Betreibers, unveraendert. Geprueft wurde es beim Speichern (CustomCode::checkHtml),
|
||||
setzen darf es nur ein Administrator - deshalb steht hier `raw` und keine Filterung. #}
|
||||
{% if props.html|default('')|trim == '' %}
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Eigenes HTML: unter Inhalt den Code einfügen.</p>{% endif %}
|
||||
{% else %}
|
||||
<div id="{{ block.id }}" class="block-html{{ designClasses }}">{{ props.html|raw }}</div>
|
||||
{% endif %}
|
||||
{% elseif type == 'postlist' %}
|
||||
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
||||
{% set feed = postLists[block.id]|default([]) %}
|
||||
@@ -262,8 +286,23 @@
|
||||
</nav>
|
||||
{% elseif type == 'gallery' %}
|
||||
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie"{% if props.lightbox|default(false) %} data-zoom-group{% endif %}>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, galleryZoom: props.lightbox|default(false), editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif kant_plugin_block(type) %}
|
||||
{# Ein Baustein eines aktiven Plugins. Die Vorlage bekommt nur, was der Kern geprüft hat:
|
||||
die Eigenschaften nach dem Schema des Manifests. Passen sie nicht, bleibt der Platz leer. #}
|
||||
{% set plugin = kant_plugin_block(type) %}
|
||||
{% set pluginProps = kant_plugin_props(block) %}
|
||||
{% if pluginProps is null %}
|
||||
<!-- Block {{ block.id }} ({{ type }}) hat Eigenschaften, die nicht zum Plugin passen, und wird nicht dargestellt. -->
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Baustein „{{ plugin.label }}“: die gespeicherten Eigenschaften passen nicht zum Plugin.</p>{% endif %}
|
||||
{% else %}
|
||||
{# Ein Container rendert seine Kinder hier, mit demselben Kontext wie jeder Kernbaustein, und
|
||||
reicht sie der Vorlage fertig gerendert als `children` - so kommt eine Plugin-Vorlage nie
|
||||
an Formulare, Beitragslisten oder die Navigation heran. #}
|
||||
{% set childrenHtml %}{% if plugin.container %}{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}{% endif %}{% endset %}
|
||||
{% include plugin.template with {block: block, props: pluginProps, designClasses: designClasses, editing: editing|default(false), plugin: plugin, children: childrenHtml, settings: kant_plugin_settings(plugin.plugin)} only %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{#
|
||||
A block whose plugin is not available. Its stored content stays in the document; the core
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{# Server rendered, no JavaScript. Errors are announced and each message sits at its field. #}
|
||||
{# Server rendered, no JavaScript needed. Errors are announced and each message sits at its field.
|
||||
Schritte sind fieldsets: ohne Skript ein langes Formular mit Abschnitten, mit form-steps.js ein
|
||||
Schritt nach dem anderen. Bedingungen stehen als Datenattribute am Feld; der Server prüft sie
|
||||
beim Absenden selbst und verlangt nichts, was gar nicht galt. #}
|
||||
{% if form is null %}
|
||||
<p class="form-missing">Dieses Formular ist nicht mehr vorhanden.</p>
|
||||
{% elseif sent %}
|
||||
<p class="form-success" role="status">{{ form.settings.success }}</p>
|
||||
{% else %}
|
||||
<form class="block-form" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
||||
{% set hasSteps = form.fields|filter(f => f.type == 'step')|length > 0 %}
|
||||
<form class="block-form{{ hasSteps ? ' form-stepped' : '' }}" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate{% if hasSteps %} data-steps{% endif %}{% if form.settings.summary|default(false) %} data-summary{% endif %}>
|
||||
<input type="hidden" name="page" value="{{ page.path }}">
|
||||
<input type="hidden" name="block" value="{{ blockId }}">
|
||||
<input type="hidden" name="issued" value="{{ issued }}">
|
||||
@@ -21,15 +25,49 @@
|
||||
{% set error = errors[field.name]|default(null) %}
|
||||
{% set value = values[field.name]|default('') %}
|
||||
{% set described = (field.help ? id ~ '-help ' : '') ~ (error ? id ~ '-error' : '') %}
|
||||
<div class="form-field{{ error ? ' has-error' : '' }}">
|
||||
{% if field.type == 'consent' %}
|
||||
{% set condition %}{% if field.showIf|default(null) %} data-show-if="{{ field.showIf.field }}" data-show-values="{{ field.showIf.values|join('|') }}"{% endif %}{% endset %}
|
||||
{% set requiredMark %}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> (Pflichtfeld)</span>{% endif %}{% endset %}
|
||||
{% if field.type == 'step' %}
|
||||
{% if not loop.first and form.fields[0:loop.index0]|filter(f => f.type == 'step')|length > 0 %}</fieldset>{% endif %}
|
||||
<fieldset class="form-step" data-step{{ condition }}>
|
||||
<legend class="form-step-title" tabindex="-1">{{ field.label }}</legend>
|
||||
{% if field.help %}<p class="form-step-intro">{{ field.help }}</p>{% endif %}
|
||||
{% elseif field.type == 'consent' %}
|
||||
<div class="form-field{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}>
|
||||
<div class="check-row">
|
||||
<input id="{{ id }}" name="{{ field.name }}" type="checkbox" value="ja"{{ value ? ' checked' : '' }} required
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
||||
<label for="{{ id }}">{{ field.label }} <span class="required-mark" aria-hidden="true">*</span></label>
|
||||
</div>
|
||||
{% else %}
|
||||
<label for="{{ id }}">{{ field.label }}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> (Pflichtfeld)</span>{% endif %}</label>
|
||||
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
|
||||
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
||||
</div>
|
||||
{% elseif field.type == 'radio' or field.type == 'checkboxes' %}
|
||||
{# Eine Gruppe: die Beschriftung ist die legend, jede Option hat ihr eigenes Feld. Bei
|
||||
der Mehrfachauswahl steht required nicht am Kästchen - das hieße „alle ankreuzen“ -
|
||||
sondern an der Gruppe; Skript und Server prüfen es dort. #}
|
||||
{# Hat eine Option ein Bild, wird die ganze Auswahl zur Bildauswahl: jede Option eine
|
||||
Karte mit Bild und Text, das Kästchen bleibt darin für Tastatur und Vorlesesoftware. #}
|
||||
{% set bildauswahl = field.images|default({})|length > 0 %}
|
||||
<fieldset class="form-field form-choice{{ bildauswahl ? ' form-choice-images' : '' }}{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}{% if field.type == 'checkboxes' and field.required %} data-required{% endif %}
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}>
|
||||
<legend>{{ field.label }}{{ requiredMark }}</legend>
|
||||
{% set picked = field.type == 'checkboxes' ? value|split(', ') : [value] %}
|
||||
{% if bildauswahl %}<div class="form-choice-grid">{% endif %}
|
||||
{% for option in field.options %}
|
||||
{% set bild = optionImages[field.images[option]|default('')]|default(null) %}
|
||||
<div class="{{ bildauswahl ? 'form-choice-card' : 'check-row' }}">
|
||||
<input id="{{ id }}-{{ loop.index }}" name="{{ field.name }}{{ field.type == 'checkboxes' ? '[]' : '' }}" type="{{ field.type == 'checkboxes' ? 'checkbox' : 'radio' }}" value="{{ option }}"{{ option in picked ? ' checked' : '' }}{{ field.type == 'radio' and field.required ? ' required' : '' }}{{ error ? ' aria-invalid="true"' : '' }}>
|
||||
<label for="{{ id }}-{{ loop.index }}">{% if bildauswahl %}<span class="form-choice-picture">{% if bild %}<img src="{{ bild.src }}" alt="" width="{{ bild.width }}" height="{{ bild.height }}" loading="lazy" decoding="async">{% endif %}</span><span class="form-choice-text">{{ option }}</span>{% else %}{{ option }}{% endif %}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if bildauswahl %}</div>{% endif %}
|
||||
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
|
||||
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
||||
</fieldset>
|
||||
{% else %}
|
||||
<div class="form-field{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}>
|
||||
<label for="{{ id }}">{{ field.label }}{{ requiredMark }}{% if field.unit|default('') %}<span class="visually-hidden"> in {{ field.unit }}</span>{% endif %}</label>
|
||||
{% if field.type == 'textarea' %}
|
||||
<textarea id="{{ id }}" name="{{ field.name }}" rows="6" maxlength="5000"{{ field.required ? ' required' : '' }}
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>{{ value }}</textarea>
|
||||
@@ -39,6 +77,14 @@
|
||||
<option value="">— bitte wählen —</option>
|
||||
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ option }}</option>{% endfor %}
|
||||
</select>
|
||||
{% elseif field.type == 'number' %}
|
||||
<div class="form-number{{ field.unit|default('') ? ' has-unit' : '' }}">
|
||||
<input id="{{ id }}" name="{{ field.name }}" value="{{ value }}" type="number" step="any" inputmode="decimal"
|
||||
{% if field.min is not null %}min="{{ field.min }}"{% endif %} {% if field.max is not null %}max="{{ field.max }}"{% endif %}
|
||||
{{ field.required ? ' required' : '' }}
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
||||
{% if field.unit|default('') %}<span class="form-unit" aria-hidden="true">{{ field.unit }}</span>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<input id="{{ id }}" name="{{ field.name }}" value="{{ value }}" maxlength="200"
|
||||
type="{{ {email: 'email', phone: 'tel'}[field.type]|default('text') }}"
|
||||
@@ -46,11 +92,12 @@
|
||||
{{ field.required ? ' required' : '' }}
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
|
||||
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
||||
</div>
|
||||
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
|
||||
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if hasSteps %}</fieldset>{% endif %}
|
||||
{% if turnstileKey|default('') %}
|
||||
{# Cloudflare Turnstile. Braucht JavaScript; ohne Skript bleibt das Formular unabsendbar, und
|
||||
genau deshalb ist die Prüfung eine Entscheidung und keine Voreinstellung. #}
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user