Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad86e4874b | ||
|
|
e15b2517b1 | ||
|
|
c7705b7083 | ||
|
|
28b2442188 | ||
|
|
8e628b9417 | ||
|
|
13f6759df8 | ||
|
|
48ae2443cd | ||
|
|
f00fbfef09 | ||
|
|
08998de533 | ||
|
|
6af2e2aead | ||
|
|
88737a95a9 | ||
|
|
e1753fa7e4 | ||
|
|
5bfe1f3c6e | ||
|
|
0ba82b7d33 | ||
|
|
668287f640 | ||
|
|
5e5d380fd3 | ||
|
|
6fb6d30e8d | ||
|
|
ace6c50743 | ||
|
|
82a74277cb | ||
|
|
5d55a65c22 |
No files matched your search
+81
-1
@@ -128,6 +128,86 @@
|
||||
"$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
|
||||
},
|
||||
{
|
||||
"name": "kantarci-markt",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8100'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8100 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8100
|
||||
},
|
||||
{
|
||||
"name": "kantarci-markt-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8101
|
||||
},
|
||||
{
|
||||
"name": "huefner",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8102'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8102 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8102
|
||||
},
|
||||
{
|
||||
"name": "huefner-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8103'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8103 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8103
|
||||
},
|
||||
{
|
||||
"name": "kantarci-reisen",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8104'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8104 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8104
|
||||
},
|
||||
{
|
||||
"name": "kantarci-reisen-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8105'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8105 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8105
|
||||
},
|
||||
{
|
||||
"name": "sprachen-test",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8111'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8111 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8111
|
||||
},
|
||||
{
|
||||
"name": "sprachen-test-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8112'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8112 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8112
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem
|
||||
|
||||
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`.
|
||||
|
||||
## Buttons im Menü (ab 1.26.0)
|
||||
|
||||
Unter **Menüs** einen Eintrag aufklappen und **Als Button darstellen** anhaken. Dann stehen Form, Darstellung und Größe (leer = Website-Standard unter Design → Buttons), Fläche und Schrift – auch beim Überfahren – sowie die Symbolfarbe mit der Farbpalette zur Wahl; das Symbol wird wie im Editor im Fenster gewählt, dazu Bewegung und „Nur das Symbol zeigen“. **Auf dem Telefon**: im aufgeklappten Menü (volle Breite), neben dem Menü-Knopf oder dort nur als Symbol. Eine Vorschau zeigt den Button normal und beim Überfahren. Einträge mit Unterpunkten können kein Button sein. Prüfung: `npm run test:menu-buttons`, Screenshots unter `reports/menu-buttons/`. Details: ADR 110.
|
||||
|
||||
## 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.
|
||||
@@ -216,6 +220,8 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
||||
|
||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
**Symbole (ab 1.25.0):** Button, Symbol oder Symbolkarte auswählen → Inhalt → **Symbol** → **Ändern …**. Das Fenster zeigt 98 Symbole in sieben Kategorien, mit Namen; Pfeiltasten und Escape funktionieren, beim Button gibt es „Kein Symbol“. Am Button außerdem **Stellung** (vor/nach dem Text), **Beim Überfahren** (Ruhig, Rückt, Erscheint) und **Nur das Symbol zeigen** (quadratischer Knopf, die Beschriftung bleibt für Vorlesesoftware). Farbe, Fläche, Größe, Ecken, Drehung und Abstand stehen unter **Design → Symbol**, je Gerät und beim Überfahren. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil. Neue Symbole kommen nach `public/assets/icons.js`, danach `npm run build:icons`. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108 und 109.
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
|
||||
@@ -335,7 +341,7 @@ Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsv
|
||||
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20261009000100` legt die Tabelle `page_translation` für die Mehrsprachigkeit an (neue Tabelle, kein Eingriff in Bestehendes; Sprachen unter Einstellungen → Sprachen, ADR 111). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
|
||||
## Verwaltung ab 1.7.0
|
||||
|
||||
@@ -385,6 +391,14 @@ Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH,
|
||||
|
||||
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.
|
||||
|
||||
### Achte lokale Referenzwebsite: Kantarci Markt (Vorab-Seite zur Neueröffnung)
|
||||
|
||||
Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. Die Demo der Eröffnungsseite liegt unter `/eroeffnung` (noindex) und bettet die Angebote aus `app.kantarci-markt.de` ein; die App erlaubt das nur von `localhost`, deshalb misst man sie mit `CMS_HOST_RULES="MAP localhost 127.0.0.1" CMS_URL=http://localhost:8101 CMS_PATHS=/eroeffnung`.
|
||||
|
||||
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
|
||||
|
||||
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf 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`.
|
||||
+91
-1
@@ -1,6 +1,94 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 09.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 111 – Mehrsprachigkeit als Übersetzungsebene, nicht als zweite Seite (Dokumentschema v27, 09.10.2026)
|
||||
|
||||
Umut: „Ich füge weitere Sprache hinzu und man wählt aus und muss dann zu jedem Feld die 2. Sprache hinzufügen. Ich möchte nicht für die 2. Sprache die komplette Seite neu bauen.“ Danach: „`/en/`-Präfix und übersetzte Slugs.“ Das Datenmodell hatte seit ADR 003 eine Spalte `locale` an jeder Seite – also das WPML-Prinzip „eine Sprache = eine eigene Seite“. Genau das wollte Umut nicht.
|
||||
|
||||
**Entscheidung.** Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter `translations.<sprache>` nur Texte, adressiert über die stabile Block-ID: `{slug?, title?, description?, seoTitle?, blocks: {<id>: {<eigenschaft>: text}}, source: {<id>.<eigenschaft>: fnv1a32}}`. Welche Eigenschaften übersetzbar sind, steht einmal in `Translations::FIELDS` und gespiegelt in `translate.js` (ein Test vergleicht beide): Texte von Überschrift, Text, Button, Bild (Alt, Unterschrift), Symbolkarte, Kennzahl, Zitat, Aufruf-Band, Kontakt (Anschrift), Akkordeon, Tab, Rechtszeile, Beitragsnavigation, Symbol, Fortschritt, Externer Inhalt. Aufbau, Gestaltung, Bilder und Links gibt es nur einmal.
|
||||
|
||||
**Warum im Dokument.** So gehören Übersetzungen zu jeder Fassung: Entwurf, Veröffentlichen, Wiederherstellen und Undo im Editor gelten für alle Sprachen zugleich, ohne zweiten Revisions- und Veröffentlichungsweg. Veraltete Schreibzugriffe erkennt dieselbe Versionsprüfung. Ein gelöschter Block nimmt beim Speichern seine Übersetzungen mit (`Translations::normalise`). Folge: Veröffentlichen gilt für alle Sprachen einer Seite gemeinsam; eine Sprache einzeln zurückhalten geht nur, indem man ihre Texte leert.
|
||||
|
||||
**Ausgeliefert wird eine Sprache durch Überlagerung.** `Translations::apply` legt die Texte auf eine Kopie des Dokuments und entfernt `translations`; danach laufen dieselbe Prüfung (`DocumentValidator`, auch je Sprache beim Speichern) und dieselben Vorlagen wie für die Hauptsprache. Kein Renderer muss Sprachen kennen, und eine Übersetzung kann keine Länge, keine Auszeichnungsregel und keine H1-Regel umgehen; formatierter Text geht durch denselben `RichText`-Filter. Ein leeres Feld zeigt den Text der Hauptsprache. Ein dekoratives Bild bekommt auch übersetzt keinen Alternativtext.
|
||||
|
||||
**Adressen.** `/en` ist die Startseite der Sprache, `/en/<pfad>` eine übersetzte Seite. Der Pfad besteht aus den übersetzten Adressen der Seite und ihrer Vorfahren; fehlt einem Vorfahren die Übersetzung, steht seine Adresse der Hauptsprache dort. Die Tabelle `page_translation (page_id, locale, slug, path)` (Migration `20261009000100`, neue Tabelle, kein Eingriff in `page`) spiegelt genau den veröffentlichten Stand: Zeilen entstehen und verschwinden beim Veröffentlichen, beim Verschieben einer Seite werden alle Pfade neu berechnet. Doppelte Adressen werden beim Speichern des Entwurfs abgelehnt; eine geänderte veröffentlichte Adresse bekommt eine 301-Weiterleitung (`en/alt` → `en/neu`). Eine Wurzelseite darf nicht wie eine aktive Sprache heißen, und eine Sprache lässt sich nicht einschalten, solange eine Seite ihre Adresse belegt.
|
||||
|
||||
**Links.** Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf deren Übersetzung – Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen. Umgesetzt als eine Ersetzung über die fertige Ausgabe (`PageController::localiseLinks`, nur `<a href="/…">`, nur Pfade aus der Zuordnung der Sprache). Links der Sprachwahl tragen `hreflang` und bleiben unberührt.
|
||||
|
||||
**SEO.** `<html lang>`, `og:locale` samt `og:locale:alternate`, Canonical auf die übersetzte Adresse, `hreflang` je veröffentlichter Sprache und `x-default` auf die Hauptsprache, die übersetzten Adressen in der Sitemap. Eine Seite ohne Übersetzung bekommt keine Alternativen. Ohne eigenen übersetzten Suchtitel nimmt die Seite den übersetzten Titel, nie den Suchtitel der Hauptsprache.
|
||||
|
||||
**Einsprachig bleibt alles wie vorher.** Sprachen stehen unter Einstellungen → Sprachen (`settings.languages`, nur Administratoren). Ohne weitere Sprache gibt es keinen Umschalter, keine Felder, keine `/en`-Route, kein `hreflang`. Wird eine Sprache abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben im Dokument.
|
||||
|
||||
**Editor.** Sprachumschalter DE | EN neben der Vorschaugröße. In einer weiteren Sprache zeigt die Seitenleiste die Liste aller Texte in Seitenreihenfolge, je mit Original, Feld und Zustand (fehlt / übersetzt / „Original geändert“, erkannt am FNV-1a-Vermerk), Filter, „Original übernehmen“ und „Übersetzung passt“. Dazu Adresse (mit Vorschlag aus dem übersetzten Titel) und Suchtitel. Die Vorschau zeigt die Seite in der Sprache, ein Klick springt zu den Texten des Blocks. Alles, was den Aufbau ändert (Werkzeugkasten, Struktur, Blockaktionen, Einfügepunkte, Ziehen, Spaltenbreiten, Doppelklick-Bearbeitung, Kontextmenü), ist in dieser Ansicht aus.
|
||||
|
||||
**Baustein „Sprachwahl“ (`language`, Schema v27).** Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist, als Kürzel oder Name; auf einer nur einsprachigen Seite nichts. Kürzel tragen den Sprachnamen für Vorlesesoftware, jeder Link `lang` und `hreflang`, die aktuelle Sprache `aria-current`.
|
||||
|
||||
**Stufe 2: Bereiche, Menüs, feste Texte (09.10.2026).** Kopf, Fuß und verknüpfte Sektionen tragen dieselbe Übersetzungsebene im Dokument und werden im selben Editor mit dem Umschalter übersetzt – ohne Adresse und Seitentitel. Da Bereiche keinen Entwurf haben, gilt eine gespeicherte Übersetzung sofort. Ausgeliefert werden sie wie Seiten: in einer weiteren Sprache überlagert, in der Hauptsprache ohne `translations`; ebenso in der Vorschau von Seite und Bereich. Menüeinträge haben keine stabile ID, deshalb trägt der Eintrag selbst seine Beschriftungen: `labels: {en: "Services"}` (je höchstens 80 Zeichen, bekannte Sprache, leer = Hauptsprache). Sie wandern so beim Umsortieren mit. Die Menüverwaltung zeigt je weitere Sprache ein Feld „Beschriftung Englisch“. Feste Texte der öffentlichen Vorlagen (Sprunglink, Menü-Knopf, Namen der Navigationen, Brotkrumen-Start, Rechtslinks, Einwilligung, Beitragslisten, „Nach oben“) gehen durch den Filter `kant_t` mit einem Wörterbuch in `LanguageExtension::UI` – Englisch vollständig, Französisch und Türkisch teilweise, sonst Englisch als Rückfall. Die Sprache der Anfrage steht im Anfrageattribut `kant_locale`.
|
||||
|
||||
**Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026).** Formulare tragen ihre Übersetzungen in den Einstellungen des Formulars (`settings.translations.<sprache>`: Erfolgsmeldung, Button, je Feld Beschriftung, Hilfetext, Einheit und Optionen als Zuordnung Original → Anzeige); gepflegt am Feld in der Formularverwaltung. Abgeschickt werden weiter die Optionen der Hauptsprache, damit Bedingungen, Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger unverändert bleiben. Ein verstecktes Feld `language` lässt die Prüfung ihre Meldungen in der Sprache geben (`FormRepository::MESSAGES`) und führt nach dem Absenden auf die übersetzte Seite zurück; das Schrittskript bekommt seine Texte als `data-texts`. Beiträge sind Seiten und werden wie Seiten übersetzt; Listen, Nachbarn und Archive zeigen übersetzte Titel und Auszüge, nicht übersetzte Beiträge in der Hauptsprache. Archive antworten unter `/en/beitraege`, `/en/kategorie/…`, `/en/schlagwort/…` (Präfixe bleiben deutsch). Namen von Kategorien und Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises stehen je Sprache in den Einstellungen (`termNames`, `consentTexts`, Einstellungen → Sprachen). Eine 404 unter `/en/…` antwortet auf Englisch: die eigene 404-Seite mit ihrer Übersetzung, die mitgelieferte über das Wörterbuch.
|
||||
|
||||
**Grenzen.** Feed, Einwilligungsseite `/einwilligung`, Datumsformat und die Präfixe der Archivadressen bleiben in der Hauptsprache. Eigenes HTML und Plugin-Bausteine bleiben unübersetzt. Bilder sind bewusst nicht je Sprache austauschbar (Umut, 09.10.2026). Die Kennzeichnung einzelner nicht übersetzter Texte mit `lang="de"` fehlt.
|
||||
|
||||
**Rückweg.** Ältere Kerne lehnen Dokumente mit Schema v27 ab (wie bei jeder Schemaerhöhung); Installationen zuerst aktualisieren. Die Migration lässt sich zurücknehmen (`down` löscht nur `page_translation`); die Übersetzungen bleiben dann im Dokument, sind aber nicht mehr erreichbar.
|
||||
|
||||
## ADR 110 – Menüeinträge als Button (07.10.2026)
|
||||
|
||||
Umut: „Buttons im Menü sind in KantSites aktuell nicht möglich … kannst du das im Menüeditor einbauen, dass man individuell Buttons nach dem gleichen Prinzip wie im Editor einbauen kann? Mit Icon, Farbe usw.“ Typisch ist „Angebot anfragen“ oder „Anrufen“ rechts im Kopf, auf dem Telefon griffbereit neben dem Menü-Knopf.
|
||||
|
||||
**Datenvertrag (keine Schemaänderung der Seiten).** Ein Menüeintrag trägt optional `button` = `{id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?}` im JSON-Baum des Menüs (ADR 012). Form, Darstellung und Größe stammen aus `ButtonDesign::OPTIONS`, Bewegung und „nur Symbol“ wie am Button-Baustein (ADR 109), das Symbol ist das vorhandene `icon` des Eintrags. Fehlende Werte heißen Website-Standard unter Design → Buttons. Farben sind `#hex` oder `@palette` (`StyleSchema::COLOR`). `mobile`: `menu` (im aufgeklappten Menü, volle Breite), `bar` (neben dem Menü-Knopf) oder `bar-icon` (dort nur als Symbol – verlangt ein Symbol). Ein Button hat keine Unterpunkte. `MenuButton::validate` lehnt alles andere ab, also auch CSS in Farbwerten.
|
||||
|
||||
**Darstellung.** `templates/menu-button.html.twig` erzeugt dieselben Klassen wie der Button-Baustein (`cms-button`, `b-desktop-*`, `icon-start`, `icon-motion-*`, `icon-only`), damit gelten `buttons.css`, Website-Standards, Hover und „Bewegung reduzieren“ unverändert. Die Menüregeln für Links (Polster, Unterstreichung, Großschrift im aufgeklappten Kopf, `margin-top` von `.button`) gelten für Menü-Buttons nicht.
|
||||
|
||||
**Eigene Farben ohne Inline-Stil.** Die CSP erlaubt keine `style`-Attribute. Jeder Button bekommt deshalb eine feste Kennung (`m` + 10 Hexzeichen, bleibt beim Speichern erhalten), und `MenuRepository::buttonCss` schreibt `.menu-button-<id>{--kb-fill…}` samt der abgeleiteten Werte der Darstellung in das Seiten-Stylesheet – auf der Website, in der Editor- und in der Bereichsvorschau.
|
||||
|
||||
**Telefon.** `bar`/`bar-icon` werden im Menübaustein ein zweites Mal als `ul.menu-actions` vor dem Menü-Knopf ausgegeben; sichtbar nur, solange das Menü zusammengeklappt ist (Tablet bzw. Telefon je nach Einstellung), im aufgeklappten Menü ist das Original dann ausgeblendet (`menu-keep-visible`) – nichts steht doppelt. Neben dem Knopf sind Buttons kompakter und brechen nicht um; Leiste und Knopf bleiben in einer Zeile. Erster Versuch nur mit `bar`: Ein zweizeiliger Text-Button schob den Menü-Knopf auf dem 390-px-Telefon in eine neue Zeile – daher die Variante nur mit Symbol.
|
||||
|
||||
**Menüverwaltung.** Je Eintrag „Als Button darstellen“; erst dann erscheinen Form, Darstellung, Größe, fünf Farbfelder mit Palette (wie im Editor), Bewegung, „Nur das Symbol zeigen“ und die Telefon-Anzeige, dazu eine Vorschau normal und beim Überfahren. Das Symbol wird wie im Editor im Fenster gewählt (ADR 109). Ohne JavaScript bleiben alle Felder normale Formularfelder.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig, darunter `testAMenuEntryRendersAsAButtonWithItsOwnColoursAndStaysBesideTheBurger`, neun Ablehnungsfälle und `testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId`. `npm test`. `npm run test:menu-buttons` im Browser auf isolierter Pilotkopie: Verwaltung mit Fenster, Palette, Vorschau und axe; Speichern/Neuladen; Website bei 1440 und 390 px (Symbol und Menü-Knopf auf einer Linie, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite mit Rahmen, axe, kein waagerechtes Scrollen). Screenshots unter `reports/menu-buttons/`.
|
||||
|
||||
**Bekannt:** Ist die Spalte mit dem Menü schmal (Pilot: zwei gleich breite Spalten), bricht ein Button wie jeder Menüeintrag in die nächste Zeile; abhelfen über die Spaltenbreite oder Größe „Klein“. Menüs mit Buttons brauchen einen Kern ab 1.26.0; ältere Kerne lehnen den unbekannten Schlüssel ab.
|
||||
|
||||
## ADR 109 – Symbole durchdacht: 98 im Satz, Auswahl im Fenster, Gestaltung unter Design (07.10.2026)
|
||||
|
||||
Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal […] Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“ Zu Recht: 33 Symbole waren zu wenig, die Kachelwand drückte das Panel nach unten, und Farbe oder Größe des Symbols gingen nur über eigenes CSS.
|
||||
|
||||
**Symbolsatz.** 98 Symbole in sieben Kategorien (Pfeile, Kontakt & Ort, Handel & Geld, Dateien & Medien, Personen & Sicherheit, Haus & Handwerk, Allgemein). Neu sind unter anderem Warenkorb, Euro, Lieferung (LKW), Paket, Karte, Navigation, Hochladen, Drucken, Teilen, Kamera, Schloss, Auszeichnung, Werkzeug, Hammer, Malerrolle, Baum, Blatt, Sonne, Wasser, Strom, Solar, Auto und Warnung – also auch das LKW-Symbol, das bei Hüfner fehlte. Weiter keine Markenlogos. Eine Quelle: `public/assets/icons.js`. `npm run build:icons` schreibt daraus `icon.html.twig` und `DocumentValidator::ICONS`, `tests/icons.test.mjs` prüft die Gleichheit.
|
||||
|
||||
**Auswahl im Fenster.** Im Panel steht nur das gewählte Symbol mit Vorschau, Namen und „Ändern …“. Das öffnet ein modales Fenster (`<dialog>`): Kategorien als Reiter links, Symbole mit Namen als Kacheln, Öffnen bei der Kategorie des gewählten Symbols mit Fokus darauf, Pfeiltasten in Reitern und Raster, Escape oder Klick auf den Rand schließt ohne Änderung, der Fokus kehrt zurück. Beim Button gibt es dort „Kein Symbol“. Symbol und Symbolkarte nutzen dasselbe Fenster.
|
||||
|
||||
**Gestaltung unter Design (keine Schemaänderung).** Neue Gruppe „Symbol“ im Style-Schema mit dem Ziel `icon`: Symbolfarbe, Fläche hinter dem Symbol (beide auch beim Überfahren), Symbolgröße, Innenabstand und Ecken der Fläche, Drehung (auch beim Überfahren), dazu Abstand zum Text am Button selbst. Damit gelten Gerätewerte, Vererbung, Hover, „Gestaltung kopieren“ und die Farbpalette wie überall. Der Compiler schreibt `#id .button-icon{…}` und `#id:hover .button-icon,#id:focus-visible .button-icon{…}`. Größe und Abstand laufen über `--icon-size` und `--icon-gap`, die `site.css` mit 1,15 em und 0,65 em als Vorgabe liest – ohne Angabe sieht alles aus wie vorher. Die Gruppe erscheint nur bei Buttons mit Symbol und ist dort aufgeklappt; ein Knopf im Inhalt führt hin.
|
||||
|
||||
**Verhalten im Inhalt (Schema v26).** `iconMotion`: `none` (ruhig), `slide` (rückt beim Überfahren in Leserichtung) oder `reveal` (erscheint erst beim Überfahren). `iconOnly`: ein Button nur aus Symbol; die Beschriftung steht dann als `.button-label` ohne Breite im Link – sie bleibt der zugängliche Name und hält die Zeilenhöhe, sodass der Knopf quadratisch und genauso hoch ist wie Text-Buttons daneben (zuerst mit `aspect-ratio` versucht: das zog die Höhe auf die Breite herunter). `iconOnly` ohne Symbol lehnt der Validator ab, und die Wahl „Kein Symbol“ nimmt es im Editor mit weg. Bewegungen folgen „Bewegung reduzieren“.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig (616), darunter `testAButtonIconMovesOnHoverOrStandsAlone`, `testButtonIconStylesTargetTheIconPerDeviceAndOnHover`, `testAButtonWithoutAnIconCannotBeIconOnly` und neue Ablehnungsfälle. `npm test` (38). `npm run test:button-icons` im Browser: Panel ohne Kachelwand, Fenster mit sieben Kategorien und axe ohne Befund, Escape, Pfeiltasten, Stellung, Bewegung mit Rückgängig, nur Symbol, Design-Gruppe mit allen Werten und wirksamer Größe, Speichern als v26 und Neuladen, gemessenes Aussehen (Flächenfarbe, Rundung, Drehung beim Überfahren, quadratischer Nur-Symbol-Button). `test:buttons` und `test:browser` (Hüfner, Entwicklungsmodus) grün, HTML 148 KB unter dem Budget.
|
||||
|
||||
## ADR 108 – Das Symbol am Button ist wählbar, ausgewählt wird mit Kacheln (07.10.2026)
|
||||
|
||||
Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – und dann: „Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche.“ Bis hierher hängte `block.html.twig` jedem Button fest `arrow-up-right` an, auch dem Textlink. Ändern ging nur über eigenes CSS (Hüfner blendet den Pfeil so aus, EB Solar drehte ihn).
|
||||
|
||||
**Datenvertrag (Schema v25).** Zwei neue, optionale Eigenschaften am Button: `icon` – ein Name aus `DocumentValidator::ICONS` oder `none` – und `iconPosition` – `start` oder `end`. Fehlt `icon`, bleibt es beim schrägen Pfeil (`BUTTON_DEFAULT_ICON`); fehlt `iconPosition`, steht das Symbol hinter dem Text. Ein Dokument ohne die beiden Felder sieht also genau aus wie vorher und braucht keinen Migrationsweg. Beide Felder verlangen v25, damit ein älterer Kern ein Dokument mit Symbolwahl ablehnt, statt es still mit Pfeil zu zeigen. Der Editor hebt das Dokument bei der ersten Wahl an, und `repairSchema` tut es beim Laden.
|
||||
|
||||
**Kompatibilität mit eigenem CSS.** Die Hülle des Symbols heißt jetzt `button-icon`. Der Standardpfeil trägt zusätzlich weiter `button-arrow`, damit Regeln wie `& .button-arrow{display:none}` auf bestehenden Websites wirken wie bisher. Wer dort ein anderes Symbol wählt, bekommt es sichtbar – die alte Regel trifft es nicht. Die Farb- und Größenregeln aus ADR 107 gelten jetzt für `button-icon`.
|
||||
|
||||
**Symbolsatz.** Dreizehn alltägliche Symbole kommen dazu: Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person. Sie stehen allen Bausteinen mit Symbol offen (Symbol, Symbolkarte, Button), nicht nur dem Button; das erweitert eine bestehende Liste und braucht wie ADR 107 kein neues Schema. Keine Markenzeichen (etwa WhatsApp): deren Logos haben eigene Nutzungsregeln.
|
||||
|
||||
**Auswahl mit Kacheln.** `public/assets/icons.js` hält Pfade, deutsche Namen und die Gruppen (Pfeile, Kontakt, Allgemein; bei Symbol und Symbolkarte zusätzlich „Weitere“) und zeichnet die Auswahl: jedes Symbol ein Knopf mit seiner Zeichnung und `aria-pressed`, „Kein Symbol“ vorn, Pfeiltasten wandern, Tab verlässt die Gruppe. Die bisherige Auswahlliste mit englischen Schlüsseln bei Symbol und Symbolkarte ist durch dieselbe Kachelauswahl ersetzt. Die Stellung ist ein Umschalter aus zwei Knöpfen und erscheint nur, wenn ein Symbol gewählt ist. Die Pfade stehen damit zweimal (Twig für die Website, JS für die Vorschau); `tests/icons.test.mjs` hält beide und die Liste im Validator gleich.
|
||||
|
||||
**Geprüft:** `CmsTest::testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone` (Standardpfeil mit `button-arrow`, Telefon vorn, kein Symbol), `testAButtonRejectsUnknownIconsAndOlderSchemas`, `tests/icons.test.mjs`, `repairSchema` hebt auf v25, `npm run test:button-icons` im Browser (Kachel, Stellung, Tastatur, Rückgängig, kein Symbol, Speichern als v25 und Neuladen).
|
||||
|
||||
## ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026)
|
||||
|
||||
Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
|
||||
|
||||
**`tel:` und `mailto:` als Linkziel von Bausteinen.** Menüeinträge (`MenuRepository::url`), Links im formatierten Text (`RichText::safeHref`) und Plugin-Felder kannten beide längst. Nur die Linkziele der Bausteine – Button, Symbolkarte, Handlungsaufruf, Symbol, verlinktes Bild – liefen über `DocumentValidator::url()` und nahmen bloß Pfad, Sprungmarke und HTTPS. Ein Knopf „Jetzt anrufen“ ging deshalb nur als Textlink im Knopf-Aussehen. Jetzt prüft `link()` diese fünf Stellen: zuerst wie bisher, dazu `tel:` mit Ziffern, `+`, Klammern und Bindestrich (keine Leerzeichen, wie im Menü) und `mailto:` mit einer gültigen Adresse und höchstens einer prozentkodierten Abfrage (`?subject=…`). Bildquellen und eingebettete Inhalte bleiben bei `url()`: `tel:` ist dort kein sinnvolles Ziel.
|
||||
|
||||
Das Dokumentschema bleibt bei v24. Die Änderung erlaubt nur zusätzliche Werte in einem bestehenden Feld und braucht keinen Migrationsweg. Ein Dokument mit `tel:` an einem Knopf lässt sich aber auf einem Kern vor dieser Fassung nicht speichern. Eine Installation, die solche Dokumente bekommt, braucht also zuerst das Update.
|
||||
|
||||
**Der Pfeil im Knopf folgt der Schriftfarbe.** Das Pfeilsymbol trägt die Klasse `block-icon`, und die färbt jedes Symbol in der Akzentfarbe. Auf einem roten Knopf mit weißer Schrift stand so ein grüner Pfeil, und auch die Farbe beim Überfahren erreichte ihn nicht. `.button-arrow .block-icon{color:inherit}` in `site.css` setzt das richtig. Wo eine Website ihren Pfeil absichtlich anders färbt, tut sie das weiter über das eigene CSS des Knopfs; das geht vor.
|
||||
|
||||
**Geprüft:** `CmsTest::testButtonsAndLinkedBlocksMayCallOrWrite` (speichern, ausgeben, Bildquelle mit `tel:` abgewiesen), neue Ungültig-Fälle in `invalidDocuments` (Buchstaben oder Leerzeichen in der Nummer, `mailto:` ohne Adresse oder mit Anführungszeichen bzw. `<`), `testTheButtonArrowTakesTheColourOfTheButtonText`.
|
||||
|
||||
## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026)
|
||||
|
||||
@@ -711,6 +799,8 @@ Umut wollte an jedem Element eigenes CSS **und** eigenes Skript setzen können.
|
||||
|
||||
Zwei echte Löcher kamen beim Testen heraus. `& .block-text` wurde zu `#id.block-text`, weil das führende Leerzeichen wegtrimmt wurde – aus dem Nachfahren wurde derselbe Knoten, die Regel traf das falsche Element. Und `url(//fremd.example/x.png)` kam durch, weil die Prüfung jedes `/` erlaubte: eine protokollrelative Adresse hätte über das Schema der Seite von Dritten geladen. Beides ist behoben und durch Tests abgedeckt.
|
||||
|
||||
**Nachtrag 23.09.2026: `content` mit Text.** Werte durften bisher keine Anführungszeichen enthalten, damit gab es kein `::before` oder `::after` – beim Nachbau von Kantarci Markt fehlten dadurch der Verlauf über einem Foto, ein Goldstrich und ein Buchstabe als Wasserzeichen. Jetzt nimmt `content` einen Text in einfachen oder doppelten Anführungszeichen an, höchstens 40 Zeichen aus Buchstaben, Ziffern, Leerzeichen und `.,:!?·•–-`. Kein `<`, kein Backslash, kein zweites Anführungszeichen: Der Wert kann weder den String noch das `<style>` verlassen. Jede andere Eigenschaft bleibt ohne Anführungszeichen (also auch kein `font-family:'…'`). Folge: Dekoration, die Vorlesesoftware nicht vorlesen soll, gehört in ein Pseudo-Element statt in einen Textbaustein.
|
||||
|
||||
## ADR 029 – Verwaltung: ein Rahmen statt sieben Kopfzeilen (12.09.2026)
|
||||
|
||||
Umuts Auftrag war „backend komplett neugestalten, einfacher schöner Bedienbarkeit komplett einfach“, mit ausdrücklicher Gestaltungsfreiheit. Das Problem war weniger die Optik als die Orientierung: jede Verwaltungsseite trug ihre eigene Kopfzeile und ihre eigene, unvollständige Liste von Links auf die anderen Seiten – die Seitenübersicht verlinkte sieben Bereiche, das Menü fünf, die Fassungen drei. Wo man war, stand nirgends.
|
||||
|
||||
+198
@@ -1,5 +1,203 @@
|
||||
# Projektstatus
|
||||
|
||||
## Formularverwaltung übersichtlicher (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: Formulare „sehr unübersichtlich … kompakter, nicht so wuchtig und so voll“; bei mehreren Formularen erst eine Übersicht ohne Vorschau, dann in die Seite rein.
|
||||
|
||||
**Neu:** `/admin/forms` ist eine Übersicht – je Formular eine Zeile mit Feldern, Empfänger, Adresse, übersetzten Sprachen und Zahl der Einsendungen, darunter die Einsendungen. Bearbeitet wird auf `/admin/forms/<adresse>` (neu: `/admin/forms/neu`), nach dem Speichern bleibt man dort. Im Editor ist jedes Feld eine Zeile (Beschriftung, Typ, Pflicht), alles Weitere unter „Details“ mit einer Kurzinfo darunter (technischer Name, Optionen, Hilfetext, Bedingung, „EN ✓“). Zustellung, Aussehen und je Sprache die Übersetzung sind zuklappbar mit Zusammenfassung; Erklärtexte gekürzt. Ohne JavaScript bleiben alle Felder sichtbar und bedienbar. Beim Testformular der Hüfner-Kopie: Seitenlänge von 4.913 px auf 1.274 px.
|
||||
|
||||
**Robuster:** Ohne weitere Sprache fasst der Kern die Tabelle `page_translation` nicht an – Speichern und Veröffentlichen funktionieren so auch, wenn nach einem Update die Migration noch aussteht (gefunden über die Pilot-Fixture, die ohne Migrationen kopiert).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 636 grün (vor dem Umbau), danach die betroffenen 59 (Editor-Sicherheit, Formulare, Übersetzung) grün; `npm test`, `lint:twig`, php-cs-fixer; `test:admin --admin-only` und `test:editor` grün inklusive axe auf allen Verwaltungsseiten und im Formulareditor; im Browser Übersicht, Bearbeiten, Speichern, Neu, 404 für unbekannte Adresse, Telefonbreite ohne waagrechtes Scrollen.
|
||||
|
||||
**Bekannt:** `test:admin` ohne `--admin-only` scheitert im Slider-Teil der Pilotseite – ebenso auf dem unveränderten Stand 1.26.0 (eigens in einem separaten Arbeitsverzeichnis geprüft), also kein Folgefehler dieser Änderungen.
|
||||
|
||||
## Mehrsprachigkeit, Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026)
|
||||
|
||||
**Neu (ADR 111, keine Migration):** Formulare je Sprache übersetzbar (in der Formularverwaltung am Feld: Beschriftung, Hilfetext, Optionen, Einheit; am Formular Button und Erfolgsmeldung), Fehlermeldungen und Schrittnavigation in der Sprache, Rückweg nach dem Absenden auf die übersetzte Seite; Einsendungen und E-Mail bleiben deutsch. Beitragslisten und Archive (`/en/beitraege`, `/en/kategorie/…`) mit übersetzten Titeln; Kategorie-/Schlagwortnamen und Einwilligungstext unter Einstellungen → Sprachen. 404 unter `/en/…` auf Englisch.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig **636 grün** (neu: Formular in beiden Sprachen inkl. Absenden/Fehler/Weiterleitung; Beiträge, Archive, Kategorienamen, Einwilligung, 404), `npm test` 41, `lint:twig`, php-cs-fixer. Browser (Testkopie Hüfner): Formularübersetzung gespeichert, `/en` zeigt „Your name (EN) * (required)“ und „Send“, `/` unverändert. **Nicht geprüft:** Formularversand per E-Mail, Lighthouse/axe auf `/en`.
|
||||
|
||||
**Bekannt:** Feed, `/einwilligung`, Datumsformat und Archiv-Präfixe bleiben deutsch.
|
||||
|
||||
## Mehrsprachigkeit, Stufe 2: Kopf, Fuß, Menüs, feste Texte (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Bilder lassen wir, mach weiter.“ Bilder bleiben für alle Sprachen gleich.
|
||||
|
||||
**Neu (ADR 111, keine weitere Migration):**
|
||||
|
||||
- **Kopf, Fuß und Sektionen** übersetzen: im Bereichs-Editor derselbe Umschalter DE | EN, die Liste zeigt nur die Texte (keine Adresse, kein Seitentitel). Gespeichert gilt sofort.
|
||||
- **Menüs:** je Eintrag ein Feld „Beschriftung Englisch“ (leer = deutsch). Auf `/en/…` zeigen die Einträge ihre englische Beschriftung und führen auf die englischen Seiten.
|
||||
- **Feste Texte** wie „Zum Inhalt springen“, „Menü“, „Hauptnavigation“, „Impressum/Datenschutz“ in der Rechtszeile, Einwilligungsknöpfe, „Weiterlesen“, „Nach oben“ erscheinen auf englischen Seiten englisch (Französisch/Türkisch teilweise, sonst Englisch).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig **634 grün**, darunter neu ein Test für Kopfbereich, Menü und feste Texte in beiden Sprachen; `npm test` 41 grün; `lint:twig`; php-cs-fixer. Im Browser (Testkopie Hüfner, Port 8111): Menü mit „Services“/„References“ gespeichert, `/en` zeigt sie mit Zielen `/en#…`, „Skip to content“, „Main navigation“; Kopfbereich im Editor auf Englisch, „Request a quote“ in Vorschau und auf `/en`, `/` weiter „Angebot anfragen“. **Nicht geprüft:** Lighthouse/axe auf `/en`.
|
||||
|
||||
**Bekannt:** Formulare, Beiträge/Archive, Einwilligungstext aus den Einstellungen und die 404-Seite noch einsprachig. Nach einem Kern-Update cached die App-Vorschau alte Editor-Skripte (gleiche Fassungsnummer) – erst nach Neuladen der Assets sieht man Änderungen.
|
||||
|
||||
**Nächster Schritt:** Stufe 3 – Formulare (Felder, Meldungen, Bestätigungsmail) und Beiträge mit Archiven übersetzbar machen; danach Lighthouse/axe einer `/en`-Seite.
|
||||
|
||||
## Mehrsprachigkeit, Stufe 1: Seiten übersetzen statt neu bauen (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: weitere Sprache hinzufügen und zu jedem Feld die 2. Sprache eintragen, ohne die Seite für die 2. Sprache neu zu bauen; Adressen mit `/en/`-Präfix und übersetzten Slugs.
|
||||
|
||||
**Neu (Kern, Schema v27, Migration `20261009000100`, ADR 111):**
|
||||
|
||||
- **Einstellungen → Sprachen:** Hauptsprache bleibt Deutsch, weitere Sprachen per Häkchen (10 zur Wahl, höchstens 5). Ohne Häkchen ist nirgends etwas zu sehen.
|
||||
- **Editor:** Umschalter DE | EN oben. In EN zeigt die Seitenleiste alle Texte der Seite mit Original, Zustand (fehlt / übersetzt / Original geändert), Filter, „Original übernehmen“, „Übersetzung passt“; dazu Adresse und Suchtitel. Vorschau in der Sprache, Klick springt zum Text. Aufbau und Gestaltung sind in EN gesperrt. Undo, Speichern und Veröffentlichen wie gewohnt – für alle Sprachen gemeinsam.
|
||||
- **Website:** `/en` und `/en/<übersetzte-adresse>`, `lang="en"`, Canonical, `hreflang` + `x-default`, `og:locale`, Sitemap. Links auf übersetzte Seiten führen zur Übersetzung. Nicht übersetzte Seiten: 404 unter `/en/…`. Neuer Baustein **„Sprachwahl“** (Kürzel oder Namen).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig (633, ein Fehler durch die Änderung gefunden und behoben, danach die betroffenen 426 Tests erneut grün), neu `tests/TranslationTest.php` (Überlagerung, Prüfung wie Hauptsprache, Adressen beim Veröffentlichen/Verschieben mit Weiterleitung, Kollisionen, HTTP mit hreflang/Links/Sitemap/404). `npm test` 41 grün, neu `tests/translate.test.mjs` (Fingerabdruck gleich PHP, Feldliste gleich Server, Undo). `lint:twig`, php-cs-fixer. Im Browser auf einer Kopie der Hüfner-Datenbank (`var/browser-tests/lang/`, Startkonfiguration `sprachen-test`, Port 8111): Sprache eingeschaltet, Startseite übersetzt, Undo/Redo, Speichern, Veröffentlichen, `/en` mit englischem Titel und `hreflang`, `/en/impressum` 404, Sitemap mit `/en`. **Nicht geprüft:** Lighthouse und axe einer `/en`-Seite und des Bausteins „Sprachwahl“, Tastaturfokus beim Klick in die Vorschau (das Browserfenster der Prüfung hatte keinen Fokus).
|
||||
|
||||
**Bekannt (Stufe 2):** Kopf/Fuß, Menübeschriftungen, Formulare, Beiträge/Archive, Einwilligung, 404 und feste Vorlagentexte bleiben auf englischen Seiten deutsch. Eigenes HTML und Plugin-Bausteine unübersetzbar, Bilder nicht je Sprache. Ändert der Server beim Speichern formatierten Text der Hauptsprache, können Übersetzungen fälschlich als „Original geändert“ erscheinen. Dokumente mit Übersetzungen oder Sprachwahl brauchen einen Kern mit Schema v27.
|
||||
|
||||
**Nächster Schritt:** Stufe 2 – Menüs und Kopf-/Fußbereiche übersetzen, danach Formulare und Beiträge. Vorher mit Umut klären, ob Bilder je Sprache austauschbar sein sollen.
|
||||
|
||||
## Zehnte Installation „Kantarci Reisen“ (08.10.2026)
|
||||
|
||||
**Anlass:** Umut: neue Website nach dem Mockup `Downloads/Kantarci-Reisen-Komplettpaket.zip`, DB und Medienordner nach gleichem Muster, Inhalte dürfen individualisiert werden. Inhaber I. Kantarci, Ooser Hauptstraße 51, 76532 Baden-Baden, info@kantarci-reisen.de. Für reiseSoft ein echter Screenshot aus `Downloads/reiseSoft`, Plattform unter reisesoft.kantarci-reisen.de.
|
||||
|
||||
**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/kantarci-reisen.db`, Medien `var/kantarci-reisen-media`, Bildquellen `var/kantarci-reisen-assets`, Startkonfigurationen `kantarci-reisen` (Port 8104, dev) und `kantarci-reisen-prod` (8105). `sh scripts/reset-kantarci-reisen.sh <Ordner Kantarci-Reisen> <Screenshot.png>` schneidet Logo und Seitensymbol zu (`build-kantarci-reisen-assets.php`), baut mit `seed-kantarci-reisen.php` alles neu und holt DM Sans auf den eigenen Server. Ohne Argumente nimmt es die vorbereiteten Bilder.
|
||||
|
||||
- **Startseite** wie das Mockup: geteilter Hero (Petrol/Küstenfoto), drei Angebote als ganz klickbare Karten (ohne Nummern, auf Umuts Wunsch), „Gute Reisen beginnen mit einem Gespräch“ mit Anschrift, neu „So läuft’s“ in drei Schritten, Anfrage auf Petrol mit CMS-Formular `reiseanfrage` (Versand an info@kantarci-reisen.de, dazu Telefon, Reisezeitraum, Personen), reiseSoft mit echtem Screenshot im Browserrahmen und Knopf zur Plattform. Dazu Impressum (Reisevermittlung), Datenschutz, 404, klebender Kopf, Einblendungen.
|
||||
- **„Reise anfragen“** ist ein Menü-Button (ADR 110) mit Pfeil, auf dem Telefon neben dem Menü-Knopf. Eigenes Menü-CSS darf `.menu-list a` kein Polster geben, ohne es dem Button zurückzugeben (`.menu-list .menu-button`).
|
||||
- **Screenshot reiseSoft:** gebaute Fassung von reiseSoft lokal (Port 4199) mit leerem Datenordner im Scratchpad gestartet, Ersteinrichtung mit Testzugang, `scripts/demodaten.mjs` (60 fiktive Vorgänge), Übersicht in 1440×900 @2x aufgenommen. Keine echten Kundendaten.
|
||||
|
||||
**Geprüft:** `test:browser` auf 8104 grün (HTML 66 KB). Lighthouse auf 8105 (Produktion): `/` mobil 98/100/100/100/100, Desktop 100 überall; `/impressum` überall 100 (`reports/lighthouse-kantarci-reisen`). Ganzseitige Screenshots 1440 und 390 px ohne Konsolenfehler und ohne waagrechtes Scrollen. Nicht geprüft: Formularversand per E-Mail.
|
||||
|
||||
**Nachtrag (08.10.2026):** Telefon 0170 2945935 (auch WhatsApp) von Umut; eine USt-IdNr. gibt es nicht. WhatsApp als Anfrageweg aus dem Baustein „Soziale Profile“ (Netzwerk in den Einstellungen, Link mit vorausgefülltem Text): Knopf unter „Über uns“ und neben dem Formular, Link im Fuß, runde Schaltfläche fest unten links (`position:fixed` im eigenen CSS). Datenschutz mit eigenem Punkt zu WhatsApp. „Inhaber“ steht nicht mehr im Fuß, nur im Impressum. Danach erneut `test:browser` grün (HTML 72 KB) und Lighthouse `/` mobil 98, sonst 100; `/datenschutz` überall 100.
|
||||
|
||||
**Bekannt:** Öffnungszeiten und Hoster sind unbekannt und stehen nirgends (Datenschutz nennt den Hoster allgemein). **Die Domain kantarci-reisen.de existiert am 08.10.2026 im DNS nicht** (NXDOMAIN bei Fritz.box, 1.1.1.1 und 8.8.8.8) – damit kommen weder Mails an info@kantarci-reisen.de an noch ist reisesoft.kantarci-reisen.de erreichbar. Vor dem Livegang klären. Das Reisefoto ist laut Paket KI-generiert, so im Bildnachweis. Grenzen des Kerns beim Aufbau: Textblöcke geben `<span>` nicht aus, eigenes CSS erlaubt keine Anführungszeichen in Werten, kein `calc()` in `maxWidth`, kein `>*` und keine `?` in Attributselektoren.
|
||||
|
||||
**Nächster Schritt:** Inhalte mit Umut abstimmen, dann Livegang nach `docs/LIVEGANG.md`.
|
||||
|
||||
## Fassung 1.26.0: Menüeinträge als Button (07.10.2026)
|
||||
|
||||
**Anlass:** Umut: Buttons im Menü – individuell wie im Editor, mit Symbol und Farbe, auf dem Telefon im aufgeklappten Menü oder neben dem Menü-Knopf.
|
||||
|
||||
**Neu (ADR 110):** In der Menüverwaltung je Eintrag „Als Button darstellen“ mit Form, Darstellung, Größe, Fläche/Schrift (auch beim Überfahren), Symbolfarbe, Symbol aus dem Fenster, Bewegung, „nur Symbol“ und Anzeige auf dem Telefon (im Menü / neben dem Menü-Knopf / dort nur als Symbol). Vorschau normal und beim Überfahren.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig, `npm test`, `npm run test:menu-buttons` (Browser, axe, 1440 und 390 px, je ein Button neben dem Menü-Knopf und im aufgeklappten Menü), `lint:twig`, php-cs-fixer.
|
||||
|
||||
**Bekannt:** In schmalen Kopfspalten bricht der Button in die nächste Zeile (wie jeder Menüeintrag). Kern ab 1.26.0 nötig.
|
||||
|
||||
## Fassung 1.25.0: 98 Symbole, Auswahlfenster, Symbol-Gestaltung unter Design (07.10.2026)
|
||||
|
||||
**Anlass:** Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal das gefällt mir nicht. Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“
|
||||
|
||||
**Neu (ADR 109):**
|
||||
|
||||
- **98 Symbole** statt 33 in sieben Kategorien, darunter Warenkorb, Euro, LKW, Paket, Karte, Kamera, Schloss, Werkzeug, Malerrolle, Baum, Solar, Strom, Auto, Warnung. Quelle `public/assets/icons.js`, `npm run build:icons` schreibt Twig und Validator.
|
||||
- **Auswahlfenster:** Im Panel nur das gewählte Symbol mit „Ändern …“. Das Fenster zeigt Kategorien als Reiter und Symbole mit Namen. Gilt für Button, Symbol und Symbolkarte.
|
||||
- **Design → Symbol** (je Gerät, mit Hover): Farbe, Fläche dahinter, Größe, Innenabstand und Ecken der Fläche, Drehung, Abstand zum Text.
|
||||
- **Inhalt (Schema v26):** Stellung, „Beim Überfahren: Ruhig / Rückt / Erscheint“, „Nur das Symbol zeigen“ (quadratischer Knopf, Beschriftung bleibt für Vorlesesoftware).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig (616) grün, `npm test` (38) grün, `npm run test:button-icons` (Editor im Browser, inkl. axe im Fenster und gemessenem Aussehen), `test:buttons` grün, `test:browser` auf Hüfner (Entwicklungsmodus, Port 8102) grün mit HTML 148 KB. php-cs-fixer und `lint:twig` ohne Befund. Nicht geprüft: Lighthouse.
|
||||
|
||||
**Bekannt:** Dokumente mit Bewegung oder „nur Symbol“ brauchen einen Kern ab 1.25.0 (Schema v26). Die Gestaltungswerte (Farbe usw.) brauchen kein neues Schema, werden von älteren Kernen aber abgelehnt (unbekannte Stileigenschaft) – Installationen zuerst aktualisieren. `test:browser` verlangt den Entwicklungsmodus (Kopf `x-kant-queries`), nicht wie in der README beschrieben den Produktionsmodus.
|
||||
|
||||
**Veröffentlicht:** Release v1.25.0 auf git.kant-it.de mit Archiv und `install.php`, Prüfsumme nach dem Herunterladen bestätigt.
|
||||
|
||||
**Hüfner aktualisiert (07.10.2026):** Die lokale Installation läuft aus diesem Repository und hat damit 1.25.0. Auf der Startseite ist das LKW-Symbol der Vorteilsleiste (`vorteil-3-icon`) jetzt der Baustein „Symbol“ mit `truck`, gestaltet wie die drei Nachbarn (32 px, `@hellgruen`), statt „Eigenes HTML“. Geändert direkt in `var/huefner.db` (veröffentlicht, Sicherung `var/huefner.db.vor-lkw`) und in `scripts/seed-huefner.php`, damit ein Neuaufbau dasselbe ergibt. Geprüft im Browser (Symbol sichtbar, keine HTML-Bausteine mehr) und mit `test:browser` (grün, HTML 148 KB).
|
||||
|
||||
**Nächster Schritt:** Livegang Hüfner nach `docs/LIVEGANG.md`, die übrigen Installationen auf 1.25.0 aktualisieren.
|
||||
|
||||
## Fassung 1.24.0: Symbol am Button wählbar, Kachelauswahl für Symbole (07.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – dann: „Ja bitte umsetzen. Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche usw.“
|
||||
|
||||
**Neu (Kern, Schema v25, ADR 108):**
|
||||
|
||||
- Button → Inhalt → **Symbol**: Kacheln mit Vorschau in drei Gruppen (Pfeile, Kontakt, Allgemein), „Kein Symbol“ vorn. Darunter **Symbol steht: Vor dem Text / Nach dem Text**, nur wenn ein Symbol gewählt ist. Ohne Wahl bleibt der schräge Pfeil hinter dem Text – bestehende Seiten sehen unverändert aus, eigenes CSS auf `.button-arrow` greift weiter.
|
||||
- 13 neue Symbole im gemeinsamen Satz (Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person), damit 33 insgesamt.
|
||||
- Symbol und Symbolkarte wählen ihr Symbol mit derselben Kachelauswahl statt der Liste mit englischen Namen.
|
||||
- `public/assets/icons.js` (Pfade, deutsche Namen, Gruppen, Auswahl), in der Importmap eingetragen. Stile in `button-controls.css`. `site.css`: Hülle heißt `button-icon`.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig (609 Tests) grün, darunter zwei neue Tests in `CmsTest` (Ausgabe mit Standardpfeil, Telefon vorn, kein Symbol; Ablehnung unbekannter Symbole und älterer Schemata). `npm test` (38) grün, dazu `tests/icons.test.mjs` (Twig, Editor und Validator kennen dieselben Symbole) und `repairSchema` hebt auf v25. `npm run test:button-icons` im Browser auf einer Kopie der Pilotdaten: Kachel wählen, Stellung umschalten, Pfeiltasten und Enter, Rückgängig/Wiederholen, kein Symbol, Speichern als v25, nach Neuladen gleich; keine Seitenfehler. Screenshots `reports/button-icons/`. php-cs-fixer und `lint:twig` ohne Befund. Nicht geprüft: Lighthouse und `test:browser` (die öffentliche Ausgabe ändert sich nur bei gewähltem Symbol um ein Inline-SVG gleicher Größe).
|
||||
|
||||
**Bekannt:** Ein Dokument mit Symbolwahl lässt sich auf einem Kern vor dieser Fassung nicht speichern (Schema v25) – Installationen zuerst aktualisieren. Keine Markenlogos im Satz.
|
||||
|
||||
**Nächster Schritt:** Push von `main` und Tag `v1.24.0`, Releasearchiv auf der Releaseseite veröffentlichen, dann die Installationen aktualisieren (vor Hüfner, damit dort Symbole gewählt werden können).
|
||||
|
||||
## Fassung 1.23.1: neunte Installation „Hüfner Gartenbau“, Knöpfe mit tel: und mailto:, Pfeil in der Schriftfarbe (06.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Hier liegt ein Designentwurf für Hüfner Gartenbau. Kannst du mir db und media erstellen für KantSites bitte. […] Effekte rein. Nichts am Code ändern, sondern alles an KantSites-Designelementen anpassen/bauen. Falls was nicht geht, Rückmeldung.“ Vorlage: `Downloads/huefner-website/huefner-website` (statisches `index.html`, 16 Fotos, Logo).
|
||||
|
||||
**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/huefner.db`, Medien `var/huefner-media`, Bildquellen `var/huefner-assets`, Startkonfigurationen `huefner` (Port 8102, dev) und `huefner-prod` (Port 8103). `sh scripts/reset-huefner.sh <Ordner des Entwurfs>` kopiert die Bilder und schneidet den Baum aus dem Logo als Seitensymbol (`build-huefner-assets.php`), baut mit `seed-huefner.php` alles neu und holt Barlow und Barlow Condensed auf den eigenen Server. Ohne Argument nimmt es die schon vorbereiteten Bilder.
|
||||
|
||||
- **Startseite** mit allen elf Abschnitten des Entwurfs: Hero mit Verlauf und Vorteilsband, Collage, Leistungs- und Referenzkacheln (Foto zoomt, Kachel bekommt Schatten), Chips, Ablauf mit gestrichelter Linie, Fuhrpark, Über uns, Karriere (Stellen als Links mit E-Mail-Betreff), Instagram-Raster, Abschlussband, Kontakt mit dem CMS-Formular `anfrage` (Versand an info@huefner-gartenbau.de). Dazu Impressum, Datenschutz, 404, Menü mit Sprungmarken, klebender Kopf, Einblendungen beim Scrollen.
|
||||
- **Inhaltliche Ergänzungen** statt der Platzhalter der Vorlage: Öffnungszeiten Mo–Fr 8–17, Sa 8–12 (von Umut, auch als strukturierte Daten), Firmengeschichte und Impressum aus dem Handelsregister (HRB 742159, AG Mannheim, GF Florian Hüfner; Simone Hüfner war nur bis 27.04.2026 Prokuristin), Hoster IONOS, alle Fotos eigene Aufnahmen. Kennzahlen „[XX] Jahre“/„[XXX] Projekte“ ersetzt durch Aussagen der Vorlage (100 % Detailarbeit, Bagger bis 15 t, ein Team). Keine USt-IdNr. bekannt, deshalb keine Zeile.
|
||||
|
||||
**Grenzen des Kerns:**
|
||||
|
||||
- Nachtrag, gleicher Tag – auf Umuts Wunsch im Kern behoben (ADR 107): Knöpfe und andere verlinkbare Bausteine nehmen `tel:` und `mailto:` an, und der Pfeil im Knopf hat die Schriftfarbe statt der Akzentfarbe. Die Anrufknöpfe bei Hüfner sind jetzt echte Knöpfe, der Pfeil braucht keine eigene Regel mehr. Vorher: Textlink im Knopf-Aussehen und `color:inherit` im CSS des Knopfs.
|
||||
- Kein LKW im Symbolsatz: Das Symbol steht als „Eigenes HTML“ mit dem SVG der Vorlage.
|
||||
- Leiste und Kopf kleben gemeinsam (die Einstellung gilt für den ganzen Kopfbereich).
|
||||
- Zeilen sind durchsichtig, farbige Karten deshalb Spalten mit Flex.
|
||||
- Eigenes CSS ohne `@media`/`*`: gerätespezifische Werte über die Gestaltungswerte (Bildhöhe, Raster-Spalten, Ränder je Gerät).
|
||||
- Das HTML-Budget von 150 KB (`test:browser`) war mit dem CSS je Kachel überschritten (152 KB). Die gemeinsamen Regeln stehen jetzt einmal am Abschnitt, Ergebnis 147,5 KB.
|
||||
|
||||
**Geprüft:** Lighthouse im Produktionsmodus (Port 8103, je drei Läufe, Median): Startseite mobil 97 / Desktop 98, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. `test:links` ohne toten Verweis, `test:browser` grün (axe in sechs Ansichten, Tastatur, ohne JavaScript, Budget). Ganzseitige Bildschirmfotos bei 1440, 820 und 375 px gegen den Entwurf verglichen, kein waagerechtes Scrollen, keine Konsolenfehler. Zoom, Schatten, Chip- und Knopf-Hover sowie das Klappmenü per Playwright nachgemessen. Keine Felddaten, kein INP. Das Formular wurde nicht abgeschickt (Mailversand nicht ausprobiert).
|
||||
|
||||
**Nächster Schritt:** Livegang nach `docs/LIVEGANG.md` (Mailversand einmal echt testen). Die Hüfner-Datenbank braucht einen Kern mit ADR 107 (`tel:` an Knöpfen).
|
||||
|
||||
## Kantarci Markt: Demo der Eröffnungsseite mit Angeboten aus der App (24.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Setze mal die demoseite um wie du es dir vorstellst ich überlasse dir alles. Links und logo zu playstore und ios app, wilhelm drapp str. in baden baden, Rest Platzhalter“. Die Angebote kommen aus der Web-Ansicht seiner App: `https://app.kantarci-markt.de/embed/angebote.php?ton=dunkel`, dazu sein Skript für die Höhe.
|
||||
|
||||
**Neu (nur Inhalt, kein Kern):** `/eroeffnung` im Seed-Skript, veröffentlicht, aber `noindex` und nirgends verlinkt. Aufbau: Einstieg „Jetzt geöffnet“ mit zwei Wegen (Angebote, Anfahrt) und Öffnungszeiten, Angebote als eingebettete App-Ansicht, App-Bereich mit Knöpfen für App Store und Google Play, das Versprechen, Besuch (Adresse mit „Route planen“, Öffnungszeiten, Kontakt), Abschluss. Zur Eröffnung soll dieser Inhalt als geplanter Entwurf auf die Startseite, `/eroeffnung` fällt dann weg.
|
||||
|
||||
- **Einwilligung:** keine nötig. `Consent::granted()` lässt jede Gruppe durch, solange die Website keine Einwilligungsleiste hat (bei Kantarci Markt aus). Meine frühere Aussage, der Baustein verlange immer eine Einwilligung, war falsch. Eine Option „Eigener Dienst“ braucht es erst, wenn die Seite einmal eine Leiste bekommt.
|
||||
- **Höhe:** Umuts Skript hängt als eigenes Skript am Baustein. Es lief zu spät: Die App meldet ihre Höhe beim Laden (gemessen 332 ms), das Skript lief als `defer`-Datei erst kurz vor DOMContentLoaded (402 ms), danach meldet die App nur noch Änderungen. Jetzt fragt das Skript nach dem Start per `km-angebote-height-request` nach und lädt den Rahmen als Rückfall nach 600 ms einmal neu, falls keine Meldung kam. Die App kennt die Nachfrage noch nicht, deshalb greift derzeit der Rückfall (zweiter Abruf der Web-Ansicht).
|
||||
- **Hintergrund:** Die Web-Ansicht ist für hellen Grund gebaut (dunkle Überschrift, weiße Karten), `ton=dunkel` und `ton=hell` liefern dieselbe Seite. Der Angebotsbereich steht deshalb auf Sand statt auf Waldgrün. `& iframe{border-radius:0;background:transparent}` am Baustein nimmt Rundung und Tönung aus `site.css`.
|
||||
- **Platzhalter:** Öffnungszeiten (Mo–Sa 8–20 Uhr, So geschlossen), „Telefon folgt“, Store-Links auf die Suche nach „Kantarci Markt“ im jeweiligen Store, Store-Knöpfe als eigene schwarze Knöpfe ohne die Logos von Apple und Google. Alles oben im Abschnitt des Seed-Skripts.
|
||||
- **`scripts/audit.mjs`** kennt `CMS_HOST_RULES` (etwa `MAP localhost 127.0.0.1`): Die App erlaubt das Einbetten nur von `kantarci-markt.de` und `http://localhost:*`, der Server lauscht auf 127.0.0.1.
|
||||
|
||||
**Geprüft:** Lighthouse im Produktionsmodus über `localhost:8101`, Rahmen geladen: `/eroeffnung` mobil 96 / Desktop 100, Barrierefreiheit, Best Practices und agentisches Browsing 100. SEO 69 nur wegen des gewollten `noindex` (`is-crawlable`), keine Konsolenfehler. Die Startseite blieb bei 98 / 100 und überall sonst 100. Im Browser: Rahmen 448 px (Desktop) bzw. 971 px (375 px) hoch, kein Scrollbalken, kein waagerechtes Scrollen. Sicherheitsrichtlinie der Seite: `frame-src https://app.kantarci-markt.de`, `script-src 'self'`.
|
||||
|
||||
**Nachtrag, gleicher Tag – Popup statt Link (App-Repository `C:\Django Project\Supermarkt`):** Umut: Angebote ohne Bild wirkten durchsichtig, „Unsere Angebote“ war weiß, und statt eines Links soll ein Popup die Einzelheiten hier zeigen. Die App-Ansicht hatte inzwischen `ton`, `titel`, `app`, `bg` bekommen; `ton=dunkel` machte die Überschrift weiß. Eingebunden ist sie jetzt mit `?titel=0&app=0` (eigene Überschrift der Seite, kein Knopf nach außen). In der App (`public/embed/angebote.php`, noch nicht committet, nicht ausgeliefert): Karten sind `<article>` mit einem Knopf im Namen statt `<a>`, ein natives `<dialog>` zeigt Bild, Kategorie, Name, Gültigkeit, Beschreibung und Preise auf Höhe der Karte (das iframe ist so hoch wie sein Inhalt) und meldet per `km-angebote-focus`, wo es steht. Die Ansicht beantwortet `km-angebote-height-request`, Angebote ohne Bild haben zartes Grün statt Creme, und ohne Datenbank gibt es die leere Ansicht statt eines Fatal Errors (`count(null)`). `embed/angebote.js` und das Skript im Seed kennen Nachfrage und Hinscrollen. Geprüft mit der lokalen App-Fassung (Anfragen im Browser umgeleitet, Beispieldaten im lokalen Cache): keine Links mehr, Popup im sichtbaren Bereich (auch nach Scrollen), Fokus auf „Schließen“ und zurück zur Karte, Esc und Klick auf den Rand schließen, axe ohne Befund, nur ein Abruf der Ansicht (kein Rückfall-Neuladen). Live wirkt es erst nach Push und App-Update auf dem Server.
|
||||
|
||||
**Offen, von Umut:** echte Store-Adressen (und auf Wunsch die offiziellen Badges), Öffnungszeiten, Telefon, Eröffnungsdatum, Impressumsangaben. App-Änderungen committen, pushen und auf dem Server aktualisieren.
|
||||
|
||||
## Fassung 1.23.0: achte Installation „Kantarci Markt – Neueröffnung“, content im eigenen CSS, Schriften ergänzt statt überschrieben (23.09.2026)
|
||||
|
||||
**Anlass:** Umut: „erstellst du bitte bald verfügbar site nach dem prinzip kannst gerne dazu implementieren“ – dazu das Paket `Kantarci-Markt-Landingpage.zip` (statische Seite: Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit Marktfoto, Tomatenkarte und Stempel, Versprechen auf Waldgrün, Abschluss „Frische. Qualität. Faire Preise.“, Fuß).
|
||||
|
||||
**Neu.** Datenbank `var/kantarci-markt.db`, Medien `var/kantarci-markt-media`, Bildquellen `var/kantarci-markt-assets`, Startkonfigurationen `kantarci-markt` (Port 8100, dev) und `kantarci-markt-prod` (Port 8101). `sh scripts/reset-kantarci-markt.sh` baut alles neu: `build-kantarci-markt-assets.mjs` (Logo zugeschnitten auf 600 px, Seitensymbol und Stempel als PNG über Chromium), `seed-kantarci-markt.php` (Startseite, Impressum, Datenschutz, 404, Kopf, Fuß, Einstellungen), dann DM Sans auf den eigenen Server. Alles aus gewöhnlichen Bausteinen, kein Plugin.
|
||||
|
||||
- **Abweichungen:** Manrope entfällt (DM Sans an seiner Stelle, eine Schrift weniger). Die Bildzeile „Vorfreude auf frische Vielfalt.“ steht höher als in der Vorlage, weil Stempel und Tomatenkarte sie dort verdeckten. Das große „K“ im Abschluss ist ein Pseudo-Element, kein Text. Keine LocalBusiness-Daten, solange der Markt nicht geöffnet hat.
|
||||
- **Impressum und Datenschutz** sind Pflicht und angelegt. Offen, im Text als „NOCH ZU PRÜFEN“: Betreiber/Rechtsform, Hausnummer, Vertretung, Register, Telefon, USt-IdNr., ob `info@kantarci-markt.de` existiert (die Domain hat Mailserver), Quelle der beiden Fotos, Hosting.
|
||||
|
||||
**Im Kern dazu gebaut:**
|
||||
|
||||
- **Eigenes CSS: `content` mit kurzem Text** (`CustomCode`), damit `::before`/`::after` möglich sind. Eng begrenzt, siehe Nachtrag zu ADR 028.
|
||||
- **`fonts.css` wird ergänzt statt überschrieben** (`FontLibrary::combine`): Ein Abruf ersetzt nur die Regeln der geholten Familien. Vorher hätte das Holen von DM Sans Lato und Cormorant der anderen lokalen Installationen aus der gemeinsamen Datei gelöscht.
|
||||
- **Variable Schriften einmal statt je Schnitt:** DM Sans liefert Google als eine Datei für alle Schnitte. Der Kern verlinkte und lud sie dreimal vor (111 KB statt 37 KB), das kostete am Telefon den LCP. Jetzt eine `@font-face` mit Gewichtsbereich `400 700`, und `preload()` nennt nur Dateien, die `fonts.css` tatsächlich verlinkt.
|
||||
- Bekannt, nicht geändert: Zeilen sind im Kern immer durchsichtig (`.block-row{background:transparent!important}`). Die grüne Karte „Wir öffnen bald“ ist deshalb eine Spalte.
|
||||
|
||||
**Geprüft:** Lighthouse im Produktionsmodus (Port 8101, je drei Läufe, Median): Startseite mobil 97 / Desktop 100, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. Vorher 87 mobil wegen CLS 0,19 (Logo im Kopf bekam seine Breite erst nach dem Laden) und der dreifachen Schrift. Bildschirmfotos 1440 px und 375 px ohne waagerechtes Scrollen und ohne Konsolenfehler, Impressum und 404 angesehen. Neue Tests in `CmsTest` (`content` erlaubt/abgewiesen, `combine`, Preload bei variabler Schrift); volle PHPUnit-Suite (596) und `npm test` (36) grün. Keine Felddaten, kein INP.
|
||||
|
||||
**Nächster Schritt:** Die offenen Angaben im Impressum von Umut bekommen; auf Wunsch ein Formular „Zur Eröffnung benachrichtigen“ (braucht dann Einwilligungstext und Datenschutzabsatz).
|
||||
|
||||
## Fassung 1.22.5: alte Browserchecks wieder grün, Kopfbereich von kant-it.de wieder bearbeitbar (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „räum den Strukturbaum-Fehler auch auf und Release 1.22.5“.
|
||||
|
||||
- **Strukturbaum:** `test:admin`, `test:buttons` und `test:navigation-ux` klickten direkt in den Strukturbaum. Seit 1.11 zeigt die Seitenleiste aber eine Ansicht auf einmal, Standard „Elemente“, und der Baum war versteckt. Jetzt wählen die Checks den Reiter „Struktur“, dann den Baustein und „Bearbeiten“, wie `editor-check.mjs`. Der Editor selbst war richtig.
|
||||
- **Echter Fehler, dabei gefunden:** Der Kopfbereich in `var/kantit.db` (und damit vermutlich auf kant-it.de) trägt die Hamburger-Umschaltung (`collapse`) bei Schema v19, erlaubt ist sie erst ab v22. Die Website zeigt ihn, aber der Editor bekam für die Vorschau „Unbekannte Hamburger-Umschaltung oder Dokumentschema vor v22“, und Speichern wäre ebenso abgelehnt worden: Der Bereich war nicht bearbeitbar. Jetzt hebt `repairSchema()` (editor-model.js) ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen. Das ist dieselbe Hebung, die der Editor beim Einstellen ohnehin macht. Die Umstellung auf formatierten Text (v12/v17) kommt nie von selbst, weil sie die Bedeutung ändert. Beim nächsten Speichern ist das Dokument korrigiert. Alle anderen lokalen Installationen hatten keine solchen Dokumente (geprüft mit dem Validator über alle Seiten und Bereiche).
|
||||
- **Weitere veraltete Stellen in `test:admin`:** Der Knopf „Bewegung testen“ heißt seit 1.11 „Bewegung in der Vorschau abspielen“ (`#preview-motion`), auch ein Hilfetext im Editor nannte noch den alten Namen. Der Slider-Teil erwartete, dass „Bewegung reduzieren“ die Automatik anhält. Seit 1.11 gilt das nur mit „Systemvorgabe beachten“. Der Check prüft jetzt das Verhalten, das die Einstellung der Website verspricht.
|
||||
|
||||
**Geprüft:** `test:admin` (vollständig, auch der öffentliche Teil), `test:buttons`, `test:navigation-ux` und `test:editor` grün. Neuer Einheitstest für `repairSchema` in `tests/editor-model.test.mjs`.
|
||||
|
||||
## Fassung 1.22.4: Ziehknopf im Menü neben der Zusammenfassung (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „ja räum das auf und Release 1.22.4“ – die bekannte axe-Meldung `nested-interactive` im Menü-Editor.
|
||||
|
||||
- Der Ziehknopf eines Menüeintrags stand in der `<summary>`, also ein Knopf in einem Knopf. Screenreader lesen so etwas unzuverlässig vor, und die Tastatur erreicht es nicht sauber. Jetzt steht er als Geschwister vor `<details>`, per CSS über die linke Ecke der Zusammenfassung gelegt (`menu-entry.html.twig`, `createRow` in `menu-editor.js`). Aussehen und Bedienung bleiben gleich: Ziehen mit Maus, Stift und Finger, Pfeiltasten auf dem Knopf.
|
||||
- Der Hinweis zu fehlenden Sprungzielen im Menü und die Hilfe zum Feld „Sprungziel“ im Editor nennen jetzt die richtige Stelle (Sektion, Zeile oder Spalte) und den Weg über die Auswahl im Menü.
|
||||
- `test:admin`: Die Fehlermeldung nach einem abgewiesenen Speichern wurde direkt nach der Serverantwort abgefragt, sie erscheint aber erst, nachdem die Antwort gelesen ist. Jetzt wartet der Test auf sie. Der Check scheitert weiterhin an der älteren Stelle beim Klick in den Strukturbaum des Editors.
|
||||
|
||||
**Geprüft:** Die Editor-Prüfung prüft die ganze Menüseite mit axe, ohne Ausnahme. Sie stellt sicher, dass in keiner Zusammenfassung ein Knopf steht, und verschiebt einen Eintrag per Pfeiltaste (Fokus bleibt auf dem Knopf). Bildschirmfoto `reports/editor/menu-editor.png` angesehen.
|
||||
|
||||
## Fassung 1.22.3: die übrigen abgetippten Adressen (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „die offenen Punkte auch umsetzen und Release 1.22.3“ – die drei Reste aus 1.22.2.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261009000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Mehrsprachigkeit: die öffentlichen Adressen der übersetzten Seiten (ADR 111).';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Die Texte einer Übersetzung stehen im Dokument der Seite und laufen so mit Entwurf,
|
||||
// Veröffentlichung und Fassungen mit. Hier steht nur, was die Website zum Auffinden
|
||||
// braucht: unter welcher Adresse eine Seite in welcher Sprache veröffentlicht ist. Die
|
||||
// Zeilen entstehen beim Veröffentlichen und spiegeln genau den öffentlichen Stand.
|
||||
// Eine neue Tabelle, keine Änderung an `page`: auf SQLite baut jede Indexänderung an einer
|
||||
// bestehenden Tabelle sie neu auf (siehe ADR 010).
|
||||
$table = $schema->createTable('page_translation');
|
||||
$table->addColumn('page_id', 'string', ['length' => 32]);
|
||||
$table->addColumn('website_id', 'string', ['length' => 32]);
|
||||
$table->addColumn('locale', 'string', ['length' => 12]);
|
||||
$table->addColumn('slug', 'string', ['length' => 80]);
|
||||
$table->addColumn('path', 'string', ['length' => 400]);
|
||||
$table->setPrimaryKey(['page_id', 'locale']);
|
||||
$table->addUniqueIndex(['website_id', 'locale', 'path'], 'uniq_page_translation_path');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Die Übersetzungen selbst bleiben in den Dokumenten; ohne diese Tabelle sind sie nur
|
||||
// nicht mehr öffentlich erreichbar.
|
||||
$schema->dropTable('page_translation');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
"scripts": {
|
||||
"build:buttons": "node scripts/build-buttons.mjs",
|
||||
"test:buttons": "node scripts/button-check.mjs",
|
||||
"test:button-icons": "node scripts/button-icon-check.mjs",
|
||||
"test:menu-buttons": "node scripts/menu-button-check.mjs",
|
||||
"build:icons": "node scripts/build-icons.mjs",
|
||||
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
|
||||
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
|
||||
"build:design": "node scripts/build-design.mjs",
|
||||
|
||||
+59
-8
@@ -70,8 +70,13 @@ textarea { resize: vertical; min-height: 110px; line-height: 1.6 }
|
||||
label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.field-help { font-size: 11px; line-height: 1.55; color: var(--muted); margin: 7px 0 0 }
|
||||
.field-help code, .card code { background: #eef2ea; padding: 1px 5px; border-radius: 4px; font-size: .95em }
|
||||
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500 }
|
||||
/* Nur Häkchen und Text sind klickbar: über die volle Breite schaltete ein Klick in die leere Mitte
|
||||
der Zeile das Häkchen um - in der Menüverwaltung klappten so die Button-Einstellungen zu. */
|
||||
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500; width: fit-content; max-width: 100% }
|
||||
.check-label input { width: 16px; min-height: 16px }
|
||||
/* Eine Gruppe von Häkchen nebeneinander, die auf schmalen Bildschirmen umbricht (Sprachen). */
|
||||
.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; border: 0; padding: 0; margin: 0 0 12px; min-width: 0 }
|
||||
.check-grid legend { font-weight: 600; margin-bottom: 8px; padding: 0 }
|
||||
/* A grid of fields that becomes one column when there is no room. */
|
||||
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px }
|
||||
.fields > div > label:first-child, .field > label:first-child { margin-top: 14px }
|
||||
@@ -862,12 +867,42 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
|
||||
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
|
||||
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
|
||||
.field-card { position: relative; padding: 2px 16px 16px; border: 1px solid var(--line); border-radius: 11px; background: #fafbf8 }
|
||||
.field-card { position: relative; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8 }
|
||||
.field-card.is-open { padding-bottom: 14px; background: #fff; box-shadow: 0 1px 6px #203b3414 }
|
||||
.field-card-new { border-style: dashed; background: var(--paper) }
|
||||
.field-card-head { display: grid; grid-template-columns: 26px minmax(0, 1fr) 190px 38px; gap: 0 12px; align-items: end }
|
||||
.field-card-index { display: grid; place-items: center; width: 26px; height: 26px; margin-bottom: 8px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card-head { display: grid; grid-template-columns: 24px minmax(0, 1fr) 170px auto auto 34px; gap: 0 10px; align-items: center }
|
||||
.field-card-head input, .field-card-head select { min-height: 36px }
|
||||
.field-card-index { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card label { margin-top: 12px }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 38px }
|
||||
.field-card-head .field-card-required { margin: 0; font-size: 13px; white-space: nowrap }
|
||||
.field-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 34px; padding: 0 8px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--accent); font-size: 12px; font-weight: 650; white-space: nowrap }
|
||||
.field-toggle:hover { background: #eef3ec }
|
||||
.field-toggle .form-chevron { transition: transform .15s }
|
||||
.field-card.is-open .field-toggle .form-chevron { transform: rotate(180deg) }
|
||||
.field-card-meta { margin: 4px 0 0 34px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 34px }
|
||||
.field-card-body[hidden] { display: none }
|
||||
.field-translation { grid-column: 1 / -1; margin-top: 12px; padding-top: 8px; border-top: 1px dashed var(--line) }
|
||||
.field-translation summary { cursor: pointer; font-size: 13px; font-weight: 650 }
|
||||
/* Ein Formular je zuklappbarer Karte, Abschnitte darin ebenfalls zuklappbar. */
|
||||
.form-card > .form-card-summary { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; list-style: none }
|
||||
details.form-card > summary.form-card-summary::before, details.form-card[open] > summary.form-card-summary::before { content: none }
|
||||
.form-card > .form-card-summary::-webkit-details-marker, .form-section > summary::-webkit-details-marker { display: none }
|
||||
.form-card-name { font-size: 16px; font-weight: 700 }
|
||||
.form-card > .form-card-summary { cursor: default; border-bottom: 1px solid var(--line) }
|
||||
.form-list-name { margin: 0; font-size: 15px; font-weight: 650 }
|
||||
.form-list-name a { text-decoration: none }
|
||||
.form-list-name a:hover { text-decoration: underline }
|
||||
.form-list .row { align-items: center }
|
||||
.back-to-list { display: inline-block; margin-top: 4px }
|
||||
.form-card-facts { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.form-chevron { flex: none; color: #52665a; transition: transform .15s }
|
||||
.form-card[open] > .form-card-summary { border-bottom: 1px solid var(--line) }
|
||||
.form-card[open] > .form-card-summary > .form-chevron, .form-section[open] > summary > .form-chevron { transform: rotate(180deg) }
|
||||
.form-section { border-bottom: 1px solid var(--line); padding-bottom: 12px }
|
||||
.form-section > summary { display: flex; align-items: center; gap: 10px; padding: 14px 0 2px; cursor: pointer; list-style: none }
|
||||
.form-section > summary .form-part { margin: 0 }
|
||||
.form-section-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.field-card-body .field-help { margin-top: 5px }
|
||||
.field-card-flags { align-self: end; padding-bottom: 11px }
|
||||
.field-card-flags .check-label { margin: 12px 0 0 }
|
||||
@@ -914,11 +949,16 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.form-look .color-control { margin-top: 0 }
|
||||
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
|
||||
@media (max-width: 700px) {
|
||||
.field-card { padding-top: 8px }
|
||||
.field-card-head { grid-template-columns: 26px minmax(0, 1fr) }
|
||||
.field-card-title, .field-card-type { grid-column: 2 }
|
||||
.field-card-head { grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px }
|
||||
.field-card-title { grid-column: 2 / 4; padding-right: 36px }
|
||||
.field-card-type { grid-column: 2 }
|
||||
.field-card-head .field-card-required { grid-column: 3 }
|
||||
.field-toggle { grid-column: 2 }
|
||||
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
|
||||
.field-card-meta { margin-left: 0 }
|
||||
.field-card-body { padding-left: 0 }
|
||||
.form-card-facts { white-space: normal }
|
||||
.form-card > .form-card-summary { flex-wrap: wrap; padding: 14px 16px }
|
||||
}
|
||||
|
||||
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
|
||||
@@ -998,3 +1038,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer }
|
||||
.term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 }
|
||||
.term-chip[aria-pressed="true"]::before { content: '✓ '; }
|
||||
/* Der Ziehknopf eines Menüeintrags liegt über der linken Ecke der Zusammenfassung, gehört aber
|
||||
nicht zu ihr - sonst stünde ein Knopf in einem Knopf. */
|
||||
.menu-entry { position: relative }
|
||||
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
|
||||
.menu-entry summary { padding-left: 58px; min-height: 60px }
|
||||
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
|
||||
/* Texte der Website in weiteren Sprachen (Einstellungen → Sprachen, ADR 111). */
|
||||
.language-texts { margin-top: 24px; padding-top: 16px; border-top: 1px solid #dfe5e1 }
|
||||
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
|
||||
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
|
||||
.language-text-group legend { font-weight: 600; padding: 0 4px }
|
||||
@@ -1 +1 @@
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pLine truncated
|
||||
@@ -20,6 +20,7 @@ export const GROUPS = {
|
||||
effect: 'Wirkung',
|
||||
menu: 'Menü-Knopf',
|
||||
slider: 'Slider-Steuerung',
|
||||
icon: 'Symbol',
|
||||
};
|
||||
|
||||
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
|
||||
@@ -520,9 +521,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
if (properties.length === 0) continue;
|
||||
const details = element('details', 'design-group');
|
||||
const groupKey = `${block.type}:${group}`;
|
||||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing');
|
||||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing' || group === 'icon');
|
||||
renderedGroups.set(groupKey, details);
|
||||
details.append(element('summary', null, groupLabel));
|
||||
details.append(element('summary', null, groupLabel)); details.dataset.group = group;
|
||||
const body = element('div', 'design-group-body');
|
||||
|
||||
for (const [key, property] of properties) {
|
||||
|
||||
@@ -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', 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: '</>' };
|
||||
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', language: 'Sprachwahl' };
|
||||
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: '</>', language: '文' };
|
||||
// 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', 'html'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
];
|
||||
|
||||
/** How many columns a row can be added with. */
|
||||
@@ -128,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' };
|
||||
// Welche Sprachen es gibt, weiß die Website; der Block sagt nur, ob er Kürzel oder Namen zeigt.
|
||||
if (type === 'language') block.props = { display: 'code' };
|
||||
// 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: '' };
|
||||
@@ -152,6 +154,38 @@ export function createBlock(type) {
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hebt ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen.
|
||||
*
|
||||
* Normalerweise tut das der Editor in dem Moment, in dem jemand etwas einstellt. Ein Dokument,
|
||||
* das ein Skript geschrieben hat, kann aber eine Eigenschaft tragen, die seine Fassung noch nicht
|
||||
* kennt - der Kopfbereich von kant-it.de hatte die Hamburger-Umschaltung bei Fassung 19. Die
|
||||
* Website zeigt so etwas, der Server lehnt Vorschau und Speichern aber ab, und der Bereich war im
|
||||
* Editor nicht mehr zu bearbeiten. Nur Fassungen, die etwas hinzufügen, werden hier gehoben; die
|
||||
* Umstellung auf formatierten Text (12, 17) ändert die Bedeutung und bleibt eine bewusste Wahl.
|
||||
*/
|
||||
export function repairSchema(document) {
|
||||
const walk = blocks => {
|
||||
for (const block of blocks || []) {
|
||||
upgradeForType(document, block.type);
|
||||
const props = block.props || {};
|
||||
if ('caption' in props) upgradeDocument(document, 3);
|
||||
if ('click' in props || 'lightbox' in props) upgradeDocument(document, 19);
|
||||
if ('newTab' in props) upgradeDocument(document, 20);
|
||||
if (Object.values(block.style || {}).some(values => values?.backgroundImage === 'none')) upgradeDocument(document, 20);
|
||||
if (block.anchor) upgradeDocument(document, 21);
|
||||
if ('collapse' in props) upgradeDocument(document, 22);
|
||||
if ('faq' in props) upgradeDocument(document, 23);
|
||||
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
|
||||
if (block.type === 'button' && ('iconMotion' in props || 'iconOnly' in props)) upgradeDocument(document, 26);
|
||||
walk(block.children);
|
||||
}
|
||||
};
|
||||
walk(document.blocks);
|
||||
if (document.translations) upgradeDocument(document, 27);
|
||||
return document;
|
||||
}
|
||||
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
@@ -163,6 +197,7 @@ export function upgradeForType(document, type) {
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (type === 'html') upgradeDocument(document, 24);
|
||||
if (type === 'language') upgradeDocument(document, 27);
|
||||
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);
|
||||
@@ -313,7 +348,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 = 24;
|
||||
export const SCHEMA_VERSION = 27;
|
||||
|
||||
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' };
|
||||
|
||||
@@ -655,6 +690,7 @@ export function relevantProperty(block, key, property) {
|
||||
const container = isContainer(block);
|
||||
if (property.group === 'slider') return block.type === 'slider';
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (key === 'columnWidths') return block.type === 'row';
|
||||
|
||||
@@ -285,3 +285,35 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)}
|
||||
.library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
}
|
||||
/* Übersetzen (ADR 111). Der Umschalter sitzt neben der Vorschaugröße; im Übersetzungsmodus
|
||||
verschwindet alles, was den Aufbau ändert - Reiter, Blockaktionen, Werkzeuge der Vorschau. */
|
||||
.language-switch{display:flex;gap:2px;padding:3px;background:#ffffff14;border-radius:9px}
|
||||
.language-switch[hidden]{display:none}
|
||||
.language-switch button{border:0;background:transparent;color:#c9d4cd;padding:0 10px;min-height:30px;min-width:36px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.04em}
|
||||
.language-switch button:hover:not(:disabled){color:#fff}
|
||||
.editor-header .language-switch button[aria-pressed=true]{background:#fff;color:var(--ink)}
|
||||
#editor[data-language] .language-switch button[aria-pressed=true]{background:#f5c451;color:#1d1d1b}
|
||||
#editor[data-language] .sidebar-nav,#editor[data-language] .sidebar-foot,#editor[data-language] .ov-toolbar,#editor[data-language] .ov-insert,#editor[data-language] .ov-resize,#editor[data-language] .ov-drop{display:none!important}
|
||||
.translate-head{display:grid;gap:8px;margin-bottom:16px}
|
||||
.translate-head h2{margin:0;font-size:16px}
|
||||
.translate-head h2:focus{outline:none}
|
||||
.translate-head progress{width:100%;height:8px;accent-color:var(--ink)}
|
||||
.translate-summary{margin:0;font-size:13px;font-weight:600}
|
||||
.translate-group{border-top:1px solid var(--line,#dfe5e1);padding:12px 0 4px;scroll-margin-top:12px}
|
||||
.translate-group.is-current{box-shadow:inset 3px 0 0 #f5c451;padding-left:10px}
|
||||
.translate-group h3{margin:0 0 8px;font-size:13px}
|
||||
.translate-jump{border:0;background:none;padding:0;font:inherit;font-weight:700;color:inherit;text-align:left;cursor:pointer}
|
||||
.translate-jump:hover{text-decoration:underline}
|
||||
.translate-field{display:grid;gap:5px;margin-bottom:14px}
|
||||
.translate-field-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
|
||||
.translate-field-head label{margin:0}
|
||||
.translate-state{font-size:11px;font-weight:700;padding:2px 7px;border-radius:999px;background:#e8ece9;color:#384640;white-space:nowrap}
|
||||
.translate-field[data-state=missing] .translate-state{background:#fde9c8;color:#6b3f00}
|
||||
.translate-field[data-state=stale] .translate-state{background:#fbd5d0;color:#7a1d12}
|
||||
.translate-field[data-state=done] .translate-state{background:#d7eedf;color:#1c5233}
|
||||
.translate-original{margin:0;padding:6px 8px;border-radius:6px;background:#f2f4f3;color:#46514c;font-size:12.5px;line-height:1.45;white-space:pre-line;max-height:7.5em;overflow:auto}
|
||||
.translate-actions{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.translate-slug{display:flex;align-items:center;gap:4px}
|
||||
.translate-slug input{flex:1;min-width:0}
|
||||
.translate-prefix{font-size:12px;color:#5b6862;white-space:nowrap}
|
||||
.translate-filter{margin-top:4px}
|
||||
+324
-25
@@ -9,10 +9,11 @@ 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, registerPlugins, pluginDefinition, PLUGIN_PRESETS } from './editor-model.js';
|
||||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } 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'];
|
||||
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
|
||||
import { renderIconField } from './icons.js';
|
||||
import { entries as translationEntries, setText, confirmText, setPageValue, slugify, plainText } from './translate.js';
|
||||
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||||
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
||||
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
||||
@@ -32,6 +33,9 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d
|
||||
// same factor, so it is kept here instead of being recomputed from the transform string.
|
||||
let previewScale = 1, overlay = null, pendingInsert = null;
|
||||
const collapsed = new Set();
|
||||
// Übersetzen (ADR 111): welche Sprachen es gibt, und welche gerade bearbeitet wird. null ist die
|
||||
// Hauptsprache - dann ist der Editor genau der von vorher.
|
||||
let languageInfo = null, language = null, translateFilter = 'all';
|
||||
let renderedProperties = null;
|
||||
let revealAfterPreview = null;
|
||||
let previewPlayback = false, stopPlayback = () => {};
|
||||
@@ -119,7 +123,7 @@ function controls() {
|
||||
$('#undo').disabled = !ready || busy || !history.past.length;
|
||||
$('#redo').disabled = !ready || busy || !history.future.length;
|
||||
const current = ready ? locate(history.document.blocks, selected) : null;
|
||||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy;
|
||||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy || Boolean(language);
|
||||
if (current) {
|
||||
$('#move-up').disabled ||= current.index === 0;
|
||||
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||||
@@ -142,6 +146,8 @@ function controls() {
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
$('#preview-motion').disabled = !ready || busy;
|
||||
$('#language-switch').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#translate-panel').querySelectorAll('input,textarea,button').forEach(input => { input.disabled = busy; });
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
@@ -203,6 +209,7 @@ function choose(id, scroll = true) {
|
||||
history.mergeKey = null;
|
||||
syncView();
|
||||
renderTree(); renderProperties(); controls(); highlight(scroll);
|
||||
if (language && selected) focusTranslation(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +217,7 @@ function choose(id, scroll = true) {
|
||||
* the exception: there the selection only moves the highlight, editing is one more step.
|
||||
*/
|
||||
function syncView() {
|
||||
if (language) return;
|
||||
if (selected && view !== 'structure') setView('element');
|
||||
else if (!selected && view === 'element') setView('elements');
|
||||
}
|
||||
@@ -319,6 +327,7 @@ function renderContent(block, panel) {
|
||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||
if (key === 'collapse') upgradeDocument(doc, 22);
|
||||
if (key === 'faq') upgradeDocument(doc, 23);
|
||||
if (key === 'iconOnly') upgradeDocument(doc, 26);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||
@@ -346,6 +355,37 @@ function renderContent(block, panel) {
|
||||
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
}
|
||||
};
|
||||
// Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
|
||||
// Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
|
||||
const choose = (key, next, version, focus) => {
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, selected)?.block;
|
||||
if (!current) return;
|
||||
(current.props ??= {})[key] = next;
|
||||
// Ohne Symbol gibt es keinen Button nur aus Symbol - sonst stünde ein leerer Knopf da.
|
||||
if (key === 'icon' && next === 'none') delete current.props.iconOnly;
|
||||
if (version) upgradeDocument(doc, version);
|
||||
}, null);
|
||||
renderProperties(); renderTree(); controls(); schedulePreview();
|
||||
document.querySelector(focus)?.focus({ preventScroll: true });
|
||||
};
|
||||
const iconField = (label, value, options = {}) => panel.append(renderIconField({
|
||||
label, value, none: options.none, onChoose: name => choose('icon', name, options.version, '.icon-field-change'),
|
||||
}));
|
||||
// Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste.
|
||||
const segmented = (label, key, current, options, version) => {
|
||||
const wrap = document.createElement('div'); wrap.className = 'icon-setting';
|
||||
const caption = document.createElement('p'); caption.className = 'icon-picker-label'; caption.id = `segmented-${key}`; caption.textContent = label;
|
||||
const group = document.createElement('div'); group.className = 'segmented'; group.setAttribute('role', 'group'); group.setAttribute('aria-labelledby', caption.id);
|
||||
group.style.gridTemplateColumns = `repeat(${options.length}, 1fr)`;
|
||||
for (const [value, text] of options) {
|
||||
const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text;
|
||||
option.setAttribute('aria-pressed', String(value === current));
|
||||
option.addEventListener('click', () => { if (value !== current) choose(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
|
||||
group.append(option);
|
||||
}
|
||||
wrap.append(caption, group); panel.append(wrap);
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||||
@@ -385,6 +425,26 @@ function renderContent(block, panel) {
|
||||
panel.append(label, rich.element, help);
|
||||
}
|
||||
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||||
if (block.type === 'button') {
|
||||
// Ohne eigene Wahl trägt ein Button den Pfeil, den er vor v25 immer hatte.
|
||||
const icon = block.props.icon ?? 'arrow-up-right';
|
||||
iconField('Symbol', icon, { none: 'Kein Symbol', version: 25 });
|
||||
if (icon !== 'none') {
|
||||
segmented('Stellung', 'iconPosition', block.props.iconPosition === 'start' ? 'start' : 'end', [['start', 'Vor dem Text'], ['end', 'Nach dem Text']], 25);
|
||||
segmented('Beim Überfahren', 'iconMotion', block.props.iconMotion ?? 'none', [['none', 'Ruhig'], ['slide', 'Rückt'], ['reveal', 'Erscheint']], 26);
|
||||
field('Nur das Symbol zeigen', 'iconOnly', block.props.iconOnly === true, { checkbox: true, help: 'Ein runder oder eckiger Knopf nur mit Symbol. Die Beschriftung bleibt als Name für Vorlesesoftware erhalten – bitte trotzdem sinnvoll ausfüllen.' });
|
||||
// Farbe, Fläche, Größe und Drehung sind Gestaltung: sie gelten je Gerät und beim Überfahren,
|
||||
// wie alle anderen Werte unter Design. Ein Knopf führt direkt dorthin.
|
||||
const design = document.createElement('button'); design.type = 'button'; design.className = 'text-button icon-design-link';
|
||||
design.textContent = 'Farbe, Größe und Fläche des Symbols → Design';
|
||||
design.addEventListener('click', () => {
|
||||
document.querySelector('[data-panel="design"]')?.click();
|
||||
const group = document.querySelector('.design-group[data-group="icon"]');
|
||||
if (group) { group.open = true; group.scrollIntoView({ block: 'start' }); group.querySelector('summary')?.focus({ preventScroll: true }); }
|
||||
});
|
||||
panel.append(design);
|
||||
}
|
||||
}
|
||||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||||
if (INLINE_TEXT[block.type]) {
|
||||
const inline = document.createElement('button');
|
||||
@@ -399,10 +459,10 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
||||
if (block.type === 'iconcard') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
iconField('Symbol', block.props.icon);
|
||||
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 – oder ein PDF.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'stat') {
|
||||
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
|
||||
@@ -418,7 +478,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 – oder ein PDF.' });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'contact') {
|
||||
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
|
||||
@@ -434,12 +494,12 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
||||
if (block.type === 'icon') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
iconField('Symbol', block.props.icon);
|
||||
// number, so the value is stored as a number: the server wants an int, not "48".
|
||||
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 – oder ein PDF.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – 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.' });
|
||||
@@ -530,7 +590,7 @@ function renderContent(block, panel) {
|
||||
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
|
||||
panel.append(hint, entries, add);
|
||||
}
|
||||
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt eingeben. Buttons und Menülinks mit #kontakt springen dann hierher; von anderen Seiten mit /seitenadresse#kontakt.' });
|
||||
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt. Im Menü lässt es sich dann als Sprungziel dieser Seite auswählen, ein Button springt mit #kontakt hierher.' });
|
||||
if (block.type === 'accordionitem') {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
|
||||
@@ -542,6 +602,12 @@ function renderContent(block, panel) {
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
|
||||
}
|
||||
if (block.type === 'language') {
|
||||
segmented('Anzeige', 'display', block.props.display === 'name' ? 'name' : 'code', [['code', 'Kürzel (DE · EN)'], ['name', 'Namen (Deutsch · English)']], 27);
|
||||
const help = document.createElement('p'); help.className = 'field-help';
|
||||
help.textContent = 'Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist. Die Sprachen stehen unter Einstellungen → Sprachen. Auf einer Seite, die es nur in einer Sprache gibt, bleibt die Sprachwahl leer. Am besten in den Kopfbereich setzen.';
|
||||
panel.append(help);
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
|
||||
@@ -556,7 +622,7 @@ 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 – oder ein PDF.' });
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – 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 });
|
||||
@@ -568,7 +634,7 @@ function renderContent(block, panel) {
|
||||
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 – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – 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);
|
||||
@@ -768,7 +834,7 @@ function renderLegacyEffects(block, panel) {
|
||||
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
|
||||
panel.append(labelEl, select);
|
||||
}
|
||||
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);
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit dem Abspielknopf über der Vorschau lässt sich das prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -926,7 +992,7 @@ async function updatePreview() {
|
||||
const sequence = previewSequence;
|
||||
const controller = new AbortController(); previewController = controller;
|
||||
try {
|
||||
const html = await request('/preview', { document: history.document }, controller.signal);
|
||||
const html = await request('/preview', { document: history.document, language }, controller.signal);
|
||||
if (sequence !== previewSequence) return;
|
||||
const frame = $('#preview');
|
||||
const top = frame.contentWindow?.scrollY || 0;
|
||||
@@ -967,7 +1033,7 @@ async function updatePreview() {
|
||||
menuSummary.parentElement.open = !menuSummary.parentElement.open;
|
||||
}
|
||||
const insert = event.target.closest('[data-editor-target]');
|
||||
if (insert && !busy && history) {
|
||||
if (insert && !busy && history && !language) {
|
||||
stopInlineEdit();
|
||||
const id = insert.dataset.editorTarget;
|
||||
if (!locate(history.document.blocks, id)) return;
|
||||
@@ -992,12 +1058,13 @@ async function updatePreview() {
|
||||
const element = event.target.closest('[id]');
|
||||
if (!element) return;
|
||||
const block = locate(history.document.blocks, element.id)?.block;
|
||||
if (language) return;
|
||||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||||
});
|
||||
// Right click: the element's actions where the pointer is, as in Flatsome.
|
||||
doc.addEventListener('contextmenu', event => {
|
||||
const element = event.target.closest('[id]');
|
||||
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return;
|
||||
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit || language) return;
|
||||
event.preventDefault();
|
||||
choose(element.id, false);
|
||||
openContextMenu(element.id, event.clientX, event.clientY);
|
||||
@@ -1013,7 +1080,8 @@ async function updatePreview() {
|
||||
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
|
||||
const finish = () => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
decorateContainers(doc, history.document);
|
||||
// Beim Übersetzen gibt es nichts einzufügen: der Aufbau gehört der Hauptsprache.
|
||||
if (!language) decorateContainers(doc, history.document);
|
||||
overlay?.attach();
|
||||
styleReady.then(loaded => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
@@ -1023,7 +1091,9 @@ async function updatePreview() {
|
||||
frame.style.visibility = '';
|
||||
showMotionStatus();
|
||||
playPreview();
|
||||
$('#preview-status').textContent = loaded
|
||||
$('#preview-status').textContent = loaded && language
|
||||
? `Entwurf auf ${languageName(language)} · Klicken springt zum Text in der Übersetzungsliste. Leere Felder zeigen den Text der Hauptsprache.`
|
||||
: loaded
|
||||
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
|
||||
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
|
||||
});
|
||||
@@ -1062,7 +1132,226 @@ function changed() {
|
||||
if (!locate(history.document.blocks, selected)) selected = null;
|
||||
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
|
||||
syncView();
|
||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||
renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Übersetzen (ADR 111).
|
||||
//
|
||||
// Dieselbe Seite, dieselbe Vorschau - nur die Seitenleiste zeigt statt Werkzeugkasten und
|
||||
// Eigenschaften die Liste der Texte: Original links darüber, Übersetzung darunter. Aufbau und
|
||||
// Gestaltung bleiben in der Hauptsprache; hier lässt sich nichts verschieben oder löschen.
|
||||
// Jede Eingabe ist ein gewöhnlicher Schritt im Verlauf, also gelten Undo, Speichern und
|
||||
// Veröffentlichen wie immer und für alle Sprachen zugleich.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
const languageName = code => languageInfo?.extra.find(item => item.code === code)?.name ?? languageInfo?.main.name ?? code;
|
||||
|
||||
function renderLanguageSwitch() {
|
||||
const holder = $('#language-switch');
|
||||
const extra = languageInfo?.extra ?? [];
|
||||
holder.hidden = !extra.length;
|
||||
$('#publish-languages').hidden = !extra.length;
|
||||
holder.replaceChildren();
|
||||
if (!extra.length) return;
|
||||
for (const item of [languageInfo.main, ...extra]) {
|
||||
const button = document.createElement('button'); button.type = 'button';
|
||||
button.textContent = item.code.toUpperCase();
|
||||
button.title = item === languageInfo.main ? `${item.name} – Hauptsprache: Aufbau, Gestaltung und Texte` : `${item.name} – nur die Texte übersetzen`;
|
||||
// Der Name beginnt mit dem sichtbaren Kürzel, damit auch Sprachsteuerung den Knopf findet.
|
||||
button.setAttribute('aria-label', item === languageInfo.main ? `${item.code.toUpperCase()} – ${item.name} (Hauptsprache) bearbeiten` : `${item.code.toUpperCase()} – ${item.name} übersetzen`);
|
||||
const active = (language ?? languageInfo.main.code) === item.code;
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.addEventListener('click', () => setLanguage(item === languageInfo.main ? null : item.code));
|
||||
holder.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function setLanguage(code) {
|
||||
if (busy || !history || code === language) return;
|
||||
stopInlineEdit(); closeContextMenu(); setPendingInsert(null);
|
||||
language = code;
|
||||
if (language) root.dataset.language = language; else delete root.dataset.language;
|
||||
selected = null; history.mergeKey = null; translateFilter = 'all';
|
||||
setView(language ? 'translate' : 'elements');
|
||||
renderLanguageSwitch(); renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
|
||||
if (language) $('#translate-panel').querySelector('h2')?.focus();
|
||||
}
|
||||
|
||||
/** Springt in der Liste zu den Texten des gewählten Blocks oder des ersten Blocks darin. */
|
||||
function focusTranslation(id) {
|
||||
const panel = $('#translate-panel');
|
||||
const ids = [];
|
||||
const collect = block => { ids.push(block.id); (block.children ?? []).forEach(collect); };
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
collect(block);
|
||||
const group = ids.map(item => panel.querySelector(`.translate-group[data-block-id="${CSS.escape(item)}"]`)).find(Boolean);
|
||||
if (!group) return;
|
||||
if (group.hidden) { translateFilter = 'all'; renderTranslations(); return focusTranslation(id); }
|
||||
panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current'));
|
||||
group.classList.add('is-current');
|
||||
const body = $('.sidebar-body');
|
||||
body.scrollTop += group.getBoundingClientRect().top - body.getBoundingClientRect().top - 12;
|
||||
group.querySelector('textarea,input,[contenteditable="true"]')?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const STATE_TEXT = { missing: 'Fehlt', stale: 'Original geändert', done: 'Übersetzt' };
|
||||
|
||||
function renderTranslations() {
|
||||
const panel = $('#translate-panel');
|
||||
if (!language || !history) { panel.replaceChildren(); return; }
|
||||
const scroll = $('.sidebar-body').scrollTop;
|
||||
const info = languageInfo.extra.find(item => item.code === language);
|
||||
const doc = history.document;
|
||||
const translation = doc.translations?.[language] ?? {};
|
||||
// Ein Kopf- oder Fußbereich hat weder Adresse noch Seitentitel - nur Texte in Blöcken.
|
||||
const list = translationEntries(doc, language).filter(entry => !languageInfo.area || entry.blockId);
|
||||
const count = { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
|
||||
panel.replaceChildren();
|
||||
|
||||
const head = el('div', 'translate-head');
|
||||
const title = el('h2', '', `Übersetzung: ${info.name}`); title.tabIndex = -1;
|
||||
const meter = el('progress'); meter.max = Math.max(1, count.total); meter.value = count.done; meter.setAttribute('aria-label', 'Übersetzt');
|
||||
const summary = el('p', 'translate-summary', `${count.done} von ${count.total} Texten übersetzt${count.stale ? ` · ${count.stale} mit geändertem Original` : ''}`);
|
||||
summary.setAttribute('role', 'status');
|
||||
const hint = el('p', 'field-help', `Aufbau, Bilder und Gestaltung bearbeitest du in ${languageInfo.main.name}. Hier stehen nur die Texte. Ein leeres Feld zeigt auf der Website den Text in ${languageInfo.main.name}.`);
|
||||
const filter = el('div', 'segmented translate-filter'); filter.setAttribute('role', 'group'); filter.setAttribute('aria-label', 'Texte filtern');
|
||||
filter.style.gridTemplateColumns = 'repeat(3, 1fr)';
|
||||
for (const [value, text] of [['all', 'Alle'], ['missing', 'Fehlend'], ['stale', 'Geändert']]) {
|
||||
const option = el('button', '', text); option.type = 'button'; option.setAttribute('aria-pressed', String(translateFilter === value));
|
||||
option.addEventListener('click', () => { translateFilter = value; renderTranslations(); });
|
||||
filter.append(option);
|
||||
}
|
||||
head.append(title, meter, summary, hint, filter);
|
||||
panel.append(head);
|
||||
|
||||
// Adresse und Suchtitel: gehören nur zur Übersetzung, ein Original gibt es dafür nicht.
|
||||
const page = el('section', 'translate-group translate-page'); page.dataset.blockId = '';
|
||||
if (languageInfo.area) page.hidden = true;
|
||||
page.append(el('h3', '', 'Seite'));
|
||||
const published = languageInfo.published?.[language];
|
||||
if (languageInfo.home) {
|
||||
page.append(el('p', 'field-help', `Startseite – erscheint unter /${language}.`));
|
||||
} else {
|
||||
const label = el('label', '', 'Adresse'); label.htmlFor = 'translate-slug';
|
||||
const wrap = el('div', 'translate-slug');
|
||||
const prefix = el('span', 'translate-prefix', `/${language}/…/`); prefix.setAttribute('aria-hidden', 'true');
|
||||
const input = el('input'); input.id = 'translate-slug'; input.maxLength = 80; input.value = translation.slug ?? '';
|
||||
input.placeholder = languageInfo.slug; input.spellcheck = false; input.autocomplete = 'off';
|
||||
input.pattern = '[a-z0-9]+(-[a-z0-9]+)*';
|
||||
const help = el('p', 'field-help', `Kleinbuchstaben, Ziffern, Bindestriche. Leer: wie in ${languageInfo.main.name} (${languageInfo.slug}).`); help.id = 'translate-slug-help';
|
||||
input.setAttribute('aria-describedby', help.id);
|
||||
input.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'slug', input.value), 'tr-slug'); controls(); });
|
||||
const suggest = el('button', 'text-button', 'Aus dem übersetzten Titel'); suggest.type = 'button';
|
||||
suggest.addEventListener('click', () => {
|
||||
const proposal = slugify(history.document.translations?.[language]?.title ?? '');
|
||||
if (!proposal) { help.textContent = 'Erst den Seitentitel übersetzen, dann lässt sich die Adresse daraus bilden.'; return; }
|
||||
input.value = proposal; history.change(content => setPageValue(content, language, 'slug', proposal)); controls();
|
||||
});
|
||||
wrap.append(prefix, input);
|
||||
page.append(label, wrap, help, suggest);
|
||||
}
|
||||
const seoLabel = el('label', '', 'Titel für Suchmaschinen'); seoLabel.htmlFor = 'translate-seo';
|
||||
const seo = el('input'); seo.id = 'translate-seo'; seo.maxLength = 160; seo.value = translation.seoTitle ?? ''; seo.lang = language;
|
||||
seo.placeholder = 'Leer: der übersetzte Seitentitel';
|
||||
seo.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'seoTitle', seo.value), 'tr-seo'); controls(); });
|
||||
page.append(seoLabel, seo);
|
||||
page.append(el('p', 'field-help', published
|
||||
? `Veröffentlicht unter /${language}${languageInfo.home ? '' : `/${published}`}. Änderungen gehen mit „Veröffentlichen“ online.`
|
||||
: `Noch nicht auf der Website. Die Seite erscheint auf ${info.name}, sobald ein Text übersetzt und die Seite veröffentlicht ist.`));
|
||||
panel.append(page);
|
||||
|
||||
// Die Texte, gruppiert nach Block in Seitenreihenfolge; Seitentitel und Beschreibung vorn.
|
||||
let group = null, groupId;
|
||||
for (const entry of list) {
|
||||
const id = entry.blockId ?? '__page';
|
||||
if (id !== groupId) {
|
||||
groupId = id;
|
||||
group = el('section', 'translate-group'); group.dataset.blockId = entry.blockId ?? '';
|
||||
const heading = el('h3');
|
||||
if (entry.blockId) {
|
||||
const jump = el('button', 'translate-jump', describe(entry.block)); jump.type = 'button';
|
||||
jump.title = 'In der Vorschau zeigen';
|
||||
const current = group;
|
||||
jump.addEventListener('click', () => { selected = entry.blockId; highlight(true); panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current')); current.classList.add('is-current'); });
|
||||
heading.append(jump);
|
||||
} else heading.textContent = 'Seitentitel und Beschreibung';
|
||||
group.append(heading);
|
||||
panel.append(group);
|
||||
}
|
||||
group.append(translationField(entry, info));
|
||||
}
|
||||
for (const section of panel.querySelectorAll('.translate-group:not(.translate-page)')) {
|
||||
const fields = [...section.querySelectorAll('.translate-field')];
|
||||
for (const field of fields) field.hidden = translateFilter !== 'all' && field.dataset.state !== translateFilter;
|
||||
section.hidden = fields.every(field => field.hidden);
|
||||
}
|
||||
if (!list.length) panel.append(el('p', 'panel-hint', languageInfo.area ? 'In diesem Bereich gibt es keine Texte zum Übersetzen. Menübeschriftungen übersetzt du unter Menüs.' : 'Auf dieser Seite gibt es noch keine Texte zum Übersetzen.'));
|
||||
else if (languageInfo.area) head.append(el('p', 'field-help', 'Gespeicherte Übersetzungen eines Bereichs gelten sofort auf allen Seiten dieser Sprache. Menübeschriftungen übersetzt du unter Menüs.'));
|
||||
else if (translateFilter !== 'all' && !panel.querySelector('.translate-field:not([hidden])')) panel.append(el('p', 'panel-hint', translateFilter === 'missing' ? 'Alles übersetzt.' : 'Kein Original wurde seit der Übersetzung geändert.'));
|
||||
if (selected) panel.querySelector(`.translate-group[data-block-id="${CSS.escape(selected)}"]`)?.classList.add('is-current');
|
||||
$('.sidebar-body').scrollTop = scroll;
|
||||
controls();
|
||||
}
|
||||
|
||||
/** Kurzname eines Blocks für die Liste: Art und der Anfang des Originals. */
|
||||
function describe(block) {
|
||||
const text = plainText(block.props?.text ?? block.props?.title ?? block.props?.alt ?? block.props?.label ?? '');
|
||||
return text ? `${blockLabel(block)} · ${text.length > 40 ? `${text.slice(0, 40)} …` : text}` : blockLabel(block);
|
||||
}
|
||||
|
||||
function translationField(entry, info) {
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
|
||||
const row = el('div', 'translate-field'); row.dataset.state = entry.state; row.dataset.key = entry.key;
|
||||
const id = `tr-${entry.key.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
const label = el('label', '', entry.label); label.htmlFor = id;
|
||||
const badge = el('span', 'translate-state', STATE_TEXT[entry.state]);
|
||||
const top = el('div', 'translate-field-head'); top.append(label, badge);
|
||||
const original = el('p', 'translate-original'); original.lang = languageInfo.main.code;
|
||||
original.textContent = entry.field.rich ? plainText(entry.original) : entry.original;
|
||||
const originalId = `${id}-original`; original.id = originalId;
|
||||
const update = value => {
|
||||
history.change(content => setText(content, language, entry.key, value), `tr-${entry.key}`);
|
||||
const state = value.trim() === '' ? 'missing' : 'done';
|
||||
row.dataset.state = state; badge.textContent = STATE_TEXT[state];
|
||||
controls(); schedulePreview();
|
||||
};
|
||||
row.append(top, original);
|
||||
let input;
|
||||
const rich = entry.field.rich === 'text' ? history.document.schemaVersion >= 12 : entry.field.rich === 'heading' ? history.document.schemaVersion >= 17 : false;
|
||||
if (rich) {
|
||||
const editor = createRichText(entry.value, markup => update(plainText(markup) === '' ? '' : markup), { label: `${entry.label} auf ${info.name}` });
|
||||
editor.element.lang = language;
|
||||
if (info.rtl) editor.element.dir = 'rtl';
|
||||
editor.element.querySelector('[contenteditable]')?.setAttribute('aria-describedby', originalId);
|
||||
// Das Label zeigt auf die Fläche, die man beschreibt.
|
||||
const surface = editor.element.querySelector('[contenteditable]'); if (surface) { surface.id = id; label.addEventListener('click', () => surface.focus()); }
|
||||
row.append(editor.element);
|
||||
} else {
|
||||
input = el(entry.field.multiline ? 'textarea' : 'input');
|
||||
input.id = id; input.value = entry.value; input.maxLength = entry.field.max; input.lang = language;
|
||||
if (info.rtl) input.dir = 'rtl';
|
||||
if (entry.field.multiline) input.rows = 3;
|
||||
input.setAttribute('aria-describedby', originalId);
|
||||
input.addEventListener('input', () => update(input.value));
|
||||
row.append(input);
|
||||
}
|
||||
const actions = el('div', 'translate-actions');
|
||||
if (entry.state === 'missing') {
|
||||
const copy = el('button', 'text-button', 'Original übernehmen'); copy.type = 'button';
|
||||
copy.title = 'Den Text der Hauptsprache als Ausgangspunkt einsetzen';
|
||||
copy.addEventListener('click', () => { history.change(content => setText(content, language, entry.key, entry.original)); changed(); });
|
||||
actions.append(copy);
|
||||
}
|
||||
if (entry.state === 'stale') {
|
||||
const ok = el('button', 'text-button', 'Übersetzung passt'); ok.type = 'button';
|
||||
ok.title = 'Das Original wurde geändert, die Übersetzung stimmt trotzdem';
|
||||
ok.addEventListener('click', () => { history.change(content => confirmText(content, language, entry.key)); changed(); });
|
||||
actions.append(ok);
|
||||
}
|
||||
if (actions.childElementCount) row.append(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function loadDesignSchema() {
|
||||
@@ -1096,12 +1385,19 @@ async function load() {
|
||||
designTokens = await response.json();
|
||||
}
|
||||
if (!designSchema) await loadDesignSchema();
|
||||
history = new EditorHistory(data.document);
|
||||
// Ein Dokument mit Eigenschaften einer neueren Fassung wird beim Laden gehoben, sonst lehnt
|
||||
// der Server schon die Vorschau ab (siehe repairSchema).
|
||||
history = new EditorHistory(repairSchema(data.document));
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
languageInfo = data.languages ?? null;
|
||||
if (language && !languageInfo?.extra.some(item => item.code === language)) { language = null; delete root.dataset.language; }
|
||||
renderLanguageSwitch();
|
||||
// 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();
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify();
|
||||
if (language) setView('translate');
|
||||
changed(); resizePreview();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
@@ -1113,13 +1409,15 @@ 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);
|
||||
if (data.languages) languageInfo = data.languages;
|
||||
// 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();
|
||||
renderTree(); renderProperties(); renderTranslations(); schedulePreview();
|
||||
} else history.markSaved(snapshot);
|
||||
if (publish && language) renderTranslations();
|
||||
notify();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
@@ -1460,12 +1758,13 @@ overlay = createCanvasOverlay({
|
||||
getDocument: () => history.document,
|
||||
getSelected: () => selected,
|
||||
onAction: (action, id) => {
|
||||
if (!id || busy || !history) return;
|
||||
if (!id || busy || !history || language) return;
|
||||
if (action === 'up') actions.move(id, -1);
|
||||
else if (action === 'down') actions.move(id, 1);
|
||||
else actions[action]?.(id);
|
||||
},
|
||||
onInsert: (id, where) => {
|
||||
if (language) return;
|
||||
const item = locate(history.document.blocks, id);
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
@@ -1473,12 +1772,12 @@ overlay = createCanvasOverlay({
|
||||
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
|
||||
// the whole row for the current device, one undo step per drag.
|
||||
onResize: (rowId, value) => {
|
||||
if (busy || !history) return;
|
||||
if (busy || !history || language) return;
|
||||
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
|
||||
changed();
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (busy || !history || language) return;
|
||||
if (payload.kind === 'add') {
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
|
||||
@@ -23,6 +23,15 @@ die Verwendung des reservierten Namens für eine geänderte Fassung.
|
||||
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
|
||||
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
|
||||
|
||||
**DM Sans** (Fließtext der Seite „Kantarci Markt") steht unter der SIL Open Font License 1.1, Text
|
||||
in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/googlefonts/dm-fonts).
|
||||
DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei,
|
||||
`fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700.
|
||||
|
||||
**Barlow** und **Barlow Condensed** (Fließtext und Überschriften der Seite „Hüfner Gartenbau“)
|
||||
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
|
||||
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -14,3 +14,27 @@
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@@ -250,10 +250,35 @@ if (root) {
|
||||
if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.';
|
||||
};
|
||||
|
||||
// Eine Feldkarte ist eine Zeile; die Details öffnet man bei Bedarf. Darunter steht kurz, was
|
||||
// in ihnen eingestellt ist, damit man nicht jede Karte aufklappen muss.
|
||||
const setOpen = (card, open) => {
|
||||
card.querySelector('[data-field-body]').hidden = !open;
|
||||
const toggle = card.querySelector('[data-field-toggle]');
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
card.classList.toggle('is-open', open);
|
||||
};
|
||||
const meta = card => {
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const parts = [card.querySelector('[data-field-name]').value.trim()];
|
||||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||||
if (['select', 'radio', 'checkboxes'].includes(type)) parts.push(options.length ? options.join(', ') : 'noch keine Optionen');
|
||||
if (card.querySelector('[data-field-help]').value.trim()) parts.push('Hilfetext');
|
||||
if (card.querySelector('[data-field-showif]').value.trim()) parts.push('mit Bedingung');
|
||||
for (const translation of card.querySelectorAll('[data-field-translation]')) {
|
||||
if ([...translation.querySelectorAll('input')].some(input => input.value.trim())) parts.push(`${translation.dataset.fieldTranslation.toUpperCase()} ✓`);
|
||||
}
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const line = card.querySelector('[data-field-meta]');
|
||||
line.textContent = meta(card); line.hidden = !line.textContent || card.classList.contains('is-open');
|
||||
const label = card.querySelector('[data-field-label]').value.trim() || `Feld ${index + 1}`;
|
||||
card.querySelector('[data-field-toggle]').setAttribute('aria-label', `Details zu „${label}“`);
|
||||
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);
|
||||
@@ -276,12 +301,19 @@ if (root) {
|
||||
const empty = cards().filter(card => card.classList.contains('field-card-new'));
|
||||
if (cards().length > empty.length) empty.forEach(card => card.remove());
|
||||
add.hidden = false;
|
||||
for (const card of cards()) {
|
||||
card.querySelector('[data-field-toggle]').hidden = false;
|
||||
setOpen(card, card.classList.contains('field-card-new'));
|
||||
}
|
||||
add.addEventListener('click', () => {
|
||||
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
|
||||
const card = list.lastElementChild; card.classList.remove('field-card-new');
|
||||
card.querySelector('[data-field-toggle]').hidden = false; setOpen(card, true);
|
||||
refresh(); card.querySelector('[data-field-label]').focus();
|
||||
});
|
||||
list.addEventListener('click', event => {
|
||||
const toggle = event.target.closest('[data-field-toggle]');
|
||||
if (toggle) { const card = toggle.closest('[data-field]'); setOpen(card, !card.classList.contains('is-open')); refresh(); return; }
|
||||
const button = event.target.closest('[data-remove-field]');
|
||||
if (!button) return;
|
||||
button.closest('[data-field]').remove(); refresh(); add.focus();
|
||||
|
||||
+16
-10
@@ -16,6 +16,10 @@
|
||||
};
|
||||
|
||||
const setup = form => {
|
||||
// Die Texte des Skripts. Auf einer übersetzten Seite bringt das Formular sie mit (ADR 111).
|
||||
let texts = {};
|
||||
try { texts = JSON.parse(form.dataset.texts || '{}'); } catch { texts = {}; }
|
||||
const t = (key, fallback) => texts[key] || fallback;
|
||||
const rules = [...form.querySelectorAll('[data-show-if]')];
|
||||
const steps = [...form.querySelectorAll('[data-step]')];
|
||||
const submit = form.querySelector('.cms-button[type="submit"]');
|
||||
@@ -54,27 +58,27 @@
|
||||
if (wantsSummary) {
|
||||
summary = el('div', 'form-step form-summary');
|
||||
summary.setAttribute('data-step', '');
|
||||
const title = el('p', 'form-step-title', 'Zusammenfassung');
|
||||
const title = el('p', 'form-step-title', t('summary', '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(title, el('p', 'form-step-intro', t('summaryIntro', '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');
|
||||
progress.setAttribute('aria-label', t('steps', '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}`));
|
||||
item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', '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');
|
||||
const back = el('button', 'button cms-button form-step-back', t('back', 'Zurück'));
|
||||
back.type = 'button';
|
||||
const next = el('button', 'button cms-button form-step-next', 'Weiter');
|
||||
const next = el('button', 'button cms-button form-step-next', t('next', 'Weiter'));
|
||||
next.type = 'button';
|
||||
nav.append(back, next);
|
||||
(submit || form).before(nav);
|
||||
@@ -90,8 +94,10 @@
|
||||
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.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return t('yes', 'Ja');
|
||||
if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value;
|
||||
// Angezeigt wird die Beschriftung der Option - auf einer übersetzten Seite die übersetzte.
|
||||
if (input.type === 'checkbox' || input.type === 'radio') return (input.labels?.[0]?.querySelector('.form-choice-text') ?? input.labels?.[0])?.textContent.trim() || input.value;
|
||||
return input.value + (unit ? ` ${unit}` : '');
|
||||
}).join(', ');
|
||||
};
|
||||
@@ -103,8 +109,8 @@
|
||||
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');
|
||||
head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`));
|
||||
const change = el('button', 'form-summary-change', t('change', 'Ändern'));
|
||||
change.type = 'button';
|
||||
change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`);
|
||||
change.addEventListener('click', () => show(index));
|
||||
@@ -153,7 +159,7 @@
|
||||
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: '' } }));
|
||||
if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } }));
|
||||
else note.hidden = false;
|
||||
group.classList.add('has-error');
|
||||
group.querySelector('input')?.focus();
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// Der Symbolsatz: Zeichnung, deutscher Name und Kategorie. Die Website zeichnet dieselben Pfade aus
|
||||
// templates/blocks/icon.html.twig; ein Test hält Twig, diese Datei und DocumentValidator::ICONS gleich.
|
||||
// Alle Symbole sind Linien auf einem 24er-Raster (stroke, keine Füllung) und erben die Textfarbe.
|
||||
export const ICON_PATHS = {
|
||||
// Pfeile und Navigation
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||
'arrow-up': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
|
||||
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
|
||||
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
|
||||
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
|
||||
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
|
||||
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
|
||||
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||
close: 'M6 6l12 12M18 6 6 18',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
minus: 'M5 12h14',
|
||||
// Kontakt und Ort
|
||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
|
||||
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
|
||||
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
|
||||
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||
// Handel und Geld
|
||||
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
|
||||
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
|
||||
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
|
||||
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
|
||||
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
|
||||
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
|
||||
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
|
||||
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
|
||||
// Dateien und Medien
|
||||
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
|
||||
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
|
||||
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
|
||||
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
|
||||
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
|
||||
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
|
||||
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
|
||||
play: 'M8 5.5v13l10-6.5Z',
|
||||
pause: 'M8 5v14m8-14v14',
|
||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
|
||||
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
|
||||
// Personen und Sicherheit
|
||||
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
|
||||
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
|
||||
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
|
||||
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
|
||||
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
|
||||
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
|
||||
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
|
||||
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||
// Haus, Garten und Handwerk
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
|
||||
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
|
||||
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
|
||||
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
|
||||
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
|
||||
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
|
||||
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
|
||||
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
|
||||
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
|
||||
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
|
||||
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
|
||||
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
|
||||
// Allgemein
|
||||
check: 'm4 12.5 5.5 5.5L20 6',
|
||||
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
|
||||
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
|
||||
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
|
||||
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
|
||||
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
|
||||
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
|
||||
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
|
||||
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
|
||||
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
|
||||
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
|
||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
||||
};
|
||||
|
||||
export const ICON_LABELS = {
|
||||
'arrow-up-right': 'Pfeil schräg', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links',
|
||||
'arrow-up': 'Pfeil nach oben', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel rechts',
|
||||
'chevron-left': 'Winkel links', 'chevron-up': 'Winkel oben', 'chevron-down': 'Winkel unten',
|
||||
'arrow-circle': 'Pfeil im Kreis', external: 'Externer Link', refresh: 'Aktualisieren', menu: 'Menü',
|
||||
close: 'Schließen', plus: 'Plus', minus: 'Minus',
|
||||
phone: 'Telefon', app: 'Smartphone', mail: 'E-Mail', chat: 'Nachricht', send: 'Senden', calendar: 'Kalender',
|
||||
clock: 'Uhr', pin: 'Ort', map: 'Karte', navigation: 'Navigation', web: 'Globus', wifi: 'WLAN', support: 'Kundendienst',
|
||||
shop: 'Einkaufstasche', cart: 'Warenkorb', tag: 'Preisschild', euro: 'Euro', 'credit-card': 'Karte bezahlen',
|
||||
gift: 'Geschenk', percent: 'Prozent', truck: 'Lieferung', package: 'Paket', briefcase: 'Aktentasche',
|
||||
document: 'Dokument', download: 'Download', upload: 'Hochladen', print: 'Drucken', link: 'Link', share: 'Teilen',
|
||||
image: 'Bild', camera: 'Kamera', video: 'Video', play: 'Abspielen', pause: 'Pause', music: 'Musik',
|
||||
mic: 'Mikrofon', book: 'Buch',
|
||||
user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz',
|
||||
eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern',
|
||||
home: 'Haus', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
|
||||
leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar',
|
||||
thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto',
|
||||
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
|
||||
search: 'Suche', settings: 'Einstellungen', filter: 'Filter', grid: 'Raster', list: 'Liste', sparkle: 'Funkeln',
|
||||
coffee: 'Kaffee', utensils: 'Besteck', laptop: 'Laptop', hardware: 'Bildschirm', network: 'Netzwerk', design: 'Stift',
|
||||
};
|
||||
|
||||
/** Die Kategorien des Auswahlfensters, in der Reihenfolge der Reiter. Jedes Symbol steht genau einmal. */
|
||||
export const ICON_GROUPS = [
|
||||
['Pfeile', ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus']],
|
||||
['Kontakt & Ort', ['phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support']],
|
||||
['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']],
|
||||
['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']],
|
||||
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
|
||||
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
|
||||
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
|
||||
];
|
||||
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
export function iconSvg(name) {
|
||||
const svg = document.createElementNS(svgNS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.setAttribute('focusable', 'false');
|
||||
// Wie auf der Website: Linien in der Textfarbe. CSS darf Größe und Strichstärke überschreiben.
|
||||
for (const [name, value] of Object.entries({ fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })) svg.setAttribute(name, value);
|
||||
const path = document.createElementNS(svgNS, 'path');
|
||||
path.setAttribute('d', ICON_PATHS[name] ?? '');
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
/**
|
||||
* Das Feld im Panel: das gewählte Symbol als Vorschau mit Namen und ein Knopf, der das
|
||||
* Auswahlfenster öffnet. Die Kacheln stehen nicht im Panel - bei hundert Symbolen wäre es eine
|
||||
* lange Wand, die alles andere nach unten drückt.
|
||||
*
|
||||
* @param {{ label: string, value: string, none?: string, onChoose: (name: string) => void }} options
|
||||
*/
|
||||
export function renderIconField({ label, value, none, onChoose }) {
|
||||
const wrap = element('div', 'icon-field');
|
||||
wrap.append(element('p', 'icon-picker-label', label));
|
||||
const row = element('div', 'icon-field-row');
|
||||
const preview = element('span', 'icon-field-preview');
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
if (value && value !== 'none') preview.append(iconSvg(value)); else preview.classList.add('is-empty');
|
||||
const name = element('span', 'icon-field-name', value && value !== 'none' ? ICON_LABELS[value] ?? value : none ?? 'Kein Symbol');
|
||||
const open = element('button', 'secondary icon-field-change', 'Ändern …');
|
||||
open.type = 'button';
|
||||
open.setAttribute('aria-haspopup', 'dialog');
|
||||
open.setAttribute('aria-label', `${label} ändern, aktuell: ${name.textContent}`);
|
||||
open.addEventListener('click', () => openIconDialog({ value, none, onChoose, returnFocus: open }));
|
||||
row.append(preview, name, open);
|
||||
wrap.append(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Auswahlfenster: Kategorien als Reiter links, die Symbole einer Kategorie als Kacheln mit
|
||||
* Namen. Ein Klick wählt und schließt, Escape schließt ohne Änderung. Geöffnet wird bei der
|
||||
* Kategorie des gewählten Symbols, der Fokus steht auf ihm.
|
||||
*/
|
||||
export function openIconDialog({ value, none, onChoose, returnFocus }) {
|
||||
const dialog = element('dialog', 'icon-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'icon-dialog-title');
|
||||
const head = element('div', 'icon-dialog-head');
|
||||
head.append(element('h2', null, 'Symbol wählen'));
|
||||
head.firstChild.id = 'icon-dialog-title';
|
||||
const close = element('button', 'icon-dialog-close');
|
||||
close.type = 'button'; close.setAttribute('aria-label', 'Schließen'); close.append(iconSvg('close'));
|
||||
close.addEventListener('click', () => dialog.close());
|
||||
head.append(close);
|
||||
const body = element('div', 'icon-dialog-body');
|
||||
const tablist = element('div', 'icon-dialog-tabs');
|
||||
tablist.setAttribute('role', 'tablist'); tablist.setAttribute('aria-orientation', 'vertical'); tablist.setAttribute('aria-label', 'Kategorien');
|
||||
const panel = element('div', 'icon-dialog-panel');
|
||||
panel.setAttribute('role', 'tabpanel'); panel.id = 'icon-dialog-panel';
|
||||
const tabs = [];
|
||||
let tiles = [];
|
||||
const choose = name => { dialog.close(); onChoose(name); };
|
||||
const show = (index, focusTile = false) => {
|
||||
tabs.forEach((tab, i) => { tab.setAttribute('aria-selected', String(i === index)); tab.tabIndex = i === index ? 0 : -1; });
|
||||
panel.setAttribute('aria-labelledby', tabs[index].id);
|
||||
panel.replaceChildren();
|
||||
const grid = element('div', 'icon-dialog-grid');
|
||||
tiles = ICON_GROUPS[index][1].map(name => {
|
||||
const tile = element('button', 'icon-choice');
|
||||
tile.type = 'button'; tile.dataset.icon = name;
|
||||
tile.setAttribute('aria-pressed', String(name === value));
|
||||
tile.tabIndex = -1;
|
||||
tile.append(iconSvg(name), element('span', 'icon-choice-name', ICON_LABELS[name]));
|
||||
tile.addEventListener('click', () => choose(name));
|
||||
grid.append(tile);
|
||||
return tile;
|
||||
});
|
||||
(tiles.find(tile => tile.dataset.icon === value) ?? tiles[0]).tabIndex = 0;
|
||||
panel.append(grid);
|
||||
if (focusTile) (tiles.find(tile => tile.tabIndex === 0)).focus();
|
||||
};
|
||||
ICON_GROUPS.forEach(([title], index) => {
|
||||
const tab = element('button', 'icon-dialog-tab', title);
|
||||
tab.type = 'button'; tab.id = `icon-tab-${index}`; tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', panel.id);
|
||||
tab.addEventListener('click', () => show(index));
|
||||
tabs.push(tab); tablist.append(tab);
|
||||
});
|
||||
tablist.addEventListener('keydown', event => {
|
||||
const index = tabs.indexOf(document.activeElement);
|
||||
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
|
||||
if (index < 0 || !step) return;
|
||||
event.preventDefault();
|
||||
const next = (index + step + tabs.length) % tabs.length;
|
||||
show(next); tabs[next].focus();
|
||||
});
|
||||
panel.addEventListener('keydown', event => {
|
||||
const index = tiles.indexOf(document.activeElement);
|
||||
if (index < 0) return;
|
||||
const columns = Math.max(1, Math.round(panel.querySelector('.icon-dialog-grid').clientWidth / tiles[0].offsetWidth));
|
||||
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns }[event.key];
|
||||
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tiles.length - 1 : step ? Math.min(tiles.length - 1, Math.max(0, index + step)) : null;
|
||||
if (target === null) return;
|
||||
event.preventDefault();
|
||||
tiles[index].tabIndex = -1; tiles[target].tabIndex = 0; tiles[target].focus();
|
||||
});
|
||||
body.append(tablist, panel);
|
||||
const foot = element('div', 'icon-dialog-foot');
|
||||
if (none) {
|
||||
const without = element('button', 'secondary', none);
|
||||
without.type = 'button'; without.dataset.icon = 'none';
|
||||
without.setAttribute('aria-pressed', String(value === 'none'));
|
||||
without.addEventListener('click', () => choose('none'));
|
||||
foot.append(without);
|
||||
}
|
||||
const cancel = element('button', 'text-button', 'Abbrechen');
|
||||
cancel.type = 'button'; cancel.addEventListener('click', () => dialog.close());
|
||||
foot.append(cancel);
|
||||
dialog.append(head, body, foot);
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (returnFocus?.isConnected) returnFocus.focus(); });
|
||||
// Ein Klick auf den abgedunkelten Rand schließt, wie bei den anderen Fenstern des Editors.
|
||||
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
|
||||
document.body.append(dialog);
|
||||
const start = Math.max(0, ICON_GROUPS.findIndex(([, names]) => names.includes(value)));
|
||||
dialog.showModal();
|
||||
show(start, true);
|
||||
return dialog;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export const PREVIEWS = {
|
||||
postnav: '<path d="M9.4 4.6L6 8l3.4 3.4M14.6 4.6L18 8l-3.4 3.4"/><path d="M6.4 8h3.6M14 8h3.6"/>',
|
||||
breadcrumb: `${bar(2, 7.2, 5, 1.7)}<path d="M8.6 6.3l1.6 1.7-1.6 1.7"/>${bar(11.6, 7.2, 4, 1.7)}<path d="M17.2 6.3l1.6 1.7-1.6 1.7"/>${bar(20.2, 7.2, 2, 1.7)}`,
|
||||
menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`,
|
||||
language: '<circle cx="12" cy="8" r="5.6"/><path d="M6.4 8h11.2M12 2.4c-2.4 2.8-2.4 8.4 0 11.2M12 2.4c2.4 2.8 2.4 8.4 0 11.2"/>',
|
||||
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
|
||||
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
|
||||
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { setupPicker } from './page-picker.js';
|
||||
import './link-field.js';
|
||||
import { renderIconField, iconSvg, ICON_LABELS } from './icons.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
|
||||
const form = document.querySelector('#menu-editor');
|
||||
const list = form.querySelector('#menu-items');
|
||||
@@ -8,7 +11,8 @@ const error = form.querySelector('#menu-error');
|
||||
const history = [];
|
||||
let serial = list.children.length, pendingPage = null, busy = false;
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
|
||||
const iconNames = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken', people: 'Menschen', music: 'Musik', home: 'Haus' };
|
||||
const iconNames = ICON_LABELS;
|
||||
const buttonSchema = JSON.parse(form.dataset.buttonSchema || '{}');
|
||||
const menuIcons = JSON.parse(form.dataset.icons || '[]');
|
||||
const chrome = () => {
|
||||
const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = '';
|
||||
@@ -40,7 +44,9 @@ function refresh() {
|
||||
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
|
||||
row.querySelector('.menu-entry-title').textContent = name;
|
||||
const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value;
|
||||
const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true);
|
||||
row.querySelector('.menu-entry-title svg')?.remove();
|
||||
const graphic = icon && iconSvg(icon);
|
||||
if (graphic) { graphic.setAttribute('width', '18'); graphic.setAttribute('height', '18'); }
|
||||
if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic);
|
||||
row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`);
|
||||
row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; });
|
||||
@@ -75,9 +81,16 @@ function createRow(data) {
|
||||
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
|
||||
const details = el('details'); const summary = el('summary');
|
||||
const { handle, chevron } = chrome();
|
||||
summary.append(handle, el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details);
|
||||
// Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr - kein Knopf in einem Knopf.
|
||||
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details);
|
||||
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
|
||||
// Je weitere Sprache eine eigene Beschriftung (ADR 111); leer zeigt die der Hauptsprache.
|
||||
for (const language of JSON.parse(form.dataset.languages || '[]')) {
|
||||
const translated = field(`label_${language.code}`, data[`label_${language.code}`] ?? data.labels?.[language.code] ?? '', `Beschriftung ${language.name}`);
|
||||
translated.maxLength = 80; translated.lang = language.code; translated.placeholder = `Leer: wie „${data.label}“`;
|
||||
if (language.rtl) translated.dir = 'rtl';
|
||||
}
|
||||
if (data.type === 'page') {
|
||||
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
|
||||
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
|
||||
@@ -90,12 +103,15 @@ function createRow(data) {
|
||||
anchorChoice(row);
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); }
|
||||
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
|
||||
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
|
||||
const icons = el('div', 'menu-icon-fields'); body.append(icons);
|
||||
for (const [key, label, choices] of [['icon', 'Symbol', { '': 'Kein Symbol', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Symbol steht', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
|
||||
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
|
||||
for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value));
|
||||
select.value = data[key] || (key === 'iconPosition' ? 'before' : '');
|
||||
const caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select);
|
||||
const wrap = el('div'); const caption = el('label', '', label); caption.htmlFor = select.id; wrap.append(caption, select); icons.append(wrap);
|
||||
}
|
||||
body.append(buttonFieldset(data, id));
|
||||
enhance(row);
|
||||
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
|
||||
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
|
||||
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
|
||||
@@ -136,7 +152,7 @@ function anchorChoice(row) {
|
||||
for (const item of items) select.append(new Option(item.label, item.anchor));
|
||||
if (current && !items.some(item => item.anchor === current)) select.append(new Option(`#${current} (auf der Seite nicht gefunden)`, current));
|
||||
select.value = current;
|
||||
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor bekommt eine Sektion unter „Erweitert“ einen Anker.';
|
||||
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor lässt sich einer Sektion, Zeile oder Spalte ein Sprungziel geben.';
|
||||
} catch {
|
||||
if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.';
|
||||
}
|
||||
@@ -155,6 +171,105 @@ function linkButtons(input) {
|
||||
}
|
||||
list.querySelectorAll('input[data-key="url"]').forEach(linkButtons);
|
||||
|
||||
/*
|
||||
* Ein Eintrag als Button (ADR 110): dieselben Felder wie die Vorlage menu-entry.html.twig, damit
|
||||
* neue und geladene Zeilen gleich aussehen und dieselben Namen tragen. Leer heißt Website-Standard.
|
||||
*/
|
||||
const BUTTON_COLORS = { buttonFill: 'Fläche', buttonInk: 'Schrift', buttonHoverFill: 'Fläche beim Überfahren', buttonHoverInk: 'Schrift beim Überfahren', buttonIconColor: 'Symbolfarbe' };
|
||||
function buttonFieldset(data, id) {
|
||||
const set = el('fieldset', 'menu-button-fields'); set.dataset.buttonFields = '';
|
||||
set.append(el('legend', '', 'Button'));
|
||||
const toggle = el('input'); toggle.type = 'checkbox'; toggle.dataset.key = 'button'; toggle.value = '1'; toggle.id = `menu-button-${id}`;
|
||||
toggle.checked = data.button === '1' || data.button === true;
|
||||
const toggleLabel = el('label', 'check-label', ' Als Button darstellen'); toggleLabel.htmlFor = toggle.id; toggleLabel.prepend(toggle);
|
||||
const hidden = el('input'); hidden.type = 'hidden'; hidden.dataset.key = 'buttonId'; hidden.value = data.buttonId || '';
|
||||
const options = el('div', 'menu-button-options'); options.dataset.buttonOptions = ''; options.hidden = !toggle.checked;
|
||||
const select = (key, label, choices, value) => {
|
||||
const wrap = el('div'); const input = el('select'); input.dataset.key = key; input.id = `menu-${key}-${id}`;
|
||||
for (const [option, caption] of Object.entries(choices)) input.append(new Option(caption, option));
|
||||
input.value = value; const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
|
||||
};
|
||||
for (const [key, label] of [['shape', 'Form'], ['appearance', 'Darstellung'], ['size', 'Größe']]) {
|
||||
const field = `button${key[0].toUpperCase()}${key.slice(1)}`;
|
||||
select(field, label, { '': 'Website-Standard', ...(buttonSchema.buttonOptions?.[key] ?? {}) }, data[field] || '');
|
||||
}
|
||||
for (const [key, label] of Object.entries(BUTTON_COLORS)) {
|
||||
const wrap = el('div'); wrap.dataset.colorField = '';
|
||||
const input = el('input'); input.dataset.key = key; input.id = `menu-${key}-${id}`; input.value = data[key] || ''; input.placeholder = 'Website-Standard';
|
||||
const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
|
||||
}
|
||||
select('iconMotion', 'Symbol beim Überfahren', { none: 'Ruhig', slide: 'Rückt', reveal: 'Erscheint' }, data.iconMotion || 'none');
|
||||
const only = el('input'); only.type = 'checkbox'; only.dataset.key = 'iconOnly'; only.value = '1'; only.id = `menu-iconOnly-${id}`; only.checked = data.iconOnly === '1';
|
||||
const onlyLabel = el('label', 'check-label', ' Nur das Symbol zeigen'); onlyLabel.htmlFor = only.id; onlyLabel.prepend(only); options.append(onlyLabel);
|
||||
select('buttonMobile', 'Auf dem Telefon', buttonSchema.mobile ?? { menu: 'Im aufgeklappten Menü' }, data.buttonMobile || 'menu');
|
||||
set.append(toggleLabel, hidden, options);
|
||||
return set;
|
||||
}
|
||||
|
||||
/*
|
||||
* Aus Feldern wird Bedienung: das Symbol über das Auswahlfenster statt einer langen Liste, Farben
|
||||
* mit der Palette der Website wie im Editor, und eine Vorschau des Buttons - normal und beim
|
||||
* Überfahren. Gespeichert wird weiter, was in den Feldern steht; ohne Skript bleiben sie sichtbar.
|
||||
*/
|
||||
function enhance(row) {
|
||||
if (row.dataset.enhanced) return;
|
||||
row.dataset.enhanced = '1';
|
||||
const value = key => row.querySelector(`[data-key="${key}"]`);
|
||||
const changed = input => input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
// Symbol: das Auswahlfenster schreibt in die versteckte Auswahl.
|
||||
const iconSelect = value('icon');
|
||||
if (iconSelect) {
|
||||
const holder = iconSelect.closest('div'); holder.hidden = true;
|
||||
const mount = el('div', 'menu-icon-choice'); holder.before(mount);
|
||||
const draw = () => mount.replaceChildren(renderIconField({
|
||||
label: 'Symbol', value: iconSelect.value || 'none', none: 'Kein Symbol',
|
||||
onChoose: name => { iconSelect.value = name === 'none' ? '' : name; changed(iconSelect); draw(); preview(); mount.querySelector('.icon-field-change')?.focus(); },
|
||||
}));
|
||||
draw();
|
||||
}
|
||||
const toggle = value('button'), options = row.querySelector('[data-button-options]');
|
||||
if (!toggle || !options) return;
|
||||
const show = () => { options.hidden = !toggle.checked; };
|
||||
toggle.addEventListener('change', show);
|
||||
for (const wrap of options.querySelectorAll('[data-color-field]')) {
|
||||
const input = wrap.querySelector('input'); const caption = wrap.querySelector('label');
|
||||
input.type = 'hidden';
|
||||
const control = createColorControl({
|
||||
id: `${input.id}-control`, label: caption.textContent, value: input.value, colors: buttonSchema.colors ?? [],
|
||||
onChange: raw => { input.value = raw; changed(input); preview(); },
|
||||
});
|
||||
caption.htmlFor = `${input.id}-control`;
|
||||
wrap.append(control);
|
||||
}
|
||||
const box = el('div', 'menu-button-preview'); box.setAttribute('aria-hidden', 'true');
|
||||
options.prepend(box);
|
||||
function preview() {
|
||||
if (!buttonSchema.buttons) return;
|
||||
const pick = key => value(key)?.value || '';
|
||||
const values = { ...buttonSchema.buttons };
|
||||
for (const [field, key] of [['buttonShape', 'shape'], ['buttonAppearance', 'appearance'], ['buttonSize', 'size'], ['buttonFill', 'backgroundColor'], ['buttonInk', 'color'], ['buttonHoverFill', 'hoverBackgroundColor'], ['buttonHoverInk', 'hoverColor']]) {
|
||||
if (pick(field)) values[key] = pick(field);
|
||||
}
|
||||
const icon = pick('icon'), before = pick('iconPosition') !== 'after', only = value('iconOnly')?.checked && icon;
|
||||
const samples = [['Normal', false], ['Beim Überfahren', true]].map(([title, hover]) => {
|
||||
const state = el('div', 'button-state'); state.append(el('span', 'field-help', title));
|
||||
const sample = buttonSample(only ? '\u200b' : (value('label')?.value || 'Button'), values, buttonSchema, hover);
|
||||
if (icon) {
|
||||
const holder = el('span', 'button-icon'); holder.append(iconSvg(icon));
|
||||
const color = pick('buttonIconColor');
|
||||
if (color) holder.style.color = color.startsWith('@') ? (buttonSchema.colors ?? []).find(entry => `@${entry.key}` === color)?.value || '' : color;
|
||||
if (before) sample.prepend(holder); else sample.append(holder);
|
||||
}
|
||||
state.append(sample); return state;
|
||||
});
|
||||
box.replaceChildren(...samples);
|
||||
}
|
||||
row.addEventListener('input', preview);
|
||||
row.addEventListener('change', preview);
|
||||
show(); preview();
|
||||
}
|
||||
list.querySelectorAll('.menu-entry').forEach(enhance);
|
||||
|
||||
function restore(value) {
|
||||
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
|
||||
}
|
||||
|
||||
+38
-2
@@ -47,8 +47,8 @@ p{hyphens:auto}
|
||||
/* 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}
|
||||
.button-icon,.nav-arrow{display:inline-flex;align-items:center}.button-icon{justify-content:center;flex:none;transition:rotate .25s ease,transform .25s ease,background-color .2s,color .2s,max-width .3s ease,opacity .3s ease,margin .3s ease}
|
||||
.nav-arrow>svg{width:1.15em;height:1.15em}.cms-button .button-icon>svg{width:var(--icon-size,1.15em);height:var(--icon-size,1.15em)}.button.cms-button{gap:var(--icon-gap,.65em)}.cms-button.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(.3em)}.cms-button.icon-start.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(-.3em)}.button.cms-button.icon-motion-reveal{gap:0}.cms-button.icon-motion-reveal .button-icon{max-width:0;opacity:0;overflow:hidden}.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1;margin-inline-start:var(--icon-gap,.65em)}.cms-button.icon-start.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{margin-inline:0 var(--icon-gap,.65em)}.button.cms-button.icon-only{gap:0;padding:var(--b-py,var(--kb-py,15px))}.cms-button.icon-only .button-icon{min-inline-size:1lh}.cms-button.icon-only .button-label{width:0;overflow:hidden;white-space:nowrap}@media (prefers-reduced-motion:reduce){html.motion-respect .cms-button .button-icon{transition:none}}
|
||||
.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
|
||||
@@ -267,6 +267,9 @@ body.consent-open{padding-bottom:320px}
|
||||
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
|
||||
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
|
||||
.block-icon .block-icon{color:inherit}
|
||||
/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe
|
||||
des Knopfs: grün auf Rot, auch beim Überfahren (Hüfner, 06.10.2026). */
|
||||
.button-icon .block-icon{color:inherit}
|
||||
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
||||
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
||||
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
||||
@@ -491,6 +494,33 @@ body.consent-open{padding-bottom:320px}
|
||||
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}
|
||||
/* Menüeinträge als Button (ADR 110). Die Menüregeln für Links (Polster, Unterstreichung, Schrift
|
||||
im aufgeklappten Kopf) gelten nicht für sie - sie sehen aus wie jeder andere Button. */
|
||||
.menu-list a.cms-button,.menu-actions a.cms-button{display:inline-flex;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));font-size:var(--kb-font-size,14px);text-decoration:none;margin:0}
|
||||
.menu-list a.cms-button:hover,.menu-list a.cms-button[aria-current=page]{text-decoration:none}
|
||||
.menu-list a.cms-button.b-desktop-appearance-link{text-decoration:underline}
|
||||
.site-header .menu-burger-body .menu-list a.cms-button{padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));font-size:var(--kb-font-size,14px)}
|
||||
/* Im aufgeklappten Menü volle Breite und etwas Luft zu den Links darüber. */
|
||||
.menu-burger-body .menu-item-button{align-self:stretch;margin-top:8px}
|
||||
.menu-burger-body .menu-item-button>.cms-button:not(.icon-only){width:100%}
|
||||
/* Neben dem Menü-Knopf stehen bleiben: nur solange das Menü zusammengeklappt ist. */
|
||||
.menu-actions{display:none;list-style:none;margin:0;padding:0;gap:8px;align-items:center;flex:0 1 auto;min-width:0}
|
||||
/* Neben dem Menü-Knopf ist wenig Platz: kompakter, in einer Zeile mit dem Knopf. */
|
||||
.menu-actions a.cms-button{--b-py:8px;--b-px:14px;white-space:nowrap}
|
||||
.menu-actions a.cms-button.icon-only{--b-px:8px}
|
||||
nav.menu-burger>.menu-actions{display:flex}
|
||||
nav.menu-burger:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
|
||||
nav.menu-burger .menu-burger-body .menu-keep-visible{display:none}
|
||||
@media (max-width:1024px),(hover:none) and (pointer:coarse){
|
||||
.menu-collapse-tablet>.menu-actions{display:flex}
|
||||
.menu-collapse-tablet:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
|
||||
.menu-collapse-tablet .menu-burger-body .menu-keep-visible{display:none}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.menu-collapse-mobile>.menu-actions{display:flex}
|
||||
.menu-collapse-mobile:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
|
||||
.menu-collapse-mobile .menu-burger-body .menu-keep-visible{display:none}
|
||||
}
|
||||
/* Visibility never overwrites the block's grid/flex/display settings. */
|
||||
.visibility-hidden{display:none!important}
|
||||
.slider-controls button[hidden]{display:none}
|
||||
@@ -540,6 +570,12 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
.block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px}
|
||||
.block-legal a{color:inherit;text-decoration:none}
|
||||
.block-legal a:hover{text-decoration:underline}
|
||||
/* Die Sprachwahl (ADR 111): eine kurze Reihe, die gewählte Sprache fett. Jedes Ziel mindestens
|
||||
24 px, damit es mit dem Finger zu treffen ist. */
|
||||
.block-language ul{display:flex;flex-wrap:wrap;gap:2px;align-items:center;list-style:none;margin:0;padding:0}
|
||||
.block-language a{display:inline-flex;align-items:center;justify-content:center;min-width:24px;min-height:24px;padding:4px 8px;color:inherit;text-decoration:none;font-size:14px;line-height:1.2}
|
||||
.block-language a:hover{text-decoration:underline}
|
||||
.block-language a[aria-current]{font-weight:700}
|
||||
.image-zoom{cursor:zoom-in}
|
||||
.zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible}
|
||||
.zoom-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Übersetzen im Editor (ADR 111).
|
||||
*
|
||||
* Eine Seite wird einmal gebaut; je weitere Sprache trägt ihr Dokument unter `translations` nur
|
||||
* die Texte, adressiert über die ID des Blocks. Dieses Modul kennt die übersetzbaren Felder,
|
||||
* schreibt eine Übersetzung in das Dokument und sagt, was fehlt oder veraltet ist. Es ändert
|
||||
* nie Aufbau, Gestaltung oder die Texte der Hauptsprache.
|
||||
*/
|
||||
import { locate, upgradeDocument, blockLabel } from './editor-model.js';
|
||||
|
||||
/** Dieselbe Liste wie Translations::FIELDS, mit Beschriftung, Länge und Art des Feldes. */
|
||||
export const FIELDS = {
|
||||
heading: [{ prop: 'text', label: 'Überschrift', max: 2000, rich: 'heading' }],
|
||||
text: [{ prop: 'text', label: 'Text', max: 20000, rich: 'text', multiline: true }],
|
||||
button: [{ prop: 'text', label: 'Beschriftung', max: 300 }],
|
||||
image: [{ prop: 'alt', label: 'Alternativtext', max: 300, multiline: true }, { prop: 'caption', label: 'Bildunterschrift', max: 500, multiline: true }],
|
||||
iconcard: [{ prop: 'title', label: 'Titel', max: 120 }, { prop: 'text', label: 'Text', max: 600, multiline: true }],
|
||||
stat: [{ prop: 'value', label: 'Wert', max: 16 }, { prop: 'label', label: 'Beschriftung', max: 80 }],
|
||||
quote: [{ prop: 'text', label: 'Zitat', max: 1000, multiline: true }, { prop: 'author', label: 'Name', max: 120 }, { prop: 'role', label: 'Rolle', max: 120 }],
|
||||
cta: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'text', label: 'Text', max: 600, multiline: true }, { prop: 'buttonText', label: 'Button', max: 80 }],
|
||||
contact: [{ prop: 'address', label: 'Anschrift', max: 200, multiline: true }],
|
||||
accordionitem: [{ prop: 'title', label: 'Titel', max: 160 }],
|
||||
tabitem: [{ prop: 'title', label: 'Titel', max: 160 }],
|
||||
legal: [{ prop: 'label', label: 'Beschriftung für Vorlesesoftware', max: 60 }],
|
||||
postnav: [{ prop: 'previousLabel', label: 'Beschriftung zurück', max: 60 }, { prop: 'nextLabel', label: 'Beschriftung weiter', max: 60 }],
|
||||
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
|
||||
};
|
||||
|
||||
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
|
||||
export const PAGE_FIELDS = [
|
||||
{ key: 'title', label: 'Seitentitel', max: 160 },
|
||||
{ key: 'description', label: 'Beschreibung für Suchmaschinen', max: 320, multiline: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah: FNV-1a über UTF-8, 32 Bit,
|
||||
* als acht Hexziffern - dasselbe wie hash('fnv1a32') in PHP (Translations::fingerprint).
|
||||
*/
|
||||
export function fingerprint(text) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const byte of new TextEncoder().encode(text)) {
|
||||
hash ^= byte;
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
const empty = value => typeof value !== 'string' || value.trim() === '';
|
||||
|
||||
/** Das sichtbare Wort eines formatierten Texts, für Überschriften in der Liste. */
|
||||
export function plainText(markup) {
|
||||
const node = document.createElement('template');
|
||||
node.innerHTML = markup ?? '';
|
||||
return node.content.textContent.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle übersetzbaren Texte des Dokuments in Seitenreihenfolge, je mit Original, Übersetzung und
|
||||
* Zustand: "missing" (fehlt), "stale" (Original seitdem geändert) oder "done".
|
||||
*
|
||||
* @return {Array<{key: string, blockId: string|null, prop: string, label: string, block: object|null, original: string, value: string, state: string, field: object}>}
|
||||
*/
|
||||
export function entries(document, locale) {
|
||||
const translation = document.translations?.[locale] ?? {};
|
||||
const list = [];
|
||||
const push = (key, blockId, prop, field, original, value, block = null) => {
|
||||
if (empty(original)) return;
|
||||
const hash = translation.source?.[key];
|
||||
const state = empty(value) ? 'missing' : hash && hash !== fingerprint(original) ? 'stale' : 'done';
|
||||
list.push({ key, blockId, prop, label: field.label, block, original, value: value ?? '', state, field });
|
||||
};
|
||||
for (const field of PAGE_FIELDS) push(field.key, null, field.key, field, document[field.key], translation[field.key]);
|
||||
const walk = blocks => {
|
||||
for (const block of blocks ?? []) {
|
||||
for (const field of FIELDS[block.type] ?? []) {
|
||||
// Ein dekoratives Bild oder Symbol hat keinen Text, also auch keinen zu übersetzen.
|
||||
if (['alt', 'label'].includes(field.prop) && block.props?.decorative === true) continue;
|
||||
push(`${block.id}.${field.prop}`, block.id, field.prop, field, block.props?.[field.prop], translation.blocks?.[block.id]?.[field.prop], block);
|
||||
}
|
||||
walk(block.children);
|
||||
}
|
||||
};
|
||||
walk(document.blocks);
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Zählt, was übersetzt ist: { total, done, stale }. */
|
||||
export function progress(document, locale) {
|
||||
const list = entries(document, locale);
|
||||
return { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
|
||||
}
|
||||
|
||||
/** Die Übersetzung einer Sprache, angelegt, falls es sie noch nicht gibt. */
|
||||
function ensure(document, locale) {
|
||||
upgradeDocument(document, 27);
|
||||
document.translations ??= {};
|
||||
return (document.translations[locale] ??= {});
|
||||
}
|
||||
|
||||
/** Räumt eine Sprache auf, die nichts mehr enthält, damit das Dokument nicht wächst. */
|
||||
function tidy(document, locale) {
|
||||
const translation = document.translations?.[locale];
|
||||
if (!translation) return;
|
||||
for (const [id, fields] of Object.entries(translation.blocks ?? {})) if (!Object.keys(fields).length) delete translation.blocks[id];
|
||||
if (translation.blocks && !Object.keys(translation.blocks).length) delete translation.blocks;
|
||||
if (translation.source && !Object.keys(translation.source).length) delete translation.source;
|
||||
if (!Object.keys(translation).length) delete document.translations[locale];
|
||||
if (document.translations && !Object.keys(document.translations).length) delete document.translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt einen übersetzten Text. Mit dem Text wird vermerkt, wie das Original gerade aussieht -
|
||||
* ändert es sich später, gilt die Übersetzung als veraltet. Leer heißt: nicht übersetzt.
|
||||
* Läuft innerhalb einer Transaktion der History.
|
||||
*/
|
||||
export function setText(document, locale, key, value) {
|
||||
const translation = ensure(document, locale);
|
||||
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
|
||||
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
|
||||
if (blockId) {
|
||||
translation.blocks ??= {};
|
||||
const fields = (translation.blocks[blockId] ??= {});
|
||||
if (empty(value)) delete fields[prop]; else fields[prop] = value;
|
||||
} else if (empty(value)) delete translation[prop]; else translation[prop] = value;
|
||||
translation.source ??= {};
|
||||
if (empty(value) || typeof original !== 'string') delete translation.source[key];
|
||||
else translation.source[key] = fingerprint(original);
|
||||
tidy(document, locale);
|
||||
}
|
||||
|
||||
/** Bestätigt eine veraltete Übersetzung, ohne sie zu ändern: das Original ist so gemeint. */
|
||||
export function confirmText(document, locale, key) {
|
||||
const translation = document.translations?.[locale];
|
||||
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
|
||||
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
|
||||
if (!translation || typeof original !== 'string') return;
|
||||
(translation.source ??= {})[key] = fingerprint(original);
|
||||
}
|
||||
|
||||
/** Adresse und Suchtitel einer Sprache. Leer heißt: wie in der Hauptsprache beziehungsweise wie der Titel. */
|
||||
export function setPageValue(document, locale, key, value) {
|
||||
const translation = ensure(document, locale);
|
||||
if (empty(value)) delete translation[key]; else translation[key] = key === 'slug' ? value.trim().toLowerCase() : value;
|
||||
tidy(document, locale);
|
||||
}
|
||||
|
||||
/** Aus einem Titel eine Adresse: Kleinbuchstaben, Ziffern, einzelne Bindestriche. */
|
||||
export function slugify(text) {
|
||||
return plainText(text).toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80).replace(/-+$/g, '');
|
||||
}
|
||||
|
||||
/** Kurzer Name eines Blocks für die Liste: Art und der Anfang seines Originaltexts. */
|
||||
export function describeBlock(block) {
|
||||
return blockLabel(block);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ try {
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
|
||||
await page.locator('.form-list a').first().click(); await page.locator('.form-def').waitFor();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
console.log('Public no-JavaScript baseline and visual captures passed.');
|
||||
@@ -76,9 +77,15 @@ try {
|
||||
await page.mouse.move(1, 1); await page.waitForTimeout(7700);
|
||||
assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
assert.equal(await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).isDisabled(), true);
|
||||
// Seit 1.11 entscheidet die Website (Einstellungen → Bewegung): Standard ist „immer abspielen“,
|
||||
// nur mit „Systemvorgabe beachten“ (Klasse motion-respect) hält „Bewegung reduzieren“ die
|
||||
// Automatik an. Geprüft wird das Verhalten, das die Einstellung verspricht.
|
||||
const respects = await page.evaluate(() => document.documentElement.classList.contains('motion-respect'));
|
||||
const play = controls.locator('.slider-play');
|
||||
assert.equal(await play.isDisabled(), respects, 'the play button follows the motion setting');
|
||||
const reduced = await geometry(); await page.waitForTimeout(7100);
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
|
||||
if (respects) assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay when the site respects it');
|
||||
else assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'autoplay keeps running when the site plays motion always');
|
||||
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'manual navigation works with reduced motion');
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
@@ -143,7 +150,7 @@ try {
|
||||
await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click();
|
||||
assert.equal((await menuSave()).status(), 422);
|
||||
assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)');
|
||||
assert.equal(await page.locator('#menu-error').isVisible(), true);
|
||||
await page.locator('#menu-error').waitFor({ state: 'visible' });
|
||||
await entries.last().locator('summary').click();
|
||||
await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt');
|
||||
assert.equal((await menuSave()).status(), 200);
|
||||
@@ -155,15 +162,22 @@ try {
|
||||
await page.getByLabel('Name des Menüs').fill('QA lokaler Stand');
|
||||
assert.equal((await menuSave()).status(), 409);
|
||||
assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand');
|
||||
assert.equal(await page.locator('#menu-error').isVisible(), true);
|
||||
await page.locator('#menu-error').waitFor({ state: 'visible' });
|
||||
checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true });
|
||||
|
||||
await page.goto(origin + '/admin/forms');
|
||||
await page.locator('.form-list a').first().click();
|
||||
const form = page.locator('.form-def').first(), message = form.locator('[name="success"]');
|
||||
// Der Abschnitt „Zustellung“ ist zugeklappt, bis man ihn öffnet.
|
||||
const openDelivery = async () => {
|
||||
await form.locator('details.form-section').first().evaluate(section => { section.open = true; });
|
||||
};
|
||||
await openDelivery();
|
||||
const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth }));
|
||||
assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width');
|
||||
await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
|
||||
await form.getByRole('button', { name: 'Formular speichern', exact: true }).click();
|
||||
await page.waitForLoadState('load'); await openDelivery();
|
||||
assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
|
||||
checks.push({ name: 'wide multiline form response persists', passed: true });
|
||||
|
||||
@@ -172,7 +186,10 @@ try {
|
||||
await page.getByLabel('Name', { exact: true }).fill('QA Gemeinsame Sektion');
|
||||
await page.getByRole('button', { name: 'Anlegen und bearbeiten', exact: true }).click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
// Die Sektion im Reiter „Struktur“ wählen, dann den Text aus „Elemente“ hineinsetzen.
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator('#structure .tree-item').first().click();
|
||||
await page.getByRole('tab', { name: 'Elemente', exact: true }).click();
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await page.getByLabel('Text', { exact: true }).fill('Dieser Inhalt wird als gemeinsame Sektion gespeichert.');
|
||||
await page.getByLabel('Text', { exact: true }).press('Tab');
|
||||
@@ -197,7 +214,7 @@ try {
|
||||
|
||||
await page.goto(origin + '/admin/pages/' + fixture.homeId);
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
|
||||
await page.locator('#preview-motion').click();
|
||||
const preview = page.frameLocator('#preview'), previewSlider = preview.locator('#hero-slider');
|
||||
// Measure in the outer page: Playwright's in-frame animation stability check can stall in
|
||||
// a no-script sandbox while a Web Animation runs. Use a real pointer on verified geometry.
|
||||
@@ -210,7 +227,10 @@ try {
|
||||
await page.waitForTimeout(800);
|
||||
assert.ok(await previewSlider.evaluate(slider => slider.scrollLeft > 20));
|
||||
assert.equal(await page.locator('#preview').getAttribute('sandbox'), 'allow-same-origin');
|
||||
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
|
||||
// Derselbe Knopf stoppt wieder; er sagt es in seiner Beschriftung und in aria-pressed.
|
||||
assert.equal(await page.locator('#preview-motion').getAttribute('aria-label'), 'Bewegung in der Vorschau stoppen');
|
||||
await page.locator('#preview-motion').click();
|
||||
assert.equal(await page.locator('#preview-motion').getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(await preview.locator('.slider-controls').count(), 0);
|
||||
checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true });
|
||||
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ const portProbe = net.createServer();
|
||||
await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve));
|
||||
const port = portProbe.address().port;
|
||||
await new Promise(resolve => portProbe.close(resolve));
|
||||
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
|
||||
// CMS_HOST_RULES, etwa "MAP localhost 127.0.0.1": für eingebettete Seiten, die das Einbetten nur
|
||||
// von localhost erlauben, während der Server auf 127.0.0.1 lauscht (Kantarci-Markt-App).
|
||||
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`, ...(process.env.CMS_HOST_RULES ? [`--host-resolver-rules=${process.env.CMS_HOST_RULES}`] : [])] });
|
||||
const summary = [];
|
||||
try {
|
||||
for (const path of paths) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bereitet die Bilder des Hüfner-Entwurfs für die Mediathek vor.
|
||||
*
|
||||
* Kopiert die Fotos und das Logo aus dem gelieferten Ordner (images/) und schneidet aus dem Logo
|
||||
* den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
|
||||
* Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar.
|
||||
*
|
||||
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner>
|
||||
*/
|
||||
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||
if ('' === $quelle || '' === $ziel || !is_dir($quelle.'/images')) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-huefner-assets.php <Ordner mit images/> <Zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach (glob($quelle.'/images/*.{jpg,png}', GLOB_BRACE) ?: [] as $datei) {
|
||||
copy($datei, $ziel.'/'.basename($datei));
|
||||
}
|
||||
|
||||
// Baum und Steine: im 1600 x 607 px großen Logo etwa x 15–500, y 10–560.
|
||||
$logo = imagecreatefrompng($quelle.'/images/logo.png');
|
||||
if (false === $logo) {
|
||||
fwrite(STDERR, "logo.png lässt sich nicht lesen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$kante = 512;
|
||||
$symbol = imagecreatetruecolor($kante, $kante);
|
||||
imagesavealpha($symbol, true);
|
||||
imagealphablending($symbol, false);
|
||||
imagefill($symbol, 0, 0, imagecolorallocatealpha($symbol, 0, 0, 0, 127));
|
||||
imagealphablending($symbol, true);
|
||||
[$x, $y, $b, $h] = [15, 10, 490, 550];
|
||||
$massstab = min(($kante - 24) / $b, ($kante - 24) / $h);
|
||||
$zb = (int) round($b * $massstab);
|
||||
$zh = (int) round($h * $massstab);
|
||||
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
|
||||
imagepng($symbol, $ziel.'/favicon.png');
|
||||
echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n";
|
||||
@@ -0,0 +1,19 @@
|
||||
// Schreibt den Symbolsatz aus public/assets/icons.js in die Twig-Vorlage der Website und in die
|
||||
// erlaubte Liste des Validators. Eine Quelle, drei Abnehmer; tests/icons.test.mjs prüft das Ergebnis.
|
||||
// Aufruf: node scripts/build-icons.mjs
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { ICON_PATHS } from '../public/assets/icons.js';
|
||||
|
||||
const twigFile = new URL('../templates/blocks/icon.html.twig', import.meta.url);
|
||||
const twig = await readFile(twigFile, 'utf8');
|
||||
const entries = Object.entries(ICON_PATHS).map(([name, path]) => ` ${/-/.test(name) ? `'${name}'` : name}: '${path}'`).join(',\n');
|
||||
const nextTwig = twig.replace(/\{% set paths = \{\n[\s\S]*?\n\} %\}/, `{% set paths = {\n${entries}\n} %}`);
|
||||
if (nextTwig === twig && !twig.includes(entries)) throw new Error('Die Symbolliste in icon.html.twig wurde nicht gefunden.');
|
||||
await writeFile(twigFile, nextTwig);
|
||||
|
||||
const validatorFile = new URL('../src/Content/DocumentValidator.php', import.meta.url);
|
||||
const validator = await readFile(validatorFile, 'utf8');
|
||||
const list = Object.keys(ICON_PATHS).map(name => `'${name}'`).join(', ');
|
||||
const nextValidator = validator.replace(/public const ICONS = \[[^\]]*\];/, `public const ICONS = [${list}];`);
|
||||
await writeFile(validatorFile, nextValidator);
|
||||
console.log(`${Object.keys(ICON_PATHS).length} Symbole geschrieben.`);
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Bereitet die Bilder der Seite „Kantarci Markt – Neueröffnung“ für die Mediathek vor.
|
||||
*
|
||||
* Die Mediathek nimmt kein SVG. Das Seitensymbol und der runde Stempel „Frische • Qualität •
|
||||
* Vertrauen“ der Vorlage sind aber SVG (der Stempel mit Text auf einem Kreisbogen, das kann kein
|
||||
* Baustein). Chromium rastert beide zu PNG. Das Logo der Vorlage (1536 × 1024, 1,3 MB) wird auf
|
||||
* seinen sichtbaren Teil zugeschnitten und auf 600 px Breite verkleinert (doppelte Anzeigebreite),
|
||||
* angezeigt wird es mit höchstens 300 px.
|
||||
*
|
||||
* node scripts/build-kantarci-markt-assets.mjs [ordner]
|
||||
* → logo.png, symbol.png, stempel.png im Ordner (Standard var/kantarci-markt-assets)
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ordner = process.argv[2] || 'var/kantarci-markt-assets';
|
||||
const FORST = '#123b26', GOLD = '#a88332', GOLD_HELL = '#d2b978';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const seite = await browser.newPage();
|
||||
|
||||
// Logo: sichtbaren Bereich über den Alphakanal bestimmen, zuschneiden, verkleinern.
|
||||
const logo = (await readFile(`${ordner}/kantarci-markt-logo.png`)).toString('base64');
|
||||
const logoPng = await seite.evaluate(async (daten) => {
|
||||
const bild = new Image();
|
||||
bild.src = `data:image/png;base64,${daten}`;
|
||||
await bild.decode();
|
||||
const quelle = new OffscreenCanvas(bild.width, bild.height);
|
||||
const q = quelle.getContext('2d');
|
||||
q.drawImage(bild, 0, 0);
|
||||
const { data } = q.getImageData(0, 0, bild.width, bild.height);
|
||||
let [x0, y0, x1, y1] = [bild.width, bild.height, 0, 0];
|
||||
for (let y = 0; y < bild.height; y++) {
|
||||
for (let x = 0; x < bild.width; x++) {
|
||||
// Der Schein um die Waage läuft sehr flach aus; erst ab etwa 8 % Deckkraft zählt er.
|
||||
if (data[(y * bild.width + x) * 4 + 3] > 20) {
|
||||
x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rand = 8;
|
||||
x0 = Math.max(0, x0 - rand); y0 = Math.max(0, y0 - rand);
|
||||
x1 = Math.min(bild.width - 1, x1 + rand); y1 = Math.min(bild.height - 1, y1 + rand);
|
||||
const breite = x1 - x0 + 1, hoehe = y1 - y0 + 1;
|
||||
const ziel = Math.min(600, breite);
|
||||
const leinwand = new OffscreenCanvas(ziel, Math.round(hoehe * ziel / breite));
|
||||
const z = leinwand.getContext('2d');
|
||||
z.imageSmoothingQuality = 'high';
|
||||
z.drawImage(bild, x0, y0, breite, hoehe, 0, 0, leinwand.width, leinwand.height);
|
||||
const blob = await leinwand.convertToBlob({ type: 'image/png' });
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
let binaer = '';
|
||||
for (const b of bytes) binaer += String.fromCharCode(b);
|
||||
return { png: btoa(binaer), breite: leinwand.width, hoehe: leinwand.height, ausschnitt: [x0, y0, breite, hoehe] };
|
||||
}, logo);
|
||||
await writeFile(`${ordner}/logo.png`, Buffer.from(logoPng.png, 'base64'));
|
||||
console.log(`logo.png ${logoPng.breite} × ${logoPng.hoehe} (Ausschnitt ${logoPng.ausschnitt.join(', ')})`);
|
||||
|
||||
const rastern = async (svg, groesse, datei) => {
|
||||
await seite.setViewportSize({ width: groesse, height: groesse });
|
||||
await seite.setContent(`<!doctype html><style>html,body{margin:0;background:transparent}svg{display:block}</style>${svg}`);
|
||||
await seite.screenshot({ path: `${ordner}/${datei}`, omitBackground: true });
|
||||
console.log(`${datei} ${groesse} × ${groesse}`);
|
||||
};
|
||||
|
||||
// Seitensymbol: das K mit Blatt aus dem Favicon der Vorlage.
|
||||
await rastern(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256">
|
||||
<rect width="64" height="64" rx="14" fill="#062f24"/>
|
||||
<path d="M19 43V18h7v10l10-10h9L33 30l13 13h-9L26 32v11z" fill="${GOLD}"/>
|
||||
<path d="M41 10c-7 2-10 6-9 13 6-1 10-5 9-13Z" fill="${GOLD}"/></svg>`, 256, 'symbol.png');
|
||||
|
||||
// Stempel: goldene Scheibe, Umschrift auf dem Kreis, Blatt in der Mitte. Doppelte Größe für
|
||||
// scharfe Kanten auf hochauflösenden Bildschirmen (angezeigt mit 122 px).
|
||||
await rastern(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122 122" width="244" height="244">
|
||||
<circle cx="61" cy="61" r="61" fill="${GOLD_HELL}"/>
|
||||
<g transform="translate(10 10) scale(1.1333)">
|
||||
<defs><path id="kreis" d="M45,45 m-31,0 a31,31 0 1,1 62,0 a31,31 0 1,1 -62,0"/></defs>
|
||||
<text font-family="Arial, sans-serif" font-weight="700" font-size="8" letter-spacing="1.4" fill="${FORST}"><textPath href="#kreis">FRISCHE • QUALITÄT • VERTRAUEN • </textPath></text>
|
||||
<path d="M45 52c-2-16 6-25 22-26-1 16-8 24-22 26Zm0 0c-8-11-17-13-27-7 7 12 16 14 27 7Z" fill="${FORST}"/>
|
||||
</g></svg>`, 244, 'stempel.png');
|
||||
|
||||
await browser.close();
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bereitet die Bilder des Kantarci-Reisen-Entwurfs für die Mediathek vor.
|
||||
*
|
||||
* Aus dem gelieferten Paket (Website/ und Logo/) entstehen:
|
||||
* - logo.png: das Logo ohne den breiten durchsichtigen Rand (im Original rund ein Viertel der Fläche),
|
||||
* - favicon.png: das „K“ mit Sonne und Schwingen als quadratisches Seitensymbol,
|
||||
* - kueste.webp: das Reisefoto der Vorlage, unverändert,
|
||||
* - reisesoft-uebersicht.png: der Screenshot der reiseSoft-Übersicht, falls als zweites Argument
|
||||
* angegeben (aufgenommen aus einer lokalen Instanz mit den fiktiven Demodaten von reiseSoft).
|
||||
*
|
||||
* Aufruf: php scripts/build-kantarci-reisen-assets.php <Paketordner Kantarci-Reisen> <Zielordner> [Screenshot]
|
||||
*/
|
||||
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||
$screenshot = $argv[3] ?? '';
|
||||
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/Website/coast.webp')) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-kantarci-reisen-assets.php <Ordner Kantarci-Reisen> <Zielordner> [Screenshot]\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
copy($quelle.'/Website/coast.webp', $ziel.'/kueste.webp');
|
||||
if ('' !== $screenshot) {
|
||||
copy($screenshot, $ziel.'/reisesoft-uebersicht.png');
|
||||
}
|
||||
|
||||
$logo = imagecreatefrompng($quelle.'/Logo/kantarci-reisen-logo-original.png');
|
||||
if (false === $logo) {
|
||||
fwrite(STDERR, "Das Logo lässt sich nicht lesen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$breite = imagesx($logo);
|
||||
$hoehe = imagesy($logo);
|
||||
|
||||
/** Kleinstes Rechteck um alle sichtbaren Pixel (Deckkraft über 10 %) innerhalb eines Bereichs. */
|
||||
$rahmen = static function (int $x0, int $y0, int $x1, int $y1) use ($logo): array {
|
||||
[$links, $oben, $rechts, $unten] = [$x1, $y1, $x0, $y0];
|
||||
for ($y = $y0; $y < $y1; $y += 2) {
|
||||
for ($x = $x0; $x < $x1; $x += 2) {
|
||||
if ((imagecolorat($logo, $x, $y) >> 24 & 0x7F) < 115) {
|
||||
$links = min($links, $x);
|
||||
$rechts = max($rechts, $x);
|
||||
$oben = min($oben, $y);
|
||||
$unten = max($unten, $y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$links, $oben, $rechts - $links + 1, $unten - $oben + 1];
|
||||
};
|
||||
|
||||
$neu = static function (int $b, int $h) {
|
||||
$bild = imagecreatetruecolor($b, $h);
|
||||
imagesavealpha($bild, true);
|
||||
imagealphablending($bild, false);
|
||||
imagefill($bild, 0, 0, imagecolorallocatealpha($bild, 0, 0, 0, 127));
|
||||
imagealphablending($bild, true);
|
||||
|
||||
return $bild;
|
||||
};
|
||||
|
||||
// Ganzes Logo mit 12 px Luft.
|
||||
[$x, $y, $b, $h] = $rahmen(0, 0, $breite, $hoehe);
|
||||
$rand = 12;
|
||||
$zuschnitt = $neu($b + 2 * $rand, $h + 2 * $rand);
|
||||
imagecopy($zuschnitt, $logo, $rand, $rand, $x, $y, $b, $h);
|
||||
imagepng($zuschnitt, $ziel.'/logo.png');
|
||||
|
||||
// Das Bildzeichen steht links bis etwa 38 % der Breite. Die obere Schwinge reicht dabei über den
|
||||
// Anfang des Schriftzugs; das „K“ von KANTARCI beginnt erst unterhalb von ihr (ab etwa 35,5 % der
|
||||
// Breite und 40 % der Höhe). Diese Ecke wird vor dem Zuschnitt geleert.
|
||||
imagealphablending($logo, false);
|
||||
imagefilledrectangle($logo, (int) ($breite * 0.355), (int) ($hoehe * 0.40), $breite - 1, $hoehe - 1, imagecolorallocatealpha($logo, 0, 0, 0, 127));
|
||||
[$x, $y, $b, $h] = $rahmen(0, 0, (int) ($breite * 0.40), $hoehe);
|
||||
$kante = 512;
|
||||
$symbol = $neu($kante, $kante);
|
||||
$massstab = min(($kante - 40) / $b, ($kante - 40) / $h);
|
||||
$zb = (int) round($b * $massstab);
|
||||
$zh = (int) round($h * $massstab);
|
||||
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
|
||||
imagepng($symbol, $ziel.'/favicon.png');
|
||||
|
||||
echo "Bilder vorbereitet: $ziel\n";
|
||||
@@ -30,6 +30,13 @@ try {
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
// Ein Baustein wird im Reiter „Struktur“ gewählt und mit „Bearbeiten“ geöffnet - die
|
||||
// Seitenleiste zeigt seit 1.11 nur eine Ansicht auf einmal.
|
||||
const selectBlock = async id => {
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator(`#structure [data-block-id="${id}"]`).click();
|
||||
await page.locator(`#structure [data-edit-block="${id}"]`).click();
|
||||
};
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
@@ -53,7 +60,7 @@ try {
|
||||
checks.push('global typography, visual presets, normal/hover preview and actual form save including empty palette rows');
|
||||
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await page.locator('#structure [data-block-id="qa-button"]').click();
|
||||
await selectBlock('qa-button');
|
||||
await page.getByRole('button', { name: 'Button-Vorlage Abgerundet', exact: true }).click();
|
||||
const waitVariant = async (id, radius, background) => page.waitForFunction(({id,radius,background}) => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, el = doc?.getElementById(id);
|
||||
@@ -75,7 +82,7 @@ try {
|
||||
await page.locator('[data-viewport="desktop"]').click(); await waitVariant('qa-button', '8px', false);
|
||||
await page.locator('.button-designer').screenshot({ path: `${report}/editor-controls.png` });
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await page.locator('#structure [data-block-id="qa-button"]').click(); await waitVariant('qa-button', '8px', false);
|
||||
await page.reload(); await selectBlock('qa-button'); await waitVariant('qa-button', '8px', false);
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
assert.equal(stored.schemaVersion, 21);
|
||||
assert.deepEqual(stored.blocks[0].children[1].props, { text: 'Mehr erfahren', href: '/kontakt' });
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Prüft das Symbol am Button im echten Editor auf einer isolierten Kopie der Pilotdaten: Auswahl-
|
||||
// fenster mit Kategorien, Tastatur, Stellung, Bewegung, nur Symbol, Farbe und Größe unter Design,
|
||||
// Rückgängig, Speichern und Neuladen. Screenshots unter reports/button-icons/.
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/button-icons'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
const selectBlock = async id => {
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator(`#structure [data-block-id="${id}"]`).click();
|
||||
await page.locator(`#structure [data-edit-block="${id}"]`).click();
|
||||
};
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
data.document.blocks.unshift({ id: 'qa-icons', type: 'section', children: [
|
||||
{ id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } },
|
||||
] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
|
||||
const inPreview = fn => page.evaluate(fn);
|
||||
const waitPreview = async expected => page.waitForFunction(expected => {
|
||||
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button');
|
||||
if (!el) return false;
|
||||
const path = el.querySelector('.button-icon svg path')?.getAttribute('d') ?? null;
|
||||
const classes = el.className;
|
||||
return (expected.path === undefined || (expected.path === null ? path === null : path?.startsWith(expected.path)))
|
||||
&& (expected.first === undefined || ((el.firstChild?.nodeType === 1 && el.firstChild.classList.contains('button-icon')) === expected.first))
|
||||
&& (expected.has ?? []).every(name => classes.includes(name))
|
||||
&& (expected.not ?? []).every(name => !classes.includes(name));
|
||||
}, expected);
|
||||
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await selectBlock('qa-icon-button');
|
||||
const field = page.locator('.icon-field');
|
||||
await field.waitFor();
|
||||
assert.equal(await field.locator('.icon-field-name').textContent(), 'Pfeil schräg');
|
||||
assert.equal(await page.locator('.icon-choice').count(), 0, 'Im Panel steht keine Kachelwand.');
|
||||
await waitPreview({ path: 'M6.5 17.5', first: false });
|
||||
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-vorher.png` }).catch(() => {});
|
||||
checks.push('Panel: nur das gewählte Symbol (Pfeil schräg) mit „Ändern …“');
|
||||
|
||||
// Das Fenster: Kategorien links, Symbole mit Namen, geöffnet bei der Kategorie des Pfeils.
|
||||
await page.getByRole('button', { name: /Symbol ändern, aktuell: Pfeil schräg/ }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
|
||||
await dialog.waitFor();
|
||||
assert.equal(await dialog.getByRole('tab').count(), 7);
|
||||
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Pfeile');
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'arrow-up-right', 'Fokus auf dem gewählten Symbol');
|
||||
const axe = await new AxeBuilder({ page }).include('.icon-dialog').analyze();
|
||||
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe im Auswahlfenster');
|
||||
await dialog.getByRole('tab', { name: 'Haus & Handwerk' }).click();
|
||||
await dialog.screenshot({ path: `${report}/fenster.png` });
|
||||
// Escape schließt ohne Änderung, der Fokus geht zurück zum Knopf.
|
||||
await page.keyboard.press('Escape');
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? ''), /Symbol ändern/);
|
||||
await waitPreview({ path: 'M6.5 17.5' });
|
||||
checks.push('Fenster: 7 Kategorien, Fokus auf dem Gewählten, axe ohne Befund, Escape ändert nichts');
|
||||
|
||||
// Tastatur: Kategorie mit Pfeiltaste wechseln, Symbol wählen.
|
||||
await page.getByRole('button', { name: /Symbol ändern/ }).click();
|
||||
await dialog.getByRole('tab', { name: 'Pfeile' }).focus();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Kontakt & Ort');
|
||||
await dialog.getByRole('button', { name: 'Telefon', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
await waitPreview({ path: 'M6 3h4', first: false });
|
||||
assert.equal(await field.locator('.icon-field-name').textContent(), 'Telefon');
|
||||
checks.push('Telefon über die Kategorie „Kontakt & Ort“ gewählt');
|
||||
|
||||
await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click();
|
||||
await waitPreview({ first: true, has: ['icon-start'] });
|
||||
await page.getByRole('button', { name: 'Rückt', exact: true }).click();
|
||||
await waitPreview({ has: ['icon-motion-slide'] });
|
||||
await page.getByRole('button', { name: 'Erscheint', exact: true }).click();
|
||||
await waitPreview({ has: ['icon-motion-reveal'], not: ['icon-motion-slide'] });
|
||||
await page.locator('#undo').click(); await waitPreview({ has: ['icon-motion-slide'] });
|
||||
await page.locator('#redo').click(); await waitPreview({ has: ['icon-motion-reveal'] });
|
||||
checks.push('Stellung und Bewegung, Rückgängig und Wiederholen');
|
||||
|
||||
await page.getByLabel('Nur das Symbol zeigen').check();
|
||||
await waitPreview({ has: ['icon-only'] });
|
||||
const label = await inPreview(() => document.querySelector('#preview').contentDocument.querySelector('#qa-icon-button .button-label')?.textContent);
|
||||
assert.equal(label, 'Jetzt anrufen', 'Beschriftung bleibt für Vorlesesoftware');
|
||||
await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-nachher.png` }).catch(() => {});
|
||||
checks.push('Nur Symbol: Beschriftung unsichtbar im Link');
|
||||
|
||||
// Design: Gruppe „Symbol“ mit Farbe, Fläche, Größe, Drehung.
|
||||
await page.getByRole('button', { name: 'Farbe, Größe und Fläche des Symbols → Design' }).click();
|
||||
const group = page.locator('.design-group[data-group="icon"]');
|
||||
await group.waitFor();
|
||||
assert.equal(await group.evaluate(el => el.open), true);
|
||||
for (const name of ['Symbolfarbe', 'Fläche hinter dem Symbol', 'Symbolgröße', 'Innenabstand der Fläche', 'Ecken der Fläche', 'Drehung', 'Abstand zum Text']) {
|
||||
assert.ok(await group.getByText(name, { exact: true }).count() > 0, `${name} fehlt in der Gruppe Symbol`);
|
||||
}
|
||||
await group.locator('#style-iconSize').fill('24');
|
||||
await group.locator('#style-iconSize').press('Enter');
|
||||
await page.waitForFunction(() => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, svg = doc?.querySelector('#qa-icon-button .button-icon svg');
|
||||
return svg && Math.round(svg.getBoundingClientRect().width) === 24;
|
||||
});
|
||||
await group.screenshot({ path: `${report}/design-symbol.png` });
|
||||
checks.push('Design → Symbol: alle sieben Werte da, Größe 24 px wirkt in der Vorschau');
|
||||
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
const button = stored.blocks[0].children[0];
|
||||
assert.equal(stored.schemaVersion, 26);
|
||||
assert.deepEqual(button.props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'phone', iconPosition: 'start', iconMotion: 'reveal', iconOnly: true });
|
||||
assert.equal(button.style.desktop.iconSize, '24px');
|
||||
await page.reload(); await selectBlock('qa-icon-button');
|
||||
await page.locator('.icon-field').waitFor();
|
||||
assert.equal(await page.locator('.icon-field-name').textContent(), 'Telefon');
|
||||
assert.equal(await page.getByLabel('Nur das Symbol zeigen').isChecked(), true);
|
||||
checks.push('gespeichert als Schema v26 mit Größe unter Design, nach Neuladen gleich');
|
||||
|
||||
// So sieht es aus: Pfeil in einer runden Fläche, die sich beim Überfahren dreht und umfärbt,
|
||||
// daneben ein Button nur aus Symbol.
|
||||
const look = (await (await context.request.get(api)).json());
|
||||
look.document.blocks[0].children = [
|
||||
{ id: 'qa-look-a', type: 'button', props: { text: 'Projekt anfragen', href: '/kontakt', icon: 'arrow-right', iconMotion: 'slide' }, style: { desktop: { iconColor: '#ffffff', iconBackgroundColor: '#b71c22', iconPadding: '6px', iconRadius: '999px', iconSize: '16px', hover: { iconBackgroundColor: '#1e2a20', iconRotate: '-45deg' } } } },
|
||||
{ id: 'qa-look-b', type: 'button', props: { text: 'Anrufen', href: 'tel:+4972296992826', icon: 'phone', iconOnly: true } },
|
||||
{ id: 'qa-look-c', type: 'button', props: { text: 'Prospekt laden', href: '/', icon: 'download', iconPosition: 'start' } },
|
||||
];
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': look.csrfToken }, data: { document: look.document, expectedVersion: look.version } })).status(), 200);
|
||||
await page.reload();
|
||||
const frame = page.frameLocator('#preview');
|
||||
await frame.locator('#qa-look-a .button-icon').waitFor();
|
||||
const section = frame.locator('#qa-icons');
|
||||
await section.screenshot({ path: `${report}/aussehen-normal.png` });
|
||||
const styles = await frame.locator('#qa-look-a .button-icon').evaluate(el => { const s = getComputedStyle(el); return { bg: s.backgroundColor, radius: s.borderRadius, color: s.color }; });
|
||||
assert.deepEqual(styles, { bg: 'rgb(183, 28, 34)', radius: '999px', color: 'rgb(255, 255, 255)' });
|
||||
await frame.locator('#qa-look-a').hover();
|
||||
await page.waitForTimeout(400);
|
||||
await section.screenshot({ path: `${report}/aussehen-hover.png` });
|
||||
assert.equal(await frame.locator('#qa-look-a .button-icon').evaluate(el => getComputedStyle(el).rotate), '-45deg');
|
||||
const only = await frame.locator('#qa-look-b').evaluate(el => ({ name: el.textContent.trim(), w: Math.round(el.getBoundingClientRect().width), h: Math.round(el.getBoundingClientRect().height) }));
|
||||
assert.equal(only.name, 'Anrufen');
|
||||
assert.ok(Math.abs(only.w - only.h) <= 2, `Nur-Symbol-Button ist nicht quadratisch: ${only.w}x${only.h}`);
|
||||
checks.push('Aussehen: farbige runde Fläche, Drehung beim Überfahren, quadratischer Nur-Symbol-Button, Symbol vor dem Text');
|
||||
await selectBlock('qa-look-c');
|
||||
|
||||
// Kein Symbol: Nur-Symbol fällt weg, die Einstellungen verschwinden.
|
||||
await page.getByRole('button', { name: /Symbol ändern/ }).click();
|
||||
await dialog.getByRole('button', { name: 'Kein Symbol', exact: true }).click();
|
||||
await page.waitForFunction(() => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-look-c'); return el && !el.querySelector('.button-icon'); });
|
||||
assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0);
|
||||
checks.push('Kein Symbol entfernt auch „nur Symbol“');
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));
|
||||
console.log(checks.map(check => `✓ ${check}`).join('\n'));
|
||||
} finally {
|
||||
await browser?.close();
|
||||
server.kill();
|
||||
}
|
||||
@@ -1147,9 +1147,8 @@ try {
|
||||
assert.equal(await savedOg.inputValue(), chosenOg, 'the image survives saving');
|
||||
checks.push({ name: 'image fields in the content settings pick from the library', passed: true });
|
||||
// Formular-Editor: Bedingung, Antwortadresse und Bild je Option werden gewählt, nicht getippt.
|
||||
await page.goto(origin + '/admin/forms');
|
||||
await page.goto(origin + '/admin/forms/neu');
|
||||
const neu = page.locator('#neues-formular');
|
||||
if (!(await neu.evaluate(details => details.open))) await neu.locator('summary').click();
|
||||
await neu.locator('[name="name"]').fill('Anfrage Prüfung');
|
||||
await neu.locator('[name="slug"]').fill('anfrage-pruefung');
|
||||
await neu.locator('[name="recipient"]').fill('info@example.com');
|
||||
@@ -1189,6 +1188,8 @@ try {
|
||||
await page.locator('dialog.page-dialog [role="option"]').first().click();
|
||||
await page.locator('dialog.page-dialog').waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#link-url').inputValue(), /^\/[a-z0-9\-/]*$/);
|
||||
await page.locator('#link-label').fill('Eigener Prüflink');
|
||||
await page.locator('#menu-add-url').click();
|
||||
await page.locator('#menu-search').focus();
|
||||
await page.locator('#menu-search-results [role="option"]').first().click();
|
||||
await page.locator('#menu-add-page').click();
|
||||
@@ -1196,11 +1197,21 @@ try {
|
||||
const sprungziel = neuerEintrag.getByLabel('Sprungziel', { exact: true });
|
||||
assert.equal(await sprungziel.evaluate(node => node.tagName), 'SELECT', 'the anchor is a choice');
|
||||
assert.equal(await sprungziel.locator('option').first().textContent(), 'Seitenanfang');
|
||||
// Nur die neuen Felder: die Zusammenfassung der Menüeinträge trägt seit jeher einen Ziehknopf
|
||||
// (nested-interactive), das ist eine eigene, bekannte Baustelle.
|
||||
// Die ganze Seite: seit der Ziehknopf neben der Zusammenfassung steht, gibt es keinen Knopf
|
||||
// mehr in einem Knopf.
|
||||
await neuerEintrag.locator('summary').click();
|
||||
const menuAxe = await new AxeBuilder({ page }).include('#menu-items .menu-entry:last-child .menu-entry-fields').include('.menu-add').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(menuAxe.violations.map(v => v.id), [], 'anchor choice and link field');
|
||||
await axe('menu editor');
|
||||
await page.screenshot({ path: 'reports/editor/menu-editor.png' });
|
||||
assert.equal(await neuerEintrag.locator('summary [data-menu-drag]').count(), 0, 'no button inside the summary');
|
||||
// Ziehknopf per Tastatur: Pfeil nach oben verschiebt den Eintrag.
|
||||
const eintraege = page.locator('#menu-items .menu-entry');
|
||||
const anzahl = await eintraege.count();
|
||||
assert.ok(anzahl >= 2);
|
||||
const titel = await neuerEintrag.locator('.menu-entry-title').textContent();
|
||||
await eintraege.nth(anzahl - 1).locator('[data-menu-drag]').focus();
|
||||
await page.keyboard.press('ArrowUp');
|
||||
assert.equal(await eintraege.nth(anzahl - 2).locator('.menu-entry-title').textContent(), titel, 'the entry moved up');
|
||||
assert.equal(await eintraege.nth(anzahl - 2).locator('[data-menu-drag]').evaluate(node => node === document.activeElement), true, 'the handle keeps focus');
|
||||
checks.push({ name: 'menu: page search for own links, anchors as a choice', passed: true });
|
||||
// Beitrag: vorhandene Kategorien stehen zum Anklicken am Feld.
|
||||
await page.goto(origin + '/admin/posts');
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Prüft Menü-Buttons (ADR 110) auf einer isolierten Kopie der Pilotdaten: in der Menüverwaltung
|
||||
// einen eigenen Link als Button gestalten (Form, Symbol aus dem Fenster, Palettenfarbe, Telefon-
|
||||
// Anzeige), Vorschau, Speichern, Neuladen - und die öffentliche Seite auf Desktop und Telefon.
|
||||
// Screenshots unter reports/menu-buttons/.
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/menu-buttons'; await mkdir(report, { recursive: true });
|
||||
let browser;
|
||||
const checks = [], errors = [];
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
const page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
|
||||
await page.goto(origin + '/admin/menus');
|
||||
await page.locator('summary', { hasText: 'Eigener Link' }).click();
|
||||
await page.locator('#link-label').fill('Angebot anfragen');
|
||||
await page.locator('#link-url').fill('/kontakt');
|
||||
await page.locator('#menu-add-url').click();
|
||||
const row = page.locator('.menu-entry').last();
|
||||
await row.locator('summary').click();
|
||||
// Ohne Häkchen keine Button-Optionen.
|
||||
assert.equal(await row.locator('[data-button-options]').isVisible(), false);
|
||||
await row.getByLabel('Als Button darstellen').check();
|
||||
const options = row.locator('[data-button-options]');
|
||||
await options.waitFor();
|
||||
// Ein Klick in die leere Mitte der Häkchenzeile schaltet nichts um.
|
||||
const box = await row.locator('[data-button-fields]').boundingBox();
|
||||
const label = await row.locator('label[for^="menu-button-"]').boundingBox();
|
||||
await page.mouse.click(box.x + box.width * 0.6, label.y + label.height / 2);
|
||||
assert.equal(await row.getByLabel('Als Button darstellen').isChecked(), true, 'Klick in die Leere klappt die Einstellungen nicht zu');
|
||||
assert.equal(await options.isVisible(), true);
|
||||
await options.getByLabel('Form').selectOption('pill');
|
||||
// Symbol über das Fenster.
|
||||
await row.getByRole('button', { name: /Symbol ändern/ }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Symbol wählen' });
|
||||
await dialog.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
assert.equal(await row.locator('.icon-field-name').textContent(), 'Pfeil nach rechts');
|
||||
await row.getByLabel('Symbol steht').selectOption('after');
|
||||
// Fläche aus der Palette, Schrift als eigener Wert.
|
||||
await options.locator('.color-trigger').first().click();
|
||||
await options.locator('.color-choices:not([hidden]) .color-option').nth(1).click();
|
||||
const fill = await row.locator('input[data-key="buttonFill"]').inputValue();
|
||||
assert.match(fill, /^@[a-z]/, 'Palettenfarbe als Verweis gespeichert');
|
||||
await options.getByLabel('Symbol beim Überfahren').selectOption('slide');
|
||||
await options.getByLabel('Auf dem Telefon').selectOption('bar-icon');
|
||||
const sample = row.locator('.menu-button-preview .cms-button').first();
|
||||
assert.equal(await sample.evaluate(el => getComputedStyle(el).borderRadius), '999px');
|
||||
assert.equal(await row.locator('.menu-button-preview .button-icon svg').count(), 2, 'Symbol in Normal- und Hover-Vorschau');
|
||||
const axe = await new AxeBuilder({ page }).include('.menu-entry:last-child').analyze();
|
||||
assert.deepEqual(axe.violations.map(v => v.id), [], 'axe in der Menüzeile');
|
||||
await row.screenshot({ path: `${report}/verwaltung.png` });
|
||||
checks.push('Verwaltung: Häkchen zeigt Optionen, Klick in die leere Zeile schaltet nichts um, Pille, Symbol aus dem Fenster, Palettenfarbe als Verweis, Vorschau mit Symbol, axe ohne Befund');
|
||||
|
||||
// Zweiter Button bleibt auf dem Telefon im aufgeklappten Menü (Vorgabe).
|
||||
await page.locator('#link-label').fill('Rückruf');
|
||||
await page.locator('#link-url').fill('/rueckruf');
|
||||
await page.locator('#menu-add-url').click();
|
||||
const second = page.locator('.menu-entry').last();
|
||||
await second.locator('summary').click();
|
||||
await second.getByLabel('Als Button darstellen').check();
|
||||
await second.locator('select[data-key="buttonAppearance"]').selectOption('outline');
|
||||
assert.equal(await second.locator('select[data-key="buttonMobile"]').inputValue(), 'menu');
|
||||
|
||||
await page.getByRole('button', { name: 'Menü speichern' }).click();
|
||||
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
|
||||
await page.reload();
|
||||
const saved = page.locator('.menu-entry').filter({ hasText: 'Angebot anfragen' }).last();
|
||||
await saved.locator('summary').click();
|
||||
assert.equal(await saved.getByLabel('Als Button darstellen').isChecked(), true);
|
||||
assert.equal(await saved.locator('select[data-key="buttonShape"]').inputValue(), 'pill');
|
||||
assert.equal(await saved.locator('select[data-key="buttonMobile"]').inputValue(), 'bar-icon');
|
||||
assert.equal(await saved.locator('.icon-field-name').textContent(), 'Pfeil nach rechts');
|
||||
checks.push('gespeichert und nach Neuladen gleich');
|
||||
|
||||
// Öffentliche Seite: Desktop im Menü, Telefon neben dem Menü-Knopf.
|
||||
const site = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
|
||||
site.on('pageerror', error => errors.push(error.message));
|
||||
await site.goto(origin + '/');
|
||||
const desktop = site.locator('.site-header .block-menu > .menu-list a.menu-button');
|
||||
await desktop.first().waitFor();
|
||||
assert.equal(await desktop.first().isVisible(), true);
|
||||
const look = await desktop.first().evaluate(el => ({ radius: getComputedStyle(el).borderRadius, bg: getComputedStyle(el).backgroundColor, deco: getComputedStyle(el).textDecorationLine }));
|
||||
assert.equal(look.radius, '999px'); assert.equal(look.deco, 'none');
|
||||
assert.equal(await site.locator('.site-header .menu-actions').first().isVisible(), false, 'Auf dem Desktop keine Zweitausgabe');
|
||||
await site.locator('.site-header').screenshot({ path: `${report}/desktop.png` });
|
||||
await site.setViewportSize({ width: 390, height: 844 });
|
||||
await site.reload();
|
||||
const bar = site.locator('.site-header .menu-actions a.menu-button');
|
||||
assert.equal(await bar.first().isVisible(), true, 'Auf dem Telefon neben dem Menü-Knopf');
|
||||
assert.match(await bar.first().getAttribute('class'), /icon-only/, 'Dort nur als Symbol');
|
||||
assert.equal(await bar.first().getAttribute('href'), '/kontakt');
|
||||
const line = await site.evaluate(() => { const a = document.querySelector('.site-header .menu-actions a'), s = document.querySelector('.site-header .menu-burger > summary'); return Math.abs(a.getBoundingClientRect().top + a.getBoundingClientRect().height / 2 - (s.getBoundingClientRect().top + s.getBoundingClientRect().height / 2)); });
|
||||
await site.locator('.site-header').screenshot({ path: `${report}/telefon.png` });
|
||||
assert.ok(line < 4, `Symbol und Menü-Knopf nicht in einer Zeile: ${line}px`);
|
||||
await site.locator('.site-header .menu-burger > summary').first().click();
|
||||
assert.equal(await site.locator('.site-header .menu-burger-body .menu-keep-visible').first().isVisible(), false, 'Im aufgeklappten Menü nicht doppelt');
|
||||
const inMenu = site.locator('.site-header .menu-burger-body a.menu-button', { hasText: 'Rückruf' });
|
||||
assert.equal(await inMenu.isVisible(), true, 'Button im aufgeklappten Menü');
|
||||
assert.equal(await site.locator('.site-header .menu-actions a', { hasText: 'Rückruf' }).count(), 0, 'nicht neben dem Menü-Knopf');
|
||||
const fit = await inMenu.evaluate(a => ({ a: a.getBoundingClientRect().width, list: a.closest('.menu-list').getBoundingClientRect().width, border: getComputedStyle(a).borderTopStyle }));
|
||||
assert.ok(Math.abs(fit.a - fit.list) < 2, `volle Breite im Menü: ${fit.a} von ${fit.list}`);
|
||||
assert.equal(fit.border, 'solid', 'Darstellung „Rahmen“ bleibt im Menü');
|
||||
await site.screenshot({ path: `${report}/telefon-offen.png` });
|
||||
const axeSite = await new AxeBuilder({ page: site }).include('.site-header').analyze();
|
||||
assert.deepEqual(axeSite.violations.map(v => v.id), [], 'axe im Kopf der Website');
|
||||
const width = await site.evaluate(() => document.documentElement.scrollWidth);
|
||||
assert.ok(width <= 390, `waagerechtes Scrollen: ${width}`);
|
||||
checks.push('Website: Button im Menü (Pille, ohne Unterstreichung), auf dem Telefon neben dem Menü-Knopf, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite, axe ohne Befund, kein waagerechtes Scrollen');
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2));
|
||||
console.log(checks.map(check => `✓ ${check}`).join('\n'));
|
||||
} finally {
|
||||
await browser?.close();
|
||||
server.kill();
|
||||
}
|
||||
@@ -34,7 +34,10 @@ try {
|
||||
data.document.blocks.push({ id: 'qa-anchor', type: 'section', children: [{ id: 'qa-anchor-title', type: 'heading', props: { text: 'Kontaktbereich', level: 2 } }] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
// Im Reiter „Struktur“ wählen und mit „Bearbeiten“ öffnen, wie im Editor selbst.
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator('#structure [data-block-id="qa-anchor"]').click();
|
||||
await page.locator('#structure [data-edit-block="qa-anchor"]').click();
|
||||
await page.locator('#property-anchor').fill('#kontakt-check'); await page.locator('#property-anchor').blur();
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
# Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/).
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/huefner.db"
|
||||
export MEDIA_DIRECTORY="$root/var/huefner-media"
|
||||
assets="$root/var/huefner-assets"
|
||||
if [ -n "$1" ]; then
|
||||
"$php" scripts/build-huefner-assets.php "$1" "$assets"
|
||||
fi
|
||||
if [ ! -f "$assets/favicon.png" ]; then
|
||||
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs als Argument aufrufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$root/var/huefner.db"
|
||||
rm -rf "$root/var/huefner-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
"$php" -d memory_limit=512M scripts/seed-huefner.php "$assets"
|
||||
# Barlow und Barlow Condensed vom eigenen Server; der Abruf ergaenzt fonts.css nur.
|
||||
if [ -f "$root/var/cacert.pem" ]; then
|
||||
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Kantarci-Markt-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-markt.db"
|
||||
export MEDIA_DIRECTORY="$root/var/kantarci-markt-media"
|
||||
rm -f "$root/var/kantarci-markt.db"
|
||||
rm -rf "$root/var/kantarci-markt-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/kantarci-markt-assets}"
|
||||
# Logo zuschneiden, Seitensymbol und Stempel rastern (Chromium ueber Playwright).
|
||||
node scripts/build-kantarci-markt-assets.mjs "$assets"
|
||||
"$php" -d memory_limit=512M scripts/seed-kantarci-markt.php "$assets"
|
||||
# DM Sans vom eigenen Server. Der Abruf ersetzt in fonts.css nur die Regeln fuer DM Sans; die
|
||||
# Schriften der anderen Installationen bleiben stehen. Das PHP 8.4 hier kennt keine
|
||||
# Stammzertifikate; mit var/cacert.pem (https://curl.se/ca/cacert.pem) klappt der Abruf.
|
||||
if [ -f "$root/var/cacert.pem" ]; then
|
||||
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Kantarci-Reisen-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
# Erstes Argument: der entpackte Ordner „Kantarci-Reisen“ aus dem Komplettpaket (Website/, Logo/).
|
||||
# Zweites Argument: der Screenshot der reiseSoft-Uebersicht (PNG, Beispieldaten).
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/kantarci-reisen.db"
|
||||
export MEDIA_DIRECTORY="$root/var/kantarci-reisen-media"
|
||||
assets="$root/var/kantarci-reisen-assets"
|
||||
if [ -n "$1" ]; then
|
||||
"$php" scripts/build-kantarci-reisen-assets.php "$1" "$assets" "$2"
|
||||
fi
|
||||
if [ ! -f "$assets/favicon.png" ] || [ ! -f "$assets/reisesoft-uebersicht.png" ]; then
|
||||
echo "Bilder fehlen: erst mit dem Paketordner und dem Screenshot als Argumente aufrufen." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$root/var/kantarci-reisen.db"
|
||||
rm -rf "$root/var/kantarci-reisen-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
"$php" -d memory_limit=512M scripts/seed-kantarci-reisen.php "$assets"
|
||||
# DM Sans vom eigenen Server; der Abruf ergaenzt fonts.css nur.
|
||||
if [ -f "$root/var/cacert.pem" ]; then
|
||||
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,919 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut die Vorab-Seite „Kantarci Markt – Neueröffnung“ als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist das gelieferte Paket „Kantarci-Markt-Landingpage.zip“ (23.09.2026): eine statische
|
||||
* Seite (index.html, styles.css) mit Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit zwei
|
||||
* Fotos und Stempel, dem Versprechen (Frisch, Qualität, Vielfältig) auf Waldgrün, dem Abschluss
|
||||
* „Frische. Qualität. Faire Preise.“ und einem schmalen Fuß. Wortlaut und Farben (Waldgrün
|
||||
* #123b26, Gold #ad893b, Creme #fcf9f2) sind daraus übernommen. Gebaut ist alles aus gewöhnlichen
|
||||
* Bausteinen – kein eigener Baustein, kein Plugin.
|
||||
*
|
||||
* Bewusste Abweichungen:
|
||||
* - DM Sans kommt vom eigenen Server (cms:fonts:fetch), nicht von Google. Manrope der Vorlage
|
||||
* (nur Kleinzeilen, Nummern und die Bildzeile) entfällt, dort steht DM Sans: eine Schrift
|
||||
* weniger zu laden. Überschriften in Georgia wie in der letzten Fassung der Vorlage.
|
||||
* - Der runde Stempel ist ein PNG (scripts/build-kantarci-markt-assets.mjs): Text auf einem
|
||||
* Kreisbogen kann kein Baustein, und die Mediathek nimmt kein SVG.
|
||||
* - Das große „K“ im Abschluss und der Goldstrich sind Pseudo-Elemente im eigenen CSS der
|
||||
* Sektion, damit Vorlesesoftware und KI-Leser kein einzelnes „K“ als Inhalt bekommen.
|
||||
* - Neu sind Impressum und Datenschutz, beide Pflicht für einen geschäftlichen Auftritt (§ 5 DDG,
|
||||
* Art. 13 DSGVO). Betreiber, Anschrift mit Hausnummer und Kontakt fehlen in der Vorlage; sie
|
||||
* stehen als „NOCH ZU PRÜFEN“ im Text.
|
||||
*
|
||||
* 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-markt.sh).
|
||||
* Erstes Argument: der Ordner mit logo.png, symbol.png, stempel.png, market.jpg, vegetables.jpg.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
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-markt')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Kantarci-Markt-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());
|
||||
$theme = new Theme($db);
|
||||
|
||||
$name = 'Kantarci Markt';
|
||||
// Die Domain kantarci-markt.de hat Mailserver (23.09.2026). Ob es das Postfach info@ gibt, ist
|
||||
// nicht bestätigt – deshalb steht es nur im Impressum und dort als „NOCH ZU PRÜFEN“.
|
||||
$mail = 'info@kantarci-markt.de';
|
||||
$strasse = 'Wilhelm-Drapp-Straße';
|
||||
$plz = '76532';
|
||||
$ort = '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'];
|
||||
$nurLinks = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $w];
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w];
|
||||
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
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. */
|
||||
$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 $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
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. */
|
||||
$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. */
|
||||
$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 = '', string $bildzeile = ''): 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';
|
||||
}
|
||||
if ('' !== $bildzeile) {
|
||||
$props['caption'] = $bildzeile;
|
||||
}
|
||||
|
||||
$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 = ['fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.2em', 'lineHeight' => 1.2, 'textTransform' => 'uppercase'];
|
||||
$linkCss = "& a{color:#123b26;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#806222}";
|
||||
|
||||
/** Die Kleinzeile in Versalien mit dem kurzen Goldstrich davor („— NEUERÖFFNUNG“). */
|
||||
$kleinzeile = static function (string $id, string $inhalt, string $farbe, string $strichFarbe = '@gold') use ($text, $zeile, $flaeche, $kleinStil): array {
|
||||
return $zeile($id, [
|
||||
$flaeche($id.'-strich', ['desktop' => ['width' => '38px', 'height' => '2px', 'backgroundColor' => $strichFarbe, 'flexShrink' => 0]], 'min-height:0'),
|
||||
$text($id.'-text', $inhalt, ['desktop' => [...$kleinStil, 'color' => $farbe, 'margin' => ['bottom' => '0px']]]),
|
||||
], '12px', ['margin' => ['top' => '0px', 'bottom' => '24px']]);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => $name, 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'forst', 'name' => 'Waldgrün', 'value' => '#123b26'],
|
||||
['key' => 'tief', 'name' => 'Tiefgrün (Fuß)', 'value' => '#0b281a'],
|
||||
['key' => 'gold', 'name' => 'Gold (Linien)', 'value' => '#ad893b'],
|
||||
['key' => 'gold-hell', 'name' => 'Gold hell (Flächen, auf Grün)', 'value' => '#d2b978'],
|
||||
['key' => 'gold-text', 'name' => 'Gold als Schrift auf Creme', 'value' => '#806222'],
|
||||
['key' => 'gold-titel', 'name' => 'Gold in großen Überschriften', 'value' => '#8c6b27'],
|
||||
['key' => 'creme', 'name' => 'Creme', 'value' => '#fcf9f2'],
|
||||
['key' => 'sand', 'name' => 'Sand (Abschluss)', 'value' => '#f4efe3'],
|
||||
['key' => 'text', 'name' => 'Fließtext', 'value' => '#58685e'],
|
||||
['key' => 'text-gruen', 'name' => 'Fließtext auf Grün', 'value' => '#b7c8c1'],
|
||||
['key' => 'klein-gruen', 'name' => 'Kleinzeile auf Grün', 'value' => '#a9bdb4'],
|
||||
['key' => 'fuss-text', 'name' => 'Schrift im Fuß', 'value' => '#a9bab2'],
|
||||
['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'DM Sans', 'source' => 'google', 'weights' => [400, 500, 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' => '@forst', 'backgroundColor' => '@creme', 'linkColor' => '@forst', 'containerWidth' => '1280px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
|
||||
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@forst', 'color' => '@papier', 'hoverBackgroundColor' => '#1d5236', 'hoverColor' => '@papier'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo.png', 'Kantarci Markt – grünes K mit goldener Waage'],
|
||||
'symbol' => ['symbol.png', 'Bildmarke von Kantarci Markt'],
|
||||
'stempel' => ['stempel.png', ''],
|
||||
'markt' => ['market.jpg', 'Frisches Blattgemüse an einem Marktstand – Beispielbild'],
|
||||
'tomaten' => ['vegetables.jpg', 'Frische Strauchtomaten – Beispielbild'],
|
||||
] 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";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopf- und Fußbereich
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf', [
|
||||
$zeile('kopf-zeile', [
|
||||
$bild('kopf-logo', $medien['logo'], 'Kantarci Markt – zur Startseite', [
|
||||
// Volle Breite bis 300 px: sonst hängt die Breite in der zentrierten Zeile am
|
||||
// geladenen Bild, und der Kopf springt beim Laden (CLS).
|
||||
'desktop' => ['width' => '100%', 'maxWidth' => '300px', 'margin' => $seiten('0px')],
|
||||
'mobile' => ['maxWidth' => '230px'],
|
||||
], '/', '& img{display:block;width:100%;height:auto}'),
|
||||
], '0px', ['justifyContent' => 'center']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '18px', 'bottom' => '18px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'mobile' => ['padding' => ['top' => '14px', 'bottom' => '14px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$fussLink = "& a{color:#a9bab2}\n& a:hover{color:#fff;text-decoration:underline}";
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$zeile('fuss-zeile', [
|
||||
$text('fuss-copyright', '© 2026 '.$name, ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('fuss-adresse', $strasse.' · '.$ort, ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', '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' => '14px', 'gap' => '4px', 'color' => '@fuss-text']],
|
||||
'css' => $fussLink],
|
||||
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']]], $fussLink),
|
||||
], '22px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
|
||||
], '20px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap'],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@tief', 'color' => '@fuss-text', 'padding' => ['top' => '28px', 'bottom' => '28px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Ein Versprechen: Nummer, Titel, Satz – auf dem Telefon rückt der Satz unter den Titel. */
|
||||
$versprechen = static fn (string $n, string $titel, string $satz): array => $zeile('versprechen-'.$n, [
|
||||
$text('versprechen-'.$n.'-nummer', $n, ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'color' => '@gold-hell', 'flexBasis' => '50px', 'flexShrink' => 0, 'margin' => ['bottom' => '0px']], 'mobile' => ['flexBasis' => '34px']]),
|
||||
$zeile('versprechen-'.$n.'-inhalt', [
|
||||
$heading('versprechen-'.$n.'-titel', $titel, 3, [
|
||||
'desktop' => ['fontSize' => '34px', 'fontWeight' => '400', 'lineHeight' => 1.2, 'color' => '@papier', 'flexGrow' => 3, 'flexBasis' => '0', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '28px'],
|
||||
'mobile' => ['fontSize' => '28px'],
|
||||
]),
|
||||
$text('versprechen-'.$n.'-satz', $satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@text-gruen', 'maxWidth' => '390px', 'flexGrow' => 5, 'flexBasis' => '0', 'margin' => ['bottom' => '0px']]]),
|
||||
], '20px', ['alignItems' => 'baseline', 'flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '8px']),
|
||||
], '20px', ['alignItems' => 'baseline', 'padding' => ['top' => '31px', 'bottom' => '31px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#cfb57359']);
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Neueröffnung',
|
||||
'description' => 'Kantarci Markt eröffnet bald: Frische, Qualität und faire Preise in der Wilhelm-Drapp-Straße in Baden-Baden.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Text links, rechts das Marktfoto mit Bildzeile, die gedrehte Tomatenkarte und
|
||||
// der Stempel.
|
||||
$section('hero', [
|
||||
$row('hero-zeile', [
|
||||
$column('hero-text', [
|
||||
$kleinzeile('hero-label', 'Neueröffnung', '@gold-text'),
|
||||
$heading('hero-titel', 'Frische bekommt<br><em>eine neue Adresse.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '80px', 'fontWeight' => '400', 'lineHeight' => 1.04, 'letterSpacing' => '-0.045em', 'color' => '@forst', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '64px'],
|
||||
'mobile' => ['fontSize' => '44px', 'lineHeight' => 1.07],
|
||||
], '& em{font-style:italic;color:#8c6b27}'),
|
||||
$text('hero-intro', 'Freuen Sie sich auf sorgfältig ausgewählte Produkte, echte Vielfalt und einen Markt, der Nachbarschaft neu denkt.', [
|
||||
'desktop' => ['maxWidth' => '600px', 'fontSize' => '20px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '32px', 'bottom' => '38px']],
|
||||
'mobile' => ['fontSize' => '17px', 'margin' => ['top' => '24px', 'bottom' => '30px']],
|
||||
]),
|
||||
// Eine Spalte statt einer Zeile: Zeilen sind im Kern immer durchsichtig.
|
||||
$column('hero-termin', [
|
||||
$column('hero-termin-symbol', [
|
||||
$symbol('hero-termin-icon', 'shop', 24, ['desktop' => ['color' => '@forst']]),
|
||||
], ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '50px', 'height' => '50px', 'flexShrink' => 0, 'backgroundColor' => '@gold-hell', 'borderRadius' => $rund('50%')]]),
|
||||
$column('hero-termin-text', [
|
||||
$text('hero-termin-klein', 'Wir öffnen bald', ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'lineHeight' => 1.3, 'color' => '#bfd1c9', 'margin' => ['bottom' => '3px']]]),
|
||||
$text('hero-termin-satz', 'Eröffnungstermin folgt', ['desktop' => ['fontSize' => '16px', 'fontWeight' => '700', 'lineHeight' => 1.2, 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => '17px', 'backgroundColor' => '@forst', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold',
|
||||
'borderRadius' => $rund('3px'), 'padding' => ['top' => '15px', 'right' => '24px', 'bottom' => '15px', 'left' => '15px'],
|
||||
'shadowY' => '18px', 'shadowBlur' => '50px', 'shadowColor' => '#062f2429'],
|
||||
'mobile' => ['gap' => '12px', 'padding' => ['top' => '14px', 'right' => '16px', 'bottom' => '14px', 'left' => '14px']],
|
||||
]),
|
||||
$text('hero-adresse', 'Hier finden Sie uns bald<br><strong>'.$strasse.'</strong><br>'.$ort, [
|
||||
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.55, 'color' => '@text', 'borderWidth' => $nurLinks('3px'), 'borderStyle' => 'solid', 'borderColor' => '@gold', 'padding' => ['left' => '18px'], 'margin' => ['top' => '28px', 'bottom' => '0px']],
|
||||
], "& strong{color:#123b26;font-weight:700}"),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'justifyContent' => 'center'],
|
||||
]),
|
||||
$column('hero-visual', [
|
||||
$bild('hero-markt', $medien['markt'], 'Frisches Blattgemüse an einem Marktstand – Beispielbild', [
|
||||
'desktop' => ['position' => 'absolute', 'top' => '0px', 'right' => '4%', 'bottom' => '8%', 'left' => '5%', 'margin' => $seiten('0px'), 'overflow' => 'hidden', 'backgroundColor' => '@forst',
|
||||
'outlineWidth' => '1px', 'outlineStyle' => 'solid', 'outlineColor' => '@gold', 'outlineOffset' => '8px',
|
||||
'shadowY' => '24px', 'shadowBlur' => '60px', 'shadowColor' => '#021d162e'],
|
||||
'mobile' => ['right' => '0px', 'bottom' => '10%', 'left' => '0px'],
|
||||
], null,
|
||||
"& img{display:block;width:100%;height:100%;object-fit:cover}\n"
|
||||
."&::after{content:'';position:absolute;top:25%;right:0;bottom:0;left:0;background:linear-gradient(transparent,rgba(2,29,22,.85) 65%)}\n"
|
||||
."& figcaption{position:absolute;z-index:1;bottom:150px;left:28px;right:28px;max-width:260px;margin:0;color:#fff;font-size:26px;font-weight:700;line-height:1.2;text-shadow:0 2px 14px rgba(2,29,22,.7)}",
|
||||
'Vorfreude auf frische Vielfalt.'),
|
||||
$bild('hero-tomaten', $medien['tomaten'], 'Frische Strauchtomaten – Beispielbild', [
|
||||
'desktop' => ['position' => 'absolute', 'width' => '49%', 'height' => '33%', 'right' => '-3%', 'bottom' => '-1%', 'margin' => $seiten('0px'), 'overflow' => 'hidden', 'rotate' => 4,
|
||||
'borderWidth' => $seiten('8px'), 'borderStyle' => 'solid', 'borderColor' => '@creme', 'shadowY' => '24px', 'shadowBlur' => '60px', 'shadowColor' => '#021d162e', 'zIndex' => 2],
|
||||
'mobile' => ['width' => '63%', 'height' => '39%', 'right' => '0px', 'borderWidth' => $seiten('7px')],
|
||||
], null, '& img{display:block;width:100%;height:100%;object-fit:cover}'),
|
||||
$bild('hero-stempel', $medien['stempel'], '', [
|
||||
'desktop' => ['position' => 'absolute', 'maxWidth' => '122px', 'width' => '122px', 'left' => '-1%', 'bottom' => '9%', 'margin' => $seiten('0px'), 'rotate' => -12, 'zIndex' => 3,
|
||||
'borderRadius' => $rund('50%'), 'shadowY' => '8px', 'shadowBlur' => '24px', 'shadowColor' => '#123b2626'],
|
||||
'mobile' => ['maxWidth' => '96px', 'width' => '96px', 'left' => '-2%', 'bottom' => '5%'],
|
||||
], null, '& img{display:block;width:100%;height:auto}'),
|
||||
], [
|
||||
'desktop' => ['position' => 'relative', 'minHeight' => '600px'],
|
||||
'tablet' => ['minHeight' => '520px'],
|
||||
'mobile' => ['minHeight' => '390px'],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '80px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '51 49'],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
'mobile' => ['gap' => '40px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '72px', 'bottom' => '84px'], 'overflow' => 'hidden'],
|
||||
'tablet' => ['padding' => ['top' => '56px', 'bottom' => '72px']],
|
||||
'mobile' => ['padding' => ['top' => '40px', 'bottom' => '64px']],
|
||||
], 'start'),
|
||||
|
||||
// 2 – Das Versprechen auf Waldgrün.
|
||||
$section('versprechen', [
|
||||
$row('versprechen-zeile', [
|
||||
$zeile('versprechen-intro', [
|
||||
$text('versprechen-nummer', '01', ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'color' => '@gold-hell', 'flexBasis' => '52px', 'flexShrink' => 0, 'margin' => ['top' => '4px', 'bottom' => '0px']], 'mobile' => ['flexBasis' => '38px']]),
|
||||
$column('versprechen-titel', [
|
||||
$text('versprechen-klein', 'Unser Versprechen', ['desktop' => [...$kleinStil, 'letterSpacing' => '0.18em', 'color' => '@klein-gruen', 'margin' => ['bottom' => '20px']]]),
|
||||
$heading('versprechen-h2', 'Mehr als<br>einkaufen.', 2, [
|
||||
'desktop' => ['fontSize' => '80px', 'fontWeight' => '400', 'lineHeight' => 1.04, 'letterSpacing' => '-0.035em', 'color' => '@papier', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '64px'],
|
||||
'mobile' => ['fontSize' => '48px'],
|
||||
]),
|
||||
]),
|
||||
], '0px', ['alignItems' => 'flex-start']),
|
||||
$column('versprechen-liste', [
|
||||
$versprechen('01', 'Frisch', 'Ausgewählte Produkte, die jeden Einkauf ein bisschen besser machen.'),
|
||||
$versprechen('02', 'Qualität', 'Sorgfältig ausgewählt, mit Vertrauen gekauft. Gute Produkte zu fairen Preisen.'),
|
||||
$versprechen('03', 'Vielfältig', 'Ein Sortiment für den Alltag, besondere Momente und jeden Geschmack.'),
|
||||
], ['desktop' => ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#cfb573a6']]),
|
||||
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '120px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'columnWidths' => '3 5'],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
'mobile' => ['gap' => '48px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@forst', 'color' => '@papier', 'padding' => ['top' => '140px', 'bottom' => '140px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'tablet' => ['padding' => ['top' => '100px', 'bottom' => '100px']],
|
||||
'mobile' => ['padding' => ['top' => '80px', 'bottom' => '80px']],
|
||||
], 'unser-versprechen'),
|
||||
|
||||
// 3 – Abschluss: Handschrift links, Gruß rechts, das große K als Wasserzeichen.
|
||||
$section('abschluss', [
|
||||
$row('abschluss-zeile', [
|
||||
$text('abschluss-spruch', 'Frische. Qualität.<br>Faire Preise.', [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontStyle' => 'italic', 'fontSize' => '84px', 'lineHeight' => 1.12, 'letterSpacing' => '-0.05em', 'color' => '@forst', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '64px'],
|
||||
'mobile' => ['fontSize' => '48px'],
|
||||
]),
|
||||
$column('abschluss-gruss', [
|
||||
$text('abschluss-gruss-text', 'Wir bereiten gerade alles für Sie vor.', ['desktop' => ['fontSize' => '17px', 'color' => '@text', 'margin' => ['bottom' => '10px']]]),
|
||||
$text('abschluss-gruss-satz', 'Bis ganz bald im Kantarci Markt.', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '34px', 'lineHeight' => 1.25, 'color' => '@forst', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '26px']]),
|
||||
], ['desktop' => ['maxWidth' => '390px', 'alignSelf' => 'center']]),
|
||||
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '40px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'position' => 'relative', 'zIndex' => 2],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'position' => 'relative', 'overflow' => 'hidden', 'minHeight' => '450px', 'padding' => ['top' => '130px', 'bottom' => '130px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'tablet' => ['padding' => ['top' => '100px', 'bottom' => '110px']],
|
||||
'mobile' => ['padding' => ['top' => '80px', 'bottom' => '96px']],
|
||||
], 'bis-bald', "&::before{content:'K';position:absolute;right:3%;top:4%;font-family:Georgia,serif;font-size:min(30vw,480px);line-height:1;color:rgba(6,47,36,.045);pointer-events:none}\n"
|
||||
."&::after{content:'';position:absolute;right:7%;bottom:58px;width:180px;height:2px;background:#ad893b}"),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): 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']);
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Der Text des Hero spielt beim Laden; die Fotos bleiben ohne Einblendung (LCP).
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-label' => ['fade'],
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-intro' => ['rise', 'medium'],
|
||||
'hero-termin' => ['rise', 'long'],
|
||||
'hero-adresse' => ['fade', 'long'],
|
||||
'versprechen-intro' => ['rise'],
|
||||
'versprechen-liste' => ['rise', 'short'],
|
||||
'abschluss-spruch' => ['rise'],
|
||||
'abschluss-gruss' => ['rise', 'short'],
|
||||
]);
|
||||
|
||||
$anlegen('home', $startseite, 'Kantarci Markt – Neueröffnung in Baden-Baden', $medien['markt']['id']);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Eröffnungsseite (Demo, 24.09.2026)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
//
|
||||
// So sieht die Startseite aus, wenn der Markt geöffnet hat. Sie liegt vorerst unter
|
||||
// /eroeffnung, ist nicht verlinkt und steht auf noindex: Umut soll sie ansehen können, ohne dass
|
||||
// die Vorab-Seite verschwindet. Zur Eröffnung wandert ihr Inhalt als Entwurf auf die Startseite
|
||||
// (geplante Veröffentlichung), /eroeffnung wird danach gelöscht.
|
||||
//
|
||||
// Die Angebote kommen aus der Web-Ansicht der eigenen App (app.kantarci-markt.de). Die erlaubt
|
||||
// das Einbetten selbst (frame-ancestors) und meldet ihre Höhe per postMessage. Das Skript am
|
||||
// Baustein ist Umuts Gegenstück dazu (24.09.2026). Eine Einwilligung braucht es nicht, solange die
|
||||
// Website keine Einwilligungsleiste hat: Consent::granted() lässt dann jede Gruppe durch.
|
||||
//
|
||||
// Platzhalter, bis Umut die echten Werte schickt: Öffnungszeiten, Telefon, Store-Adressen (die
|
||||
// führen vorerst auf die Suche im jeweiligen Store) und die Store-Badges (eigene Knöpfe statt der
|
||||
// offiziellen Grafiken von Apple und Google).
|
||||
|
||||
// Ohne eigene Überschrift und ohne Knopf zur App: Die Seite hat ihre Überschrift, und Umut will keine Links nach
|
||||
// außen (24.09.2026). Ein Klick auf ein Angebot öffnet ein Popup in der Ansicht. Heller Ton, weil der Abschnitt Sand ist.
|
||||
$angeboteUrl = 'https://app.kantarci-markt.de/embed/angebote.php?titel=0&app=0';
|
||||
$appStoreUrl = 'https://apps.apple.com/de/search?term=Kantarci%20Markt';
|
||||
$playStoreUrl = 'https://play.google.com/store/search?q=Kantarci%20Markt&c=apps';
|
||||
$oeffnungszeiten = [['Montag bis Freitag', '8:00 – 20:00 Uhr'], ['Samstag', '8:00 – 20:00 Uhr'], ['Sonntag und Feiertage', 'geschlossen']];
|
||||
$routeUrl = 'https://www.google.com/maps/dir/?api=1&destination='.rawurlencode($strasse.', '.$plz.' '.$ort);
|
||||
|
||||
// Umuts Gegenstück aus der App, um zwei Dinge ergänzt. Erstens läuft es als eigene Datei (defer)
|
||||
// und damit oft erst, nachdem die App ihre Höhe schon einmal gemeldet hat - danach meldet sie nur
|
||||
// noch Änderungen. Deshalb fragt es nach dem Start nach ('km-angebote-height-request'); antwortet
|
||||
// die App nicht, lädt es den Rahmen als Rückfall einmal neu. Zweitens meldet die App beim Öffnen
|
||||
// eines Angebots, wo ihr Popup steht ('km-angebote-focus'); liegt das außerhalb des Sichtbaren,
|
||||
// scrollt die Seite dorthin.
|
||||
$hoehenSkript = <<<'JS'
|
||||
(function () {
|
||||
var gemeldet = false;
|
||||
function passend(f) {
|
||||
try {
|
||||
var src = new URL(f.src, location.href);
|
||||
return src.pathname.indexOf('/embed/angebote') !== -1 ? src : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
window.addEventListener('message', function (ev) {
|
||||
if (!ev.data || (ev.data.type !== 'km-angebote-height' && ev.data.type !== 'km-angebote-focus')) return;
|
||||
var frames = document.querySelectorAll('iframe');
|
||||
for (var i = 0; i < frames.length; i++) {
|
||||
var f = frames[i];
|
||||
if (f.contentWindow !== ev.source) continue;
|
||||
var src = passend(f);
|
||||
if (!src || src.origin !== ev.origin) return;
|
||||
if (ev.data.type === 'km-angebote-focus') {
|
||||
var oben = f.getBoundingClientRect().top + Math.max(0, Math.min(20000, Number(ev.data.top) || 0));
|
||||
var hoehe = Math.max(0, Math.min(20000, Number(ev.data.height) || 0));
|
||||
if (oben < 16 || oben + Math.min(hoehe, window.innerHeight - 32) > window.innerHeight - 16) {
|
||||
var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
window.scrollTo({ top: window.scrollY + oben - 24, behavior: still ? 'auto' : 'smooth' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
var h = Math.max(150, Math.min(20000, Number(ev.data.height) || 0));
|
||||
f.style.height = (h + 2) + 'px';
|
||||
f.style.minHeight = '0';
|
||||
f.style.background = 'transparent';
|
||||
f.setAttribute('scrolling', 'no');
|
||||
gemeldet = true;
|
||||
return;
|
||||
}
|
||||
});
|
||||
var frames = document.querySelectorAll('iframe');
|
||||
for (var i = 0; i < frames.length; i++) {
|
||||
(function (f) {
|
||||
var src = passend(f);
|
||||
if (!src) return;
|
||||
f.contentWindow.postMessage({ type: 'km-angebote-height-request' }, src.origin);
|
||||
setTimeout(function () { if (!gemeldet) f.src = f.src; }, 600);
|
||||
})(frames[i]);
|
||||
}
|
||||
})();
|
||||
JS;
|
||||
|
||||
/** Ändert Bausteine eines Baums nach ID, ohne ihn neu zu schreiben. */
|
||||
$aendern = static function (array $block, array $plan) use (&$aendern): array {
|
||||
if (isset($plan[$block['id'] ?? ''])) {
|
||||
$block = $plan[$block['id']]($block);
|
||||
}
|
||||
foreach ($block['children'] ?? [] as $i => $kind) {
|
||||
$block['children'][$i] = $aendern($kind, $plan);
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
$setzeText = static fn (string $inhalt): Closure => static function (array $b) use ($inhalt): array {
|
||||
$b['props']['text'] = $inhalt;
|
||||
|
||||
return $b;
|
||||
};
|
||||
|
||||
/** Ein Store-Knopf: schwarz, zweizeilig, wie die Badges der Stores, aber ohne deren Logos. */
|
||||
$storeKnopf = static fn (string $id, string $oben, string $unten, string $href): array => $text($id,
|
||||
'<a href="'.htmlspecialchars($href, ENT_QUOTES).'">'.$oben.'<br><strong>'.$unten.'</strong></a>',
|
||||
['desktop' => ['margin' => ['bottom' => '0px']]],
|
||||
"& a{display:inline-block;min-width:176px;padding:9px 20px 10px;border-radius:9px;background:#000;border:1px solid #a6a6a6;color:#fff;font-size:12px;line-height:1.25;text-decoration:none}\n"
|
||||
."& a:hover{background:#123b26;border-color:#d2b978}\n"
|
||||
."& a:focus-visible{outline:3px solid #ad893b;outline-offset:3px}\n"
|
||||
.'& strong{display:block;font-size:21px;font-weight:500;letter-spacing:-0.01em}');
|
||||
|
||||
$abschnittTitel = static fn (string $id, string $klein, string $titel, string $farbeKlein, string $farbeTitel, string $strich = '@gold', string $farbeBetont = '#8c6b27') => [
|
||||
$kleinzeile($id.'-label', $klein, $farbeKlein, $strich),
|
||||
$heading($id.'-h2', $titel, 2, [
|
||||
'desktop' => ['fontSize' => '64px', 'fontWeight' => '400', 'lineHeight' => 1.06, 'letterSpacing' => '-0.035em', 'color' => $farbeTitel, 'margin' => ['bottom' => '22px']],
|
||||
'tablet' => ['fontSize' => '52px'],
|
||||
'mobile' => ['fontSize' => '40px'],
|
||||
], '& em{font-style:italic;color:'.$farbeBetont.'}'),
|
||||
];
|
||||
|
||||
$eroeffnung = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Jetzt geöffnet',
|
||||
'description' => 'Kantarci Markt in der Wilhelm-Drapp-Straße in Baden-Baden: frische Produkte, echte Vielfalt, faire Preise und jede Woche neue Angebote.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: wie die Vorab-Seite, aber mit Öffnungszeiten und zwei Wegen weiter.
|
||||
$aendern($startseite['blocks'][0], [
|
||||
'hero-label-text' => $setzeText('Jetzt geöffnet'),
|
||||
'hero-titel' => $setzeText('Frische hat jetzt<br><em>eine Adresse.</em>'),
|
||||
'hero-intro' => $setzeText('Sorgfältig ausgewählte Produkte, echte Vielfalt und faire Preise – ab sofort in der Wilhelm-Drapp-Straße in Baden-Baden.'),
|
||||
'hero-termin-icon' => static function (array $b): array {
|
||||
$b['props']['icon'] = 'clock';
|
||||
|
||||
return $b;
|
||||
},
|
||||
'hero-termin-klein' => $setzeText('Heute geöffnet'),
|
||||
'hero-termin-satz' => $setzeText($oeffnungszeiten[0][1]),
|
||||
'hero-adresse' => $setzeText('So finden Sie uns<br><strong>'.$strasse.'</strong><br>'.$plz.' '.$ort),
|
||||
'hero-markt' => static function (array $b): array {
|
||||
$b['props']['caption'] = 'Frische Vielfalt, jeden Tag.';
|
||||
|
||||
return $b;
|
||||
},
|
||||
'hero-text' => static function (array $b) use ($zeile, $button): array {
|
||||
// Die beiden Wege stehen zwischen Einleitung und Öffnungszeiten.
|
||||
array_splice($b['children'], 3, 0, [$zeile('hero-wege', [
|
||||
$button('hero-angebote', 'Aktuelle Angebote', '#angebote'),
|
||||
['id' => 'hero-besuch', 'type' => 'button', 'props' => ['text' => 'Anfahrt & Öffnungszeiten', 'href' => '#besuch'],
|
||||
'css' => "background:transparent;color:#123b26;box-shadow:inset 0 0 0 1px #123b26\n& span{display:none}\n&:hover{background:#123b26;color:#fff}"],
|
||||
], '14px', ['flexWrap' => 'wrap', 'margin' => ['top' => '0px', 'bottom' => '30px']])]);
|
||||
|
||||
return $b;
|
||||
},
|
||||
]),
|
||||
|
||||
// 2 – Angebote aus der App, auf Sand: Die Web-Ansicht ist für hellen Grund gestaltet
|
||||
// (dunkle Überschrift, weiße Karten); `ton` ändert daran derzeit nichts (24.09.2026).
|
||||
$section('angebote-bereich', [
|
||||
$row('angebote-kopf', [
|
||||
$column('angebote-kopf-titel', $abschnittTitel('angebote', 'Diese Woche', 'Aktuelle<br><em>Angebote.</em>', '@gold-text', '@forst')),
|
||||
$column('angebote-kopf-text', [
|
||||
$text('angebote-intro', 'Direkt aus unserer App – jede Woche neu. Die Angebote gelten im Markt, solange der Vorrat reicht.', [
|
||||
'desktop' => ['maxWidth' => '420px', 'fontSize' => '17px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], ['desktop' => ['alignSelf' => 'flex-end']]),
|
||||
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '40px', 'alignItems' => 'flex-end', 'margin' => ['top' => '0px', 'bottom' => '44px']],
|
||||
'mobile' => ['gap' => '8px', 'margin' => ['bottom' => '32px']],
|
||||
]),
|
||||
['id' => 'angebote-app', 'type' => 'embed',
|
||||
'props' => ['group' => 'external', 'title' => 'Aktuelle Angebote von Kantarci Markt', 'provider' => 'Kantarci-Markt-App', 'url' => $angeboteUrl, 'hint' => '', 'height' => 900],
|
||||
// Die Höhe setzt das Skript, sobald die App sie meldet; bis dahin gelten die 900 px.
|
||||
'css' => '& iframe{border-radius:0;background:transparent}',
|
||||
'script' => $hoehenSkript],
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '110px', 'bottom' => '110px'], 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'tablet' => ['padding' => ['top' => '84px', 'bottom' => '84px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
], 'angebote'),
|
||||
|
||||
// 3 – Die App: Text und Store-Knöpfe links, die Bildmarke als App-Symbol rechts.
|
||||
$section('app-bereich', [
|
||||
$row('app-zeile', [
|
||||
$column('app-text', [
|
||||
...$abschnittTitel('app', 'Die Kantarci-Markt-App', 'Angebote immer<br>in der Tasche.', '@gold-text', '@forst'),
|
||||
$text('app-intro', 'Mit unserer App haben Sie die aktuellen Angebote jederzeit dabei – auch unterwegs und vor dem Einkauf.', [
|
||||
'desktop' => ['maxWidth' => '520px', 'fontSize' => '18px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['bottom' => '30px']],
|
||||
'mobile' => ['fontSize' => '17px'],
|
||||
]),
|
||||
$zeile('app-stores', [
|
||||
$storeKnopf('app-ios', 'Laden im', 'App Store', $appStoreUrl),
|
||||
$storeKnopf('app-android', 'Jetzt bei', 'Google Play', $playStoreUrl),
|
||||
], '14px', ['flexWrap' => 'wrap']),
|
||||
]),
|
||||
$column('app-symbol', [
|
||||
$bild('app-symbol-bild', $medien['symbol'], 'App-Symbol von Kantarci Markt', [
|
||||
'desktop' => ['maxWidth' => '200px', 'width' => '200px', 'margin' => $seiten('0px'), 'rotate' => -6,
|
||||
'shadowY' => '24px', 'shadowBlur' => '50px', 'shadowColor' => '#062f2440', 'borderRadius' => $rund('44px'), 'overflow' => 'hidden'],
|
||||
'mobile' => ['maxWidth' => '140px', 'width' => '140px', 'borderRadius' => $rund('31px')],
|
||||
], null, '& img{display:block;width:100%;height:auto}'),
|
||||
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'center', 'alignItems' => 'center']]),
|
||||
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '60px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '3 2'],
|
||||
'mobile' => ['gap' => '44px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '110px', 'bottom' => '110px']],
|
||||
'tablet' => ['padding' => ['top' => '84px', 'bottom' => '84px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '72px']],
|
||||
], 'app'),
|
||||
|
||||
// 4 – Das Versprechen, wie auf der Vorab-Seite.
|
||||
$startseite['blocks'][1],
|
||||
|
||||
// 5 – Besuch: Adresse mit Route, Öffnungszeiten, Kontakt.
|
||||
$section('besuch-bereich', [
|
||||
$row('besuch-kopf', [
|
||||
$column('besuch-titel', $abschnittTitel('besuch', 'Besuchen Sie uns', 'Wir freuen uns<br>auf Sie.', '@gold-text', '@forst')),
|
||||
], [], ['desktop' => ['margin' => ['top' => '0px', 'bottom' => '40px']]]),
|
||||
$row('besuch-spalten', [
|
||||
$column('besuch-adresse', [
|
||||
$heading('besuch-adresse-h3', 'Adresse', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.18em', 'textTransform' => 'uppercase', 'color' => '@gold-text', 'margin' => ['bottom' => '14px']]]),
|
||||
$text('besuch-adresse-text', $name.'<br>'.$strasse.'<br>'.$plz.' '.$ort, ['desktop' => ['fontSize' => '18px', 'lineHeight' => 1.6, 'color' => '@forst', 'margin' => ['bottom' => '22px']]]),
|
||||
['id' => 'besuch-route', 'type' => 'button', 'props' => ['text' => 'Route planen', 'href' => $routeUrl, 'newTab' => true], 'css' => '& span{display:none}'],
|
||||
]),
|
||||
$column('besuch-zeiten', [
|
||||
$heading('besuch-zeiten-h3', 'Öffnungszeiten', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.18em', 'textTransform' => 'uppercase', 'color' => '@gold-text', 'margin' => ['bottom' => '14px']]]),
|
||||
$text('besuch-zeiten-text', implode('<br>', array_map(static fn (array $z): string => '<strong>'.$z[0].'</strong>'.$z[1], $oeffnungszeiten)), [
|
||||
'desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
], "& strong{display:block;margin-top:12px;color:#123b26;font-weight:700}\n& strong:first-child{margin-top:0}"),
|
||||
]),
|
||||
$column('besuch-kontakt', [
|
||||
$heading('besuch-kontakt-h3', 'Kontakt', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.18em', 'textTransform' => 'uppercase', 'color' => '@gold-text', 'margin' => ['bottom' => '14px']]]),
|
||||
$text('besuch-kontakt-text', 'Telefon folgt<br>E-Mail: <a href="mailto:'.$mail.'">'.$mail.'</a>', [
|
||||
'desktop' => ['fontSize' => '17px', 'lineHeight' => 1.9, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
], $linkCss),
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'three'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '48px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e4dcc8', 'padding' => ['top' => '36px']],
|
||||
'mobile' => ['gap' => '36px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '110px', 'bottom' => '110px']],
|
||||
'tablet' => ['padding' => ['top' => '84px', 'bottom' => '84px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '72px']],
|
||||
], 'besuch'),
|
||||
|
||||
// 6 – Abschluss wie auf der Vorab-Seite, mit neuem Gruß.
|
||||
$aendern($startseite['blocks'][2], [
|
||||
'abschluss-gruss-text' => $setzeText('Danke, dass Sie da sind.'),
|
||||
'abschluss-gruss-satz' => $setzeText('Bis gleich im Kantarci Markt.'),
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$eroeffnung = $einblenden($eroeffnung, [
|
||||
'hero-label' => ['fade'],
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-intro' => ['rise', 'medium'],
|
||||
'hero-wege' => ['rise', 'long'],
|
||||
'hero-termin' => ['rise', 'long'],
|
||||
'hero-adresse' => ['fade', 'long'],
|
||||
'app-text' => ['rise'],
|
||||
'app-symbol' => ['rise', 'short'],
|
||||
'versprechen-intro' => ['rise'],
|
||||
'versprechen-liste' => ['rise', 'short'],
|
||||
'abschluss-spruch' => ['rise'],
|
||||
'abschluss-gruss' => ['rise', 'short'],
|
||||
]);
|
||||
|
||||
$eroeffnungId = $anlegen('eroeffnung', $eroeffnung, 'Kantarci Markt – frisch, vielfältig, fair in Baden-Baden', $medien['markt']['id']);
|
||||
// Nur zum Ansehen: nicht in Suchmaschinen, nicht in der Sitemap, nirgends verlinkt.
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $eroeffnungId]);
|
||||
|
||||
/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Waldgrün, 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', '@gold-hell'),
|
||||
$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => ['fontSize' => '56px', 'fontWeight' => '400', 'lineHeight' => 1.1, 'letterSpacing' => '-0.03em', 'color' => '@papier', 'margin' => ['bottom' => '20px']],
|
||||
'mobile' => ['fontSize' => '40px'],
|
||||
]),
|
||||
$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text-gruen', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@forst', 'color' => '@papier', 'padding' => ['top' => '76px', 'bottom' => '68px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '44px']],
|
||||
]);
|
||||
|
||||
$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' => '@forst', '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' => '#e4dcc8', '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];
|
||||
};
|
||||
|
||||
$pruefen = static fn (string $was): string => '<strong>NOCH ZU PRÜFEN:</strong> '.$was;
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben von Kantarci Markt, Baden-Baden.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
|
||||
['Anbieter', $name.'<br>'.$pruefen('Inhaber bzw. Firma mit Rechtsform (etwa „… GmbH“ oder „Inhaber …, e. K.“).').'<br>'.$strasse.' '.$pruefen('Hausnummer').'<br>'.$plz.' '.$ort],
|
||||
['Vertreten durch', $pruefen('Geschäftsführer bzw. Inhaber.')],
|
||||
['Registereintrag', $pruefen('Handelsregisternummer und Registergericht, falls eingetragen.')],
|
||||
['Kontakt', "E-Mail: <a href=\"mailto:$mail\">$mail</a><br>".$pruefen('Ob das Postfach existiert, und eine Telefonnummer als zweiten schnellen Kontaktweg ergänzen.')],
|
||||
['Umsatzsteuer', $pruefen('Umsatzsteuer-Identifikationsnummer nach § 27a UStG, falls vorhanden.')],
|
||||
['Verantwortlich für den Inhalt', $pruefen('Name der verantwortlichen Person, 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: '.$name.'. Die Fotos von Marktstand und Tomaten sind Beispielbilder. '.$pruefen('Quelle und Lizenz der beiden Fotos eintragen.')],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | '.$name);
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Welche Daten beim Besuch der Website von Kantarci Markt verarbeitet werden.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortlicher', $name.', '.$strasse.', '.$plz.' '.$ort.". E-Mail <a href=\"mailto:$mail\">$mail</a>.<br><br>".$pruefen('Inhaber bzw. Firma wie im Impressum.')],
|
||||
['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. Kontakt per E-Mail', 'Wenn Sie uns schreiben, verarbeiten wir Ihre Adresse und Ihre Nachricht, um Ihr Anliegen zu beantworten (Art. 6 Abs. 1 lit. b oder f DSGVO). Wir löschen die Nachricht, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'],
|
||||
['5. 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.'],
|
||||
['6. 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 | '.$name);
|
||||
|
||||
$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-text'),
|
||||
$heading('fehler-titel', 'Diese Seite<br><em>gibt es nicht.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '64px', 'fontWeight' => '400', 'lineHeight' => 1.05, 'letterSpacing' => '-0.04em', 'color' => '@forst', 'margin' => ['bottom' => '22px']],
|
||||
'mobile' => ['fontSize' => '42px'],
|
||||
], '& em{font-style:italic;color:#8c6b27}'),
|
||||
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
|
||||
'desktop' => ['maxWidth' => '460px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@creme', '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' => $name,
|
||||
'description' => 'Kantarci Markt eröffnet bald in der Wilhelm-Drapp-Straße in Baden-Baden: frische Produkte, echte Vielfalt und faire Preise.',
|
||||
'email' => '',
|
||||
'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE',
|
||||
// Noch kein Geschäft mit Öffnungszeiten: keine LocalBusiness-Angaben, bis der Markt öffnet.
|
||||
'localBusiness' => false, '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,906 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut den Entwurf „Kantarci Reisen“ als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist Umuts Paket „Kantarci-Reisen-Komplettpaket.zip“ (08.10.2026): eine statische Seite
|
||||
* mit weißem Kopf, geteiltem Hero (Petrol links, Küstenfoto rechts), drei Angeboten, „Gute Reisen
|
||||
* beginnen mit einem Gespräch“, Anfrage auf Petrol mit weißer Formularkarte, reiseSoft-Abschnitt
|
||||
* und Fuß. Wortlaut, Farben (Petrol #0A363B, Orange #FF753D) und die kursive Serifenschrift für
|
||||
* die Pointe („wo*anders.*“) sind daraus übernommen. Gebaut ist alles aus gewöhnlichen Bausteinen,
|
||||
* Gestaltungswerten und eigenem CSS je Baustein – kein Kerncode, kein Plugin.
|
||||
*
|
||||
* Bewusste Abweichungen und Ergänzungen:
|
||||
* - Statt Arial die DM Sans vom eigenen Server; die Serifenpointe bleibt Georgia kursiv wie in der
|
||||
* Vorlage (Systemschrift, nichts zu laden).
|
||||
* - Das Formular ist ein echtes CMS-Formular mit Versand an info@kantarci-reisen.de, nicht die
|
||||
* Demo der Vorlage. Dazu Telefon, Reisezeitraum und Personenzahl, alle freiwillig.
|
||||
* - Die reiseSoft-Karte der Vorlage (drei Zeilen als Attrappe) ist ein echter Screenshot der
|
||||
* Übersicht – aufgenommen aus einer lokalen reiseSoft-Instanz mit deren fiktiven Demodaten,
|
||||
* also ohne Kundendaten. Daneben drei Funktionen, die reiseSoft wirklich hat, und ein Knopf zur
|
||||
* Plattform unter reisesoft.kantarci-reisen.de.
|
||||
* - Neu: „So läuft’s“ in drei Schritten, Anschrift im Abschnitt „Über uns“, Impressum,
|
||||
* Datenschutz und 404. „01 / 03“ im Hero (ohne Bedeutung) ist der Ort.
|
||||
* - Inhaber, Anschrift, E-Mail und Telefon von Umut. Eine USt-IdNr. gibt es nicht, Öffnungszeiten
|
||||
* sind nicht bekannt; beides erscheint deshalb nirgends.
|
||||
* - WhatsApp als zweiter Weg zur Anfrage: Knopf unter „Über uns“ und neben dem Formular, Link im
|
||||
* Fuß und eine runde Schaltfläche unten links auf jeder Seite. Gebaut aus dem Baustein
|
||||
* „Soziale Profile“ (Netzwerk WhatsApp aus den Einstellungen), der das Markensymbol mitbringt.
|
||||
*
|
||||
* 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-reisen.sh).
|
||||
* Erstes Argument: der Ordner aus build-kantarci-reisen-assets.php.
|
||||
*/
|
||||
|
||||
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-reisen')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Kantarci-Reisen-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 Reisen';
|
||||
$inhaber = 'I. Kantarci';
|
||||
$mail = 'info@kantarci-reisen.de';
|
||||
$strasse = 'Ooser Hauptstraße 51';
|
||||
$plz = '76532';
|
||||
$ort = 'Baden-Baden';
|
||||
$reisesoft = 'https://reisesoft.kantarci-reisen.de';
|
||||
// Telefon und WhatsApp von Umut (08.10.2026), dieselbe Mobilnummer.
|
||||
$telefon = '0170 2945935';
|
||||
$tel = 'tel:+491702945935';
|
||||
$whatsapp = 'https://wa.me/491702945935?text='.rawurlencode('Hallo Kantarci Reisen, ich interessiere mich für eine Reise.');
|
||||
$SCHEMA = DocumentValidator::SCHEMA_VERSION;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// 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'];
|
||||
$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w];
|
||||
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = '', string $breite = 'normal'): array {
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
if ('normal' !== $breite) {
|
||||
$block['layout'] = ['width' => $breite];
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$row = static function (string $id, array $children, array $design = [], array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
|
||||
if ([] !== $design) {
|
||||
$block['layout'] = ['design' => $design];
|
||||
}
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$zweiSpalten = ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']];
|
||||
|
||||
/** Eine Zeile, die nebeneinander stellt statt zu rastern. */
|
||||
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = [], string $css = '') 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, $css);
|
||||
};
|
||||
|
||||
$column = static function (string $id, array $children, array $style = [], string $css = '', string $sichtbar = 'all'): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
if ('all' !== $sichtbar) {
|
||||
$block['layout'] = ['visibility' => $sichtbar];
|
||||
}
|
||||
|
||||
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 = '', string $sichtbar = 'all'): array {
|
||||
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
if ('all' !== $sichtbar) {
|
||||
$block['layout'] = ['visibility' => $sichtbar];
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$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 fn (string $id, string $name, int $groesse, array $style = []): array => ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
|
||||
|
||||
/**
|
||||
* Ein Button wie in der Vorlage: ohne Pfeil, hebt sich beim Überfahren leicht an.
|
||||
*
|
||||
* @param string $art orange (Theme), petrol, rahmen (Petrol auf Hell), hell (Rahmen auf Petrol)
|
||||
*/
|
||||
$knopf = static function (string $id, string $inhalt, string $href, string $art = 'orange', array $style = [], string $icon = 'none') : array {
|
||||
$farbe = [
|
||||
'orange' => '',
|
||||
'petrol' => "background:#0a363b;color:#fff;\n&:hover{background:#20545a;color:#fff}\n",
|
||||
'rahmen' => "background:transparent;color:#0a363b;box-shadow:inset 0 0 0 1px #0a363b;\n&:hover{background:#f0f5f4;color:#0a363b}\n",
|
||||
'hell' => "background:transparent;color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.55);\n&:hover{background:rgba(255,255,255,.1);color:#fff}\n",
|
||||
][$art];
|
||||
$props = ['text' => $inhalt, 'href' => $href, 'icon' => $icon];
|
||||
if ('none' !== $icon) {
|
||||
$props['iconPosition'] = 'end';
|
||||
$props['iconMotion'] = 'slide';
|
||||
}
|
||||
|
||||
return ['id' => $id, 'type' => 'button', 'props' => $props, 'css' => "min-height:50px;padding:0 26px;margin-top:0;transition:transform .2s ease,background-color .2s ease;\n".$farbe.'&:hover{transform:translateY(-2px)}']
|
||||
+ ([] === $style ? [] : ['style' => $style]);
|
||||
};
|
||||
|
||||
/** WhatsApp als Knopf: Symbol und Name aus dem Baustein „Soziale Profile“, in WhatsApp-Grün. */
|
||||
$waKnopf = static fn (string $id, array $style = []): array => ['id' => $id, 'type' => 'social',
|
||||
'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => true, 'networks' => ['whatsapp']],
|
||||
'css' => "& a{min-height:50px;padding:0 24px;gap:10px;border-radius:4px;background:#25d366;color:#073b2f;font-size:15px;font-weight:600;transition:transform .2s ease,background-color .2s ease}\n& a:hover{background:#3ee07a;color:#073b2f;transform:translateY(-2px)}",
|
||||
] + ([] === $style ? [] : ['style' => $style]);
|
||||
|
||||
/**
|
||||
* Setzt die Einblendungen nach einer Liste.
|
||||
*
|
||||
* @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;
|
||||
};
|
||||
|
||||
// Die kursive Serifenpointe der Vorlage: auf Petrol Apricot, auf Hell in der Schriftfarbe.
|
||||
$serif = "& em{font-family:Georgia,serif;font-style:italic;font-weight:400;letter-spacing:-.045em}";
|
||||
$serifHell = $serif."\n& em{color:#ffab82}";
|
||||
$linkCss = "& a{color:#0a363b;text-decoration:underline;text-decoration-color:#ff753d;text-underline-offset:4px}\n& a:hover{color:#a43e13}";
|
||||
|
||||
/** Die Kleinzeile in Versalien („WOHIN ZIEHT ES DICH?“). */
|
||||
$kleinzeile = static fn (string $id, string $inhalt, string $farbe = '@text', string $unten = '22px') => $text($id, $inhalt, ['desktop' => [
|
||||
'fontSize' => '12px', 'fontWeight' => '600', 'letterSpacing' => '0.15em', 'lineHeight' => 1.7, 'textTransform' => 'uppercase', 'color' => $farbe, 'margin' => ['top' => '0px', 'bottom' => $unten],
|
||||
]]);
|
||||
|
||||
$h2Stil = [
|
||||
'desktop' => ['fontSize' => '49px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'letterSpacing' => '-0.04em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '40px'],
|
||||
'mobile' => ['fontSize' => '35px'],
|
||||
];
|
||||
$fliess = static fn (string $farbe = '@text', string $unten = '0px', string $breite = 'none', string $groesse = '16px') => [
|
||||
'desktop' => ['fontSize' => $groesse, 'lineHeight' => 1.8, 'color' => $farbe, 'maxWidth' => $breite, 'margin' => ['top' => '0px', 'bottom' => $unten]],
|
||||
'mobile' => ['maxWidth' => 'none'],
|
||||
];
|
||||
|
||||
// Ein Link über die ganze Karte: Die Karte ist positioniert, der Text darin nicht, also spannt
|
||||
// das ::after des Links die ganze Karte auf. Ein Link, ein Name, ganze Fläche klickbar.
|
||||
$ganzeKarte = "position:relative;\n& a::after{content:'';position:absolute;top:0;right:0;bottom:0;left:0}\n& a:focus-visible{outline:none}\n&:focus-within{outline:3px solid #ff753d;outline-offset:4px}";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => $firma, 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'tinte', 'name' => 'Petrol (Schrift, dunkle Flächen)', 'value' => '#0a363b'],
|
||||
['key' => 'petrol-hell', 'name' => 'Petrol hell (beim Überfahren)', 'value' => '#20545a'],
|
||||
['key' => 'orange', 'name' => 'Orange (Knöpfe, Linien)', 'value' => '#ff753d'],
|
||||
['key' => 'orange-hell', 'name' => 'Orange hell (Knöpfe beim Überfahren)', 'value' => '#ff8b5a'],
|
||||
['key' => 'apricot', 'name' => 'Apricot (Pointe auf Petrol)', 'value' => '#ffab82'],
|
||||
['key' => 'orange-text', 'name' => 'Orange dunkel (Schrift auf Weiß)', 'value' => '#a43e13'],
|
||||
['key' => 'knopf-schrift', 'name' => 'Schrift auf Orange', 'value' => '#102f30'],
|
||||
['key' => 'text', 'name' => 'Fließtext', 'value' => '#52696b'],
|
||||
['key' => 'linie', 'name' => 'Linien', 'value' => '#dbe3e3'],
|
||||
['key' => 'pale', 'name' => 'Helles Petrol (Flächen)', 'value' => '#f0f5f4'],
|
||||
['key' => 'auf-dunkel', 'name' => 'Schrift auf Petrol', 'value' => '#c0d5d5'],
|
||||
['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
['key' => 'body', 'name' => 'Text und Überschriften', 'family' => 'DM Sans', 'source' => 'google', 'weights' => [400, 500, 600, 700], 'fallback' => 'sans-serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'body', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@tinte', 'containerWidth' => '1200px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
|
||||
['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'body', 'fontSize' => 15, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@orange', 'color' => '@knopf-schrift', 'hoverBackgroundColor' => '@orange-hell', 'hoverColor' => '@knopf-schrift'],
|
||||
);
|
||||
echo "Farben, Schrift und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo.png', 'Kantarci Reisen'],
|
||||
'favicon' => ['favicon.png', 'Bildzeichen von Kantarci Reisen: K mit Sonne und Schwingen'],
|
||||
'kueste' => ['kueste.webp', 'Türkisfarbene Mittelmeerbucht mit sonniger Strandterrasse'],
|
||||
'reisesoft' => ['reisesoft-uebersicht.png', 'reiseSoft-Übersicht mit offenen Posten, Umsatz und den letzten Vorgängen (Beispieldaten)'],
|
||||
] 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";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Formular
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$forms->save('reiseanfrage', 'Reiseanfrage', [
|
||||
['name' => 'name', 'type' => 'text', 'label' => 'Dein Name', 'required' => true, 'help' => 'Vor- und Nachname'],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'Deine E-Mail', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Freiwillig – wenn wir dich lieber anrufen sollen'],
|
||||
['name' => 'anliegen', 'type' => 'select', 'label' => 'Anliegen', 'required' => true, 'options' => ['Türkei-Urlaub', 'Hotels weltweit', 'Flüge in die Türkei', 'reiseSoft für mein Reisebüro', 'Allgemeine Anfrage']],
|
||||
['name' => 'zeitraum', 'type' => 'text', 'label' => 'Reisezeitraum', 'required' => false, 'help' => 'z. B. Pfingstferien oder 2 Wochen im August'],
|
||||
['name' => 'personen', 'type' => 'number', 'label' => 'Personen', 'required' => false, 'min' => '1', 'max' => '30'],
|
||||
['name' => 'wuensche', 'type' => 'textarea', 'label' => 'Deine Wünsche', 'required' => true, 'help' => 'Reiseziel, Abflughafen, Budget, was dir wichtig ist …'],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich stimme der Verarbeitung meiner Angaben gemäß Datenschutzhinweisen zu.'],
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Reiseanfrage über die Website', 'success' => 'Danke! Deine Anfrage ist bei uns angekommen. Wir melden uns persönlich bei dir – meist innerhalb eines Werktags.', 'replyToField' => 'email', 'buttonLabel' => 'Anfrage senden'], 0);
|
||||
echo "Formular angelegt: reiseanfrage\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopfbereich: weiß, Logo, Menü, „Reise anfragen“ als Menü-Button
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf', [
|
||||
$zeile('kopf-zeile', [
|
||||
$bild('kopf-logo', $medien['logo'], $firma.' – zur Startseite', [
|
||||
// Feste Breite: sonst bekommt das Logo seine Breite erst nach dem Laden (CLS).
|
||||
'desktop' => ['width' => '230px', 'maxWidth' => '230px', 'flexShrink' => 0, 'margin' => $seiten('0px')],
|
||||
'mobile' => ['width' => '132px', 'maxWidth' => '132px'],
|
||||
], '/', '& img{display:block;width:100%;height:auto;border-radius:0}'),
|
||||
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet', 'burgerLabel' => 'icon'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '15px', 'fontWeight' => '500', 'color' => '@tinte',
|
||||
'burgerColor' => '@tinte', 'burgerBackgroundColor' => '@papier', 'burgerBorderColor' => '@linie', 'burgerBorderRadius' => '4px',
|
||||
]],
|
||||
// Orange Linie, die beim Überfahren von links einläuft; das Klappmenü liegt als
|
||||
// Fläche unter dem ganzen Kopf.
|
||||
'css' => "& .menu-list{gap:4px 36px;align-items:center}\n"
|
||||
."& .menu-list a{position:relative;padding:8px 0;text-decoration:none}\n"
|
||||
."& .menu-list a::after{content:'';position:absolute;left:0;right:100%;bottom:2px;height:2px;background:#ff753d;transition:right .25s ease}\n"
|
||||
."& .menu-list a:hover::after{right:0}\n"
|
||||
// Der Menü-Button ist auch ein Link der Liste: Polster und Linie gehören nicht zu ihm.
|
||||
."& .menu-list .menu-button{padding:12px 22px}\n"
|
||||
."& .menu-list .menu-button::after{display:none}\n"
|
||||
."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n"
|
||||
."& .menu-burger-body{position:absolute;left:0;right:0;top:100%;background:#fff;padding:4px 20px 20px;border-top:1px solid #dbe3e3;box-shadow:0 18px 30px rgba(10,54,59,.1);font-size:18px}\n"
|
||||
."& .menu-burger-body .menu-list{flex-direction:column;align-items:stretch;gap:0}\n"
|
||||
."& .menu-burger-body .menu-list a{display:block;padding:14px 0;border-bottom:1px solid #e8eded}\n"
|
||||
.'& .menu-burger-body .menu-list a::after{display:none}'],
|
||||
], '28px', ['justifyContent' => 'space-between', 'minHeight' => '96px'], ['minHeight' => '72px', 'gap' => '6px']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '#fffffff5', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie'],
|
||||
], null, '', 'wide'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Fußbereich
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$fussLink = "& a{color:#52696b;text-decoration:none;transition:color .2s}\n& a:hover{color:#0a363b}";
|
||||
$fussTitel = static fn (string $id, string $inhalt) => $kleinzeile($id, $inhalt, '@tinte', '14px');
|
||||
$fussText = static fn (string $id, string $inhalt) => $text($id, $inhalt, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.9, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $fussLink."\n& a{overflow-wrap:anywhere}");
|
||||
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$column('fuss-raster', [
|
||||
$column('fuss-marke', [
|
||||
$bild('fuss-logo', $medien['logo'], $firma, ['desktop' => ['width' => '200px', 'maxWidth' => '200px', 'margin' => $seiten('0px')]], null, '& img{display:block;width:100%;height:auto;border-radius:0}'),
|
||||
$text('fuss-spruch', 'Türkei im Herzen. Die Welt im Blick.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '18px', 'bottom' => '0px']]]),
|
||||
]),
|
||||
$column('fuss-reisen', [
|
||||
$fussTitel('fuss-reisen-titel', 'Reisen'),
|
||||
$fussText('fuss-reisen-liste', '<a href="/#reisen">Türkei-Urlaub</a><br><a href="/#reisen">Hotels weltweit</a><br><a href="/#reisen">Flüge in die Türkei</a><br><a href="/#kontakt">Reise anfragen</a>'),
|
||||
]),
|
||||
$column('fuss-business', [
|
||||
$fussTitel('fuss-business-titel', 'Für Reisebüros'),
|
||||
$fussText('fuss-business-liste', '<a href="/#reisesoft">reiseSoft kennenlernen</a><br><a href="'.$reisesoft.'">Zur reiseSoft-Plattform</a>'),
|
||||
]),
|
||||
$column('fuss-kontakt', [
|
||||
$fussTitel('fuss-kontakt-titel', 'Kontakt'),
|
||||
$fussText('fuss-kontakt-text', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort.'<br><a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a><br><a href="'.$whatsapp.'">WhatsApp</a>'),
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '40px', 'padding' => ['top' => '64px', 'bottom' => '40px']],
|
||||
'tablet' => ['gridColumns' => 2],
|
||||
'mobile' => ['gridColumns' => 1, 'gap' => '32px', 'padding' => ['top' => '48px', 'bottom' => '32px']],
|
||||
]),
|
||||
// Auf jeder Seite unten links: WhatsApp als runde Schaltfläche (rechts sitzt „nach oben“).
|
||||
['id' => 'fuss-whatsapp', 'type' => 'social', 'props' => ['size' => 32, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => false, 'networks' => ['whatsapp']],
|
||||
'css' => "position:fixed;left:20px;bottom:20px;z-index:40;margin:0\n& a{width:58px;height:58px;justify-content:center;border-radius:50%;background:#25d366;color:#073b2f;box-shadow:0 10px 28px rgba(10,54,59,.28);transition:transform .2s ease}\n& a:hover{transform:scale(1.07);color:#073b2f}"],
|
||||
$zeile('fuss-unten', [
|
||||
$text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '13px', 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
|
||||
$zeile('fuss-links', [
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
|
||||
'style' => ['desktop' => ['fontSize' => '13px', 'gap' => '24px', 'color' => '@text']],
|
||||
'css' => $fussLink],
|
||||
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '13px', 'margin' => ['bottom' => '0px']]], $fussLink),
|
||||
], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
|
||||
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'rowGap' => '12px', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '22px', 'bottom' => '32px']],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'borderWidth' => $nurOben('7px'), 'borderStyle' => 'solid', 'borderColor' => '@tinte'],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Ein Angebot: Nummer und Kleinzeile, Titel, Satz, Textlink; die ganze Karte ist der Link. */
|
||||
$angebot = static function (int $n, string $klein, string $icon, string $titel, string $satz, string $link) use ($column, $zeile, $text, $heading, $symbol, $nurOben, $ganzeKarte): array {
|
||||
$id = 'angebot-'.$n;
|
||||
|
||||
return $column($id, [
|
||||
$zeile($id.'-kopf', [
|
||||
$symbol($id.'-icon', $icon, 24, ['desktop' => ['color' => '@orange-text']]),
|
||||
$text($id.'-klein', $klein, ['desktop' => ['fontSize' => '11px', 'fontWeight' => '600', 'letterSpacing' => '0.12em', 'textTransform' => 'uppercase', 'color' => '#687e80', 'margin' => ['bottom' => '0px']]]),
|
||||
], '12px', ['justifyContent' => 'space-between', 'margin' => ['bottom' => '30px']]),
|
||||
$heading($id.'-titel', $titel, 3, [
|
||||
'desktop' => ['fontSize' => '30px', 'fontWeight' => '500', 'lineHeight' => 1.15, 'letterSpacing' => '-0.035em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '18px']],
|
||||
'mobile' => ['fontSize' => '28px'],
|
||||
]),
|
||||
$text($id.'-satz', $satz, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '@text', 'maxWidth' => '320px', 'margin' => ['bottom' => '26px']], 'mobile' => ['maxWidth' => 'none']]),
|
||||
$text($id.'-link', '<a href="#kontakt">'.$link.'</a>', ['desktop' => ['fontSize' => '14px', 'fontWeight' => '600', 'margin' => ['top' => 'auto', 'bottom' => '0px']]],
|
||||
"& a{display:inline-block;color:#0a363b;text-decoration:none;border-bottom:1px solid #ff753d;padding-bottom:6px;transition:border-color .2s}\n& a:hover{border-bottom-color:#0a363b}"),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'padding' => ['top' => '30px', 'right' => '30px', 'bottom' => '34px', 'left' => '30px'],
|
||||
'borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $n > 1 ? '1px' : '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie'],
|
||||
'mobile' => ['padding' => ['top' => '26px', 'right' => '0px', 'bottom' => '28px', 'left' => '0px'], 'borderWidth' => $nurOben($n > 1 ? '1px' : '0px')],
|
||||
], $ganzeKarte."\ntransition:background-color .25s ease;\n&:hover{background-color:#f0f5f4}");
|
||||
};
|
||||
|
||||
/** Ein Schritt: große Ziffer in Orange-Linie, Titel, Satz. */
|
||||
$schritt = static fn (int $n, string $titel, string $satz) => $column('schritt-'.$n, [
|
||||
$text('schritt-'.$n.'-nummer', sprintf('%02d', $n), ['desktop' => ['fontSize' => '13px', 'fontWeight' => '600', 'letterSpacing' => '0.12em', 'color' => '@orange-text', 'borderWidth' => $nurOben('2px'), 'borderStyle' => 'solid', 'borderColor' => '@orange', 'padding' => ['top' => '16px'], 'margin' => ['bottom' => '14px']]]),
|
||||
$heading('schritt-'.$n.'-titel', $titel, 3, ['desktop' => ['fontSize' => '22px', 'fontWeight' => '500', 'lineHeight' => 1.25, 'letterSpacing' => '-0.02em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '10px']]]),
|
||||
$text('schritt-'.$n.'-satz', $satz, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
|
||||
]);
|
||||
|
||||
/** Eine Funktion von reiseSoft: Nummer im Kästchen, Titel, Satz. */
|
||||
$funktion = static fn (int $n, string $titel, string $satz) => $zeile('funktion-'.$n, [
|
||||
$text('funktion-'.$n.'-nummer', sprintf('%02d', $n), ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '36px', 'height' => '36px', 'flexShrink' => 0, 'backgroundColor' => '@papier', 'borderRadius' => $rund('4px'), 'fontSize' => '12px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('funktion-'.$n.'-text', '<strong>'.$titel.'</strong>'.$satz, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.55, 'color' => '@text', 'margin' => ['bottom' => '0px']]], '& strong{display:block;font-size:15px;font-weight:600;color:#0a363b;margin-bottom:2px}'),
|
||||
], '16px', ['alignItems' => 'flex-start', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#d5dfde', 'padding' => ['top' => '16px', 'bottom' => '16px']]);
|
||||
|
||||
// Formular im Zuschnitt der Vorlage: Felder hell mit Rahmen, Name/E-Mail, Telefon/Anliegen und
|
||||
// Zeitraum/Personen nebeneinander, Knopf über die ganze Breite.
|
||||
$formularCss = '& .block-form{max-width:none;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));column-gap:18px;row-gap:18px}
|
||||
& .block-form>.form-field{grid-column:1/-1}
|
||||
& .block-form>.cms-button{grid-column:1/-1}
|
||||
& .block-form>.form-error-summary{grid-column:1/-1}
|
||||
& .block-form>.form-success{grid-column:1/-1}
|
||||
& .block-form>.form-missing{grid-column:1/-1}
|
||||
& .block-form>[data-label="Dein Name"]{grid-column:span 1}
|
||||
& .block-form>[data-label="Deine E-Mail"]{grid-column:span 1}
|
||||
& .block-form>[data-label="Telefon"]{grid-column:span 1}
|
||||
& .block-form>[data-label="Anliegen"]{grid-column:span 1}
|
||||
& .block-form>[data-label="Reisezeitraum"]{grid-column:span 1}
|
||||
& .block-form>[data-label="Personen"]{grid-column:span 1}
|
||||
& .form-field{align-content:start}
|
||||
& .form-field label{font-size:14px;font-weight:600;color:#0a363b}
|
||||
& .form-field input{background:#f5f8f7;border:1px solid #b5c6c6;border-radius:4px;padding:13px 14px;font-size:16px;color:#0a363b;min-height:48px}
|
||||
& .form-field select{background:#f5f8f7;border:1px solid #b5c6c6;border-radius:4px;padding:13px 14px;font-size:16px;color:#0a363b;min-height:48px}
|
||||
& .form-field textarea{background:#f5f8f7;border:1px solid #b5c6c6;border-radius:4px;padding:13px 14px;font-size:16px;color:#0a363b;min-height:110px}
|
||||
& .form-field input:focus{border-color:#0a363b;outline:2px solid #ff753d;outline-offset:2px}
|
||||
& .form-field select:focus{border-color:#0a363b;outline:2px solid #ff753d;outline-offset:2px}
|
||||
& .form-field textarea:focus{border-color:#0a363b;outline:2px solid #ff753d;outline-offset:2px}
|
||||
& .field-note{font-size:12px;color:#52696b;opacity:1}
|
||||
& .check-row label{font-weight:400;font-size:13px;line-height:1.6;color:#52696b}
|
||||
& .check-row input{accent-color:#0a363b}
|
||||
& .cms-button{width:100%;justify-content:center;min-height:54px;font-size:16px;transition:transform .2s ease,background-color .2s ease}
|
||||
& .cms-button:hover{transform:translateY(-2px)}
|
||||
& .cms-button .button-arrow{display:none}';
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Zeit für woanders.',
|
||||
'description' => 'Türkei im Herzen. Die Welt im Blick. Persönliche Reiseberatung in Baden-Baden: Türkei-Urlaub, Hotels weltweit und Flüge in die Türkei.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Petrol links mit Text, Foto rechts mit Bildunterschrift.
|
||||
$section('hero', [
|
||||
$row('hero-zeile', [
|
||||
$column('hero-text', [
|
||||
$kleinzeile('hero-klein', 'Türkei im Herzen. Die Welt im Blick.', '@auf-dunkel', '0px'),
|
||||
$heading('hero-titel', 'Zeit für<br>wo<em>anders.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '104px', 'fontWeight' => '500', 'lineHeight' => 1.01, 'letterSpacing' => '-0.058em', 'color' => '@papier', 'margin' => ['top' => '30px', 'bottom' => '30px']],
|
||||
'tablet' => ['fontSize' => '76px'],
|
||||
'mobile' => ['fontSize' => '68px', 'margin' => ['top' => '24px', 'bottom' => '22px']],
|
||||
], $serifHell),
|
||||
$text('hero-satz', 'Salz auf der Haut. Sonne im Gesicht.<br>Und eine Reise, die zu dir passt.', [
|
||||
'desktop' => ['fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '#d9e4e3', 'margin' => ['bottom' => '32px']],
|
||||
'mobile' => ['fontSize' => '16px', 'margin' => ['bottom' => '24px']],
|
||||
]),
|
||||
$knopf('hero-knopf', 'Deine Auszeit planen', '#kontakt'),
|
||||
$zeile('hero-unten', [
|
||||
$text('hero-unten-links', 'Persönlich beraten.<br>Entspannt verreisen.', ['desktop' => ['fontSize' => '11px', 'lineHeight' => 1.6, 'letterSpacing' => '0.14em', 'textTransform' => 'uppercase', 'color' => '#a1bcbc', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('hero-unten-rechts', 'Baden-Baden · Oos', ['desktop' => ['fontSize' => '12px', 'letterSpacing' => '0.08em', 'color' => '#a1bcbc', 'margin' => ['bottom' => '0px']]], 'white-space:nowrap'),
|
||||
], '20px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'width' => '100%', 'margin' => ['top' => 'auto'], 'padding' => ['top' => '56px']], ['padding' => ['top' => '32px']]),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'backgroundColor' => '@tinte', 'color' => '@papier',
|
||||
'padding' => ['top' => '68px', 'right' => '11%', 'bottom' => '34px', 'left' => '11%']],
|
||||
'tablet' => ['padding' => ['top' => '48px', 'right' => '8%', 'bottom' => '30px', 'left' => '8%']],
|
||||
'mobile' => ['padding' => ['top' => '40px', 'right' => '18px', 'bottom' => '28px', 'left' => '18px']],
|
||||
]),
|
||||
$column('hero-foto-spalte', [
|
||||
// Das größte Element über der Falz: keine Einblendung (LCP).
|
||||
$bild('hero-foto', $medien['kueste'], 'Türkisfarbene Mittelmeerbucht mit sonniger Strandterrasse', [
|
||||
'desktop' => ['position' => 'absolute', 'top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px', 'margin' => $seiten('0px')],
|
||||
], null, '& img{width:100%;height:100%;object-fit:cover;object-position:55% center;border-radius:0}'),
|
||||
$text('hero-etikett', 'Reiseinspiration', ['desktop' => ['position' => 'absolute', 'top' => '24px', 'right' => '22px', 'backgroundColor' => '@papier', 'fontSize' => '10px', 'fontWeight' => '600', 'letterSpacing' => '0.15em', 'textTransform' => 'uppercase', 'color' => '@tinte', 'padding' => ['top' => '9px', 'right' => '11px', 'bottom' => '9px', 'left' => '11px'], 'margin' => ['bottom' => '0px']]]),
|
||||
$text('hero-bildzeile', '<strong>Dein nächster Lieblingsort</strong>Ein bisschen mehr Meer.', [
|
||||
'desktop' => ['position' => 'absolute', 'left' => '32px', 'bottom' => '32px', 'maxWidth' => '82%', 'backgroundColor' => '@papier', 'fontSize' => '23px', 'fontWeight' => '500', 'lineHeight' => 1.2, 'letterSpacing' => '-0.025em', 'color' => '@tinte',
|
||||
'padding' => ['top' => '19px', 'right' => '23px', 'bottom' => '19px', 'left' => '23px'], 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['left' => '18px', 'bottom' => '18px', 'fontSize' => '20px', 'padding' => ['top' => '15px', 'right' => '18px', 'bottom' => '15px', 'left' => '18px']],
|
||||
], '& strong{display:block;font-size:10px;font-weight:600;letter-spacing:.17em;text-transform:uppercase;margin-bottom:9px}'),
|
||||
], [
|
||||
'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'minHeight' => '650px', 'backgroundColor' => '#3e8a94'],
|
||||
'tablet' => ['minHeight' => '440px'],
|
||||
'mobile' => ['minHeight' => '370px'],
|
||||
]),
|
||||
], $zweiSpalten, ['desktop' => ['gap' => '0px', 'margin' => ['top' => '0px'], 'columnWidths' => '9 11', 'alignItems' => 'stretch']]),
|
||||
], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '0px']], 'mobile' => ['padding' => ['top' => '0px', 'bottom' => '0px']]], null, '& .section-inner{max-width:1600px;margin-inline:auto}', 'full'),
|
||||
|
||||
// 2 – Reisen: Kopf mit Satz rechts, drei Angebote zwischen Linien.
|
||||
$section('reisen-bereich', [
|
||||
$row('reisen-kopf', [
|
||||
$column('reisen-kopf-titel', [
|
||||
$kleinzeile('reisen-klein', 'Wohin zieht es dich?'),
|
||||
$heading('reisen-titel', 'Du hast Fernweh.<br>Wir haben Ideen.', 2, $h2Stil),
|
||||
]),
|
||||
$text('reisen-satz', 'Eine Auszeit am Meer, ein neuer Lieblingsort oder ein Besuch bei der Familie. Wir finden die passende Reise für dich.', [
|
||||
'desktop' => ['maxWidth' => '360px', 'fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['maxWidth' => 'none'],
|
||||
]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'rowGap' => '22px', 'columnGap' => '50px', 'margin' => ['top' => '0px', 'bottom' => '46px']],
|
||||
'mobile' => ['margin' => ['bottom' => '30px']],
|
||||
]),
|
||||
$column('reisen-raster', [
|
||||
$angebot(1, 'Unser Schwerpunkt', 'sun', 'Türkei.<br>Ganz nach dir.', 'Von der Türkischen Riviera bis zur Ägäis. Entdecke Urlaub zwischen Gastfreundschaft, Küste und Kultur – mit Tipps aus erster Hand.', 'Türkei-Urlaub anfragen'),
|
||||
$angebot(2, 'Hotels weltweit', 'building', 'Einchecken.<br>Abschalten.', 'Kleines Boutiquehotel oder großzügiges Resort? Wir helfen dir, den richtigen Ort für deine Auszeit zu finden.', 'Hotel anfragen'),
|
||||
$angebot(3, 'Flüge in die Türkei', 'navigation', 'Manchmal ist<br>das Ziel Zuhause.', 'Für Urlaub, Familie oder einfach Sehnsucht. Wir suchen dir die passende Verbindung – auch ab Karlsruhe/Baden-Baden, Stuttgart oder Frankfurt.', 'Türkei-Flug anfragen'),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '0px', 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie'],
|
||||
'mobile' => ['gridColumns' => 1],
|
||||
], '& [id="angebot-1"]{padding-left:0}'),
|
||||
], [
|
||||
'desktop' => ['padding' => ['top' => '96px', 'bottom' => '88px']],
|
||||
'mobile' => ['padding' => ['top' => '56px', 'bottom' => '48px']],
|
||||
], 'reisen'),
|
||||
|
||||
// 3 – Über uns: „Gute Reisen beginnen mit einem Gespräch“, dazu die Anschrift.
|
||||
$section('beratung-bereich', [
|
||||
$row('beratung-zeile', [
|
||||
$column('beratung-links', [
|
||||
$kleinzeile('beratung-klein', 'Dein Reisebüro.<br>Dein direkter Draht.'),
|
||||
$zeile('beratung-ort', [
|
||||
$symbol('beratung-ort-icon', 'pin', 20, ['desktop' => ['color' => '@orange-text', 'flexShrink' => 0]]),
|
||||
$text('beratung-ort-text', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort.'-Oos', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
|
||||
], '10px', ['alignItems' => 'flex-start', 'margin' => ['top' => '8px']]),
|
||||
$zeile('beratung-mail', [
|
||||
$symbol('beratung-mail-icon', 'mail', 20, ['desktop' => ['color' => '@orange-text', 'flexShrink' => 0]]),
|
||||
$text('beratung-mail-text', '<a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
], '10px', ['margin' => ['top' => '14px']]),
|
||||
$zeile('beratung-telefon', [
|
||||
$symbol('beratung-telefon-icon', 'phone', 20, ['desktop' => ['color' => '@orange-text', 'flexShrink' => 0]]),
|
||||
$text('beratung-telefon-text', '<a href="'.$tel.'">'.$telefon.'</a>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
], '10px', ['margin' => ['top' => '14px']]),
|
||||
]),
|
||||
$column('beratung-rechts', [
|
||||
$heading('beratung-titel', 'Gute Reisen beginnen<br>mit einem <em>Gespräch.</em>', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'fontSize' => '56px']], $serif),
|
||||
$zeile('beratung-unten', [
|
||||
$text('beratung-satz', 'Erzähl uns, was dir wichtig ist. Wir hören zu, denken mit und planen mit dir. Persönlich, unkompliziert und mit besonderer Nähe zur Türkei – ob Strandurlaub, Familienbesuch oder Städtereise nach Istanbul.', [
|
||||
'desktop' => ['flexGrow' => 1, 'flexBasis' => '300px', 'maxWidth' => '430px', 'fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['flexBasis' => 'auto', 'maxWidth' => 'none'],
|
||||
]),
|
||||
$zeile('beratung-knoepfe', [
|
||||
$knopf('beratung-knopf', 'Lass uns sprechen', '#kontakt', 'rahmen'),
|
||||
$waKnopf('beratung-whatsapp'),
|
||||
], '12px', ['flexWrap' => 'wrap', 'flexShrink' => 0]),
|
||||
], '40px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'rowGap' => '22px', 'margin' => ['top' => '30px']]),
|
||||
]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '30px', 'margin' => ['top' => '0px'], 'columnWidths' => '1 2'],
|
||||
'tablet' => ['gap' => '24px'],
|
||||
]),
|
||||
// Neu gegenüber der Vorlage: der Weg zur Reise in drei Schritten.
|
||||
$column('schritte', [
|
||||
$schritt(1, 'Wunsch erzählen', 'Per Formular, E-Mail oder direkt bei uns in Oos. Ein grober Plan genügt.'),
|
||||
$schritt(2, 'Vorschläge bekommen', 'Wir vergleichen Hotels, Flüge und Termine und melden uns mit passenden Ideen.'),
|
||||
$schritt(3, 'Entspannt losfliegen', 'Du entscheidest, wir buchen. Unterlagen und Ansprechpartner kommen von uns.'),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '40px', 'margin' => ['top' => '72px']],
|
||||
'mobile' => ['gridColumns' => 1, 'gap' => '28px', 'margin' => ['top' => '48px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['padding' => ['top' => '0px', 'bottom' => '96px']],
|
||||
'mobile' => ['padding' => ['top' => '0px', 'bottom' => '56px']],
|
||||
], 'beratung', '& h2 br{display:inline}'),
|
||||
|
||||
// 4 – Anfrage: Petrol mit oranger Oberkante, weiße Formularkarte rechts.
|
||||
$section('kontakt-bereich', [
|
||||
$row('kontakt-zeile', [
|
||||
$column('kontakt-text', [
|
||||
$kleinzeile('kontakt-klein', 'Der erste Schritt zur Auszeit', '@auf-dunkel'),
|
||||
$heading('kontakt-titel', 'Wohin darf<br>es <em>gehen?</em>', 2, [
|
||||
'desktop' => ['fontSize' => '66px', 'fontWeight' => '500', 'lineHeight' => 1.05, 'letterSpacing' => '-0.045em', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '25px']],
|
||||
'mobile' => ['fontSize' => '54px'],
|
||||
], $serifHell),
|
||||
$text('kontakt-satz', 'Ein Wunsch. Ein grober Plan. Oder schon ein festes Ziel. Erzähl uns davon – wir melden uns persönlich bei dir.', $fliess('#c2d3d3', '32px', '320px')),
|
||||
$text('kontakt-whatsapp-satz', 'Lieber kurz schreiben? Auf WhatsApp antworten wir dir persönlich.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'color' => '#c2d3d3', 'margin' => ['bottom' => '14px']]]),
|
||||
$waKnopf('kontakt-whatsapp', ['desktop' => ['margin' => ['bottom' => '36px']]]),
|
||||
$text('kontakt-hinweis', 'Türkei-Reisen · Hotels weltweit · Türkei-Flüge<br><a href="'.$tel.'">'.$telefon.'</a> · <a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '13px', 'lineHeight' => 1.9, 'color' => '#a5bebe', 'margin' => ['bottom' => '0px']]],
|
||||
"& a{color:#fff;text-decoration:underline;text-decoration-color:#ff753d;text-underline-offset:4px}\n& a:hover{color:#ffab82}"),
|
||||
]),
|
||||
$column('kontakt-karte', [
|
||||
$kleinzeile('kontakt-karte-klein', 'Persönliche Reiseberatung', '@orange-text', '10px'),
|
||||
$heading('kontakt-karte-titel', 'Deine Reiseanfrage', 3, ['desktop' => ['fontSize' => '30px', 'fontWeight' => '500', 'letterSpacing' => '-0.035em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '10px']], 'mobile' => ['fontSize' => '28px']]),
|
||||
$text('kontakt-karte-satz', 'Trag deine Wünsche ein – hier beginnt deine nächste Reise.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '26px']]]),
|
||||
['id' => 'kontakt-formular', 'type' => 'form', 'props' => ['form' => 'reiseanfrage'], 'css' => $formularCss],
|
||||
$text('kontakt-karte-hinweis', 'Deine Angaben nutzen wir nur für deine Anfrage. Mehr in den <a href="/datenschutz">Datenschutzhinweisen</a>.', ['desktop' => ['fontSize' => '12px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '16px', 'bottom' => '0px']]], $linkCss),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'color' => '@tinte', 'borderRadius' => $rund('8px'), 'padding' => $seiten('36px'), 'shadowY' => '20px', 'shadowBlur' => '50px', 'shadowColor' => '#001c2030'],
|
||||
'mobile' => ['padding' => ['top' => '25px', 'right' => '20px', 'bottom' => '25px', 'left' => '20px']],
|
||||
]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '7%', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '9 11'],
|
||||
'tablet' => ['gap' => '40px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '72px', 'bottom' => '72px'], 'borderWidth' => $nurOben('7px'), 'borderStyle' => 'solid', 'borderColor' => '@orange'],
|
||||
'mobile' => ['padding' => ['top' => '44px', 'bottom' => '40px']],
|
||||
], 'kontakt'),
|
||||
|
||||
// 5 – reiseSoft: Text und Funktionen links, echter Screenshot rechts.
|
||||
$section('reisesoft-bereich', [
|
||||
$row('reisesoft-zeile', [
|
||||
$column('reisesoft-text', [
|
||||
$zeile('reisesoft-marke', [
|
||||
$text('reisesoft-name', 'reise<strong>Soft</strong>', ['desktop' => ['fontSize' => '25px', 'letterSpacing' => '-0.04em', 'color' => '@tinte', 'margin' => ['bottom' => '0px']]], '& strong{font-weight:700}'),
|
||||
$text('reisesoft-etikett', 'Für Reisebüros', ['desktop' => ['fontSize' => '10px', 'fontWeight' => '600', 'letterSpacing' => '0.12em', 'textTransform' => 'uppercase', 'color' => '@tinte', 'backgroundColor' => '@papier', 'padding' => ['top' => '6px', 'right' => '8px', 'bottom' => '6px', 'left' => '8px'], 'margin' => ['bottom' => '0px']]]),
|
||||
], '14px', ['margin' => ['bottom' => '26px']]),
|
||||
$kleinzeile('reisesoft-klein', 'Für die andere Seite des Reisens'),
|
||||
$heading('reisesoft-titel', 'Weniger Verwaltung.<br>Mehr <em>Reisebüro.</em>', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'fontSize' => '42px', 'margin' => ['top' => '0px', 'bottom' => '24px']]], $serif),
|
||||
$text('reisesoft-satz', 'reiseSoft ist unsere Webplattform für Reisebüros – entstanden aus dem eigenen Büroalltag. Buchungen, Kunden, Rechnungen und Zahlungen an einem Ort, damit mehr Zeit für die Beratung bleibt.', $fliess('@text', '24px', '440px', '15px')),
|
||||
$column('reisesoft-funktionen', [
|
||||
$funktion(1, 'Buchung per Strg+V übernehmen', 'Aus Amadeus oder dem Airline-Portal kopieren, einfügen, prüfen – fertig.'),
|
||||
$funktion(2, 'Rechnung und E-Rechnung', 'Fortlaufende Rechnungsnummern, ZUGFeRD und XRechnung, Versand per E-Mail.'),
|
||||
$funktion(3, 'Offene Posten im Blick', 'Zahlungen buchen, Umsatz und Ertrag auswerten – direkt im Browser.'),
|
||||
], ['desktop' => ['margin' => ['bottom' => '30px']]], '& [id="funktion-3"]{border-bottom:1px solid #d5dfde}'),
|
||||
$zeile('reisesoft-knoepfe', [
|
||||
$knopf('reisesoft-plattform', 'Zur reiseSoft-Plattform', $reisesoft, 'petrol', [], 'external'),
|
||||
$knopf('reisesoft-anfragen', 'reiseSoft kennenlernen', '#kontakt', 'rahmen'),
|
||||
], '12px', ['flexWrap' => 'wrap']),
|
||||
]),
|
||||
$column('reisesoft-bild-spalte', [
|
||||
$column('reisesoft-fenster', [
|
||||
$zeile('reisesoft-fenster-leiste', [
|
||||
$text('reisesoft-fenster-adresse', 'reisesoft.kantarci-reisen.de', ['desktop' => ['flexGrow' => 1, 'fontSize' => '11px', 'color' => '@text', 'textAlign' => 'center', 'backgroundColor' => '@pale', 'borderRadius' => $rund('4px'), 'padding' => ['top' => '5px', 'right' => '10px', 'bottom' => '5px', 'left' => '10px'], 'margin' => ['bottom' => '0px']]]),
|
||||
], '16px', ['padding' => ['top' => '10px', 'right' => '14px', 'bottom' => '10px', 'left' => '14px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e8eded'], [], [],
|
||||
// Die drei Punkte eines Browserfensters, als Schatten eines einzigen Punkts.
|
||||
"&::before{content:'';flex-shrink:0;width:9px;height:9px;margin-right:30px;border-radius:50%;background:#ff753d;box-shadow:15px 0 #d5dfde,30px 0 #d5dfde}"),
|
||||
$bild('reisesoft-bild', $medien['reisesoft'], 'reiseSoft-Übersicht mit offenen Posten, Umsatz und den letzten Vorgängen (Beispieldaten) – zur Plattform', ['desktop' => ['margin' => $seiten('0px')]], $reisesoft,
|
||||
"& img{display:block;width:100%;height:auto;border-radius:0}\n& .image-link:focus-visible{outline:3px solid #ff753d;outline-offset:-3px}"),
|
||||
], [
|
||||
'desktop' => ['overflow' => 'hidden', 'backgroundColor' => '@papier', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#d5dfde', 'borderRadius' => $rund('8px'), 'shadowY' => '24px', 'shadowBlur' => '50px', 'shadowColor' => '#0a363b26'],
|
||||
], "transform:rotate(-2deg);transition:transform .5s cubic-bezier(.2,.7,.2,1)\n&:hover{transform:rotate(0deg) scale(1.01)}"),
|
||||
$text('reisesoft-bildzeile', 'Echte Oberfläche, fiktive Beispieldaten.', ['desktop' => ['fontSize' => '12px', 'color' => '@text', 'textAlign' => 'right', 'margin' => ['top' => '22px', 'bottom' => '0px']]]),
|
||||
]),
|
||||
], $zweiSpalten, [
|
||||
'desktop' => ['gap' => '72px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '9 11'],
|
||||
'tablet' => ['gap' => '48px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@pale', 'padding' => ['top' => '88px', 'bottom' => '96px']],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '52px']],
|
||||
], 'reisesoft'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): 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']);
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Der Hero-Text steigt beim Laden auf, das Foto nicht (LCP). Weiter unten erscheinen Köpfe und
|
||||
// Raster beim Scrollen.
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-satz' => ['rise', 'medium'],
|
||||
'hero-knopf' => ['rise', 'medium'],
|
||||
'hero-bildzeile' => ['left', 'long'],
|
||||
'reisen-kopf' => ['rise'],
|
||||
'angebot-1' => ['rise'],
|
||||
'angebot-2' => ['rise', 'short'],
|
||||
'angebot-3' => ['rise', 'medium'],
|
||||
'beratung-zeile' => ['rise'],
|
||||
'schritt-1' => ['rise'],
|
||||
'schritt-2' => ['rise', 'short'],
|
||||
'schritt-3' => ['rise', 'medium'],
|
||||
'kontakt-text' => ['rise'],
|
||||
'kontakt-karte' => ['rise', 'short'],
|
||||
'reisesoft-text' => ['rise'],
|
||||
'reisesoft-bild-spalte' => ['right', 'short'],
|
||||
]);
|
||||
|
||||
$homeId = $anlegen('home', $startseite, 'Kantarci Reisen – Türkei-Urlaub, Hotels & Flüge | Reisebüro Baden-Baden', $medien['kueste']['id']);
|
||||
|
||||
/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Petrol, 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, '@auf-dunkel'),
|
||||
$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => ['fontSize' => '66px', 'fontWeight' => '500', 'lineHeight' => 1.05, 'letterSpacing' => '-0.045em', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '20px']],
|
||||
'mobile' => ['fontSize' => '46px'],
|
||||
]),
|
||||
$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '80px', 'bottom' => '68px'], 'borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '7px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@orange'],
|
||||
'mobile' => ['padding' => ['top' => '52px', 'bottom' => '44px']],
|
||||
]);
|
||||
|
||||
$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' => '19px', 'fontWeight' => '600', 'lineHeight' => 1.3, 'letterSpacing' => '-0.01em', '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' => '96px']], 'mobile' => ['padding' => ['top' => '24px', 'bottom' => '64px']]]);
|
||||
|
||||
return [$kopf, $inhalt];
|
||||
};
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben von Kantarci Reisen, Inhaber I. Kantarci, Baden-Baden.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
|
||||
['Anbieter', $firma.'<br>Inhaber: '.$inhaber.'<br>'.$strasse.'<br>'.$plz.' '.$ort],
|
||||
['Kontakt', 'Telefon: <a href="'.$tel.'">'.$telefon.'</a><br>E-Mail: <a href="mailto:'.$mail.'">'.$mail.'</a><br>WhatsApp: <a href="'.$whatsapp.'">'.$telefon.'</a>'],
|
||||
['Verantwortlich für den Inhalt', $inhaber.', Anschrift wie oben.'],
|
||||
['Reisevermittlung', 'Kantarci Reisen vermittelt Reiseleistungen wie Pauschalreisen, Hotelaufenthalte und Flüge. Vertragspartner für die jeweilige Leistung ist der Reiseveranstalter, das Hotel oder die Fluggesellschaft; es gelten deren Geschäfts- und Beförderungsbedingungen, die wir vor der Buchung mitgeben.'],
|
||||
['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. Reiseinformationen geben wir nach bestem Wissen weiter; verbindlich sind die Angaben im jeweiligen Angebot. Bei Bekanntwerden von Rechtsverletzungen entfernen wir betroffene Inhalte umgehend.'],
|
||||
['Haftung für Links', 'Diese Website verweist auf die Plattform reiseSoft unter <a href="'.$reisesoft.'">reisesoft.kantarci-reisen.de</a>. Für Inhalte fremder Seiten ist der jeweilige Anbieter verantwortlich.'],
|
||||
['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: '.$firma.'. Reisefoto der Startseite: KI-generiert. Screenshot: reiseSoft mit fiktiven Beispieldaten.'],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | '.$firma);
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Welche Daten beim Besuch der Website von Kantarci Reisen und bei einer Reiseanfrage verarbeitet werden.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('datenschutz', 'Deine Daten', 'Datenschutz', 'Hier erfährst du, welche Daten beim Besuch dieser Website und bei einer Anfrage verarbeitet werden – und welche Rechte du hast.', [
|
||||
['1. Verantwortlicher', $firma.', Inhaber '.$inhaber.', '.$strasse.', '.$plz.' '.$ort.'. Telefon <a href="'.$tel.'">'.$telefon.'</a>, E-Mail <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
|
||||
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet unser Hostingdienstleister 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>Mit dem Hostingdienstleister besteht ein Vertrag über die Auftragsverarbeitung nach Art. 28 DSGVO. Die Serverprotokolle werden nach spätestens sieben Tagen gelöscht oder anonymisiert, sofern sie nicht zur Aufklärung eines Sicherheitsvorfalls länger gebraucht werden.'],
|
||||
['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. Reiseanfrage', 'Wenn du uns über das Formular schreibst, verarbeiten wir deinen Namen, deine E-Mail-Adresse, auf Wunsch deine Telefonnummer, Reisezeitraum und Personenzahl sowie deine Wünsche, um deine Anfrage zu beantworten und passende Angebote vorzubereiten (Art. 6 Abs. 1 lit. b DSGVO). Die Anfrage wird in unserem System gespeichert und per E-Mail an uns weitergeleitet. Wir löschen sie, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'],
|
||||
['5. Buchungen', 'Kommt es zu einer Buchung, geben wir die dafür nötigen Angaben (zum Beispiel Namen der Reisenden laut Ausweis und Reisedaten) an den Reiseveranstalter, das Hotel oder die Fluggesellschaft weiter (Art. 6 Abs. 1 lit. b DSGVO). Rechnungsunterlagen bewahren wir so lange auf, wie Handels- und Steuerrecht es verlangen.'],
|
||||
['6. Kontakt per E-Mail oder Telefon', 'Wenn du uns schreibst oder anrufst, verarbeiten wir deine Angaben, um dein Anliegen zu bearbeiten (Art. 6 Abs. 1 lit. b oder f DSGVO).'],
|
||||
['7. WhatsApp', 'Die WhatsApp-Knöpfe sind einfache Links. Erst wenn du einen davon nutzt, öffnet sich WhatsApp, ein Dienst der WhatsApp Ireland Limited (Meta). Dabei verarbeitet WhatsApp deine Telefonnummer, Nachrichten und Nutzungsdaten nach eigenen Datenschutzhinweisen, auch in Ländern außerhalb der EU. Wir nutzen deine Nachricht nur, um dein Anliegen zu beantworten (Art. 6 Abs. 1 lit. b DSGVO). Wenn du das nicht möchtest, erreichst du uns genauso per Formular, E-Mail oder Telefon.'],
|
||||
['8. reiseSoft', 'Der Link zur Plattform reiseSoft führt auf eine eigene Anwendung unter reisesoft.kantarci-reisen.de. Erst wenn du ihm folgst, werden dort Daten verarbeitet; für angemeldete Reisebüros gelten die Hinweise der Plattform.'],
|
||||
['9. Deine Rechte', 'Du hast 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 kannst du widersprechen.'],
|
||||
['10. Beschwerderecht', 'Du kannst dich 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', '@orange-text'),
|
||||
$heading('fehler-titel', 'Hier geht’s<br>leider <em>nicht weiter.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '72px', 'fontWeight' => '500', 'lineHeight' => 1.05, 'letterSpacing' => '-0.05em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '22px']],
|
||||
'mobile' => ['fontSize' => '46px'],
|
||||
], $serif),
|
||||
$text('fehler-text', 'Diese Seite gibt es nicht oder sie wurde verschoben. Von der Startseite aus findest du sicher dein nächstes Ziel.', $fliess('@text', '32px', '480px', '17px')),
|
||||
$knopf('fehler-knopf', 'Zur Startseite', '/'),
|
||||
], [
|
||||
'desktop' => ['padding' => ['top' => '112px', 'bottom' => '132px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Menü, Einstellungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$sprung = static fn (string $label, string $anchor, array $extra = []): array => ['label' => $label, 'type' => 'page', 'pageId' => $homeId, 'anchor' => $anchor, 'children' => [], ...$extra];
|
||||
$menus->save('header', 'Hauptnavigation', [
|
||||
$sprung('Reisen', 'reisen'),
|
||||
$sprung('Über uns', 'beratung'),
|
||||
$sprung('reiseSoft', 'reisesoft'),
|
||||
$sprung('Kontakt', 'kontakt'),
|
||||
// „Reise anfragen“ als Menü-Button (ADR 110): auf dem Telefon neben dem Menü-Knopf.
|
||||
$sprung('Reise anfragen', 'kontakt', [
|
||||
'icon' => 'arrow-right', 'iconPosition' => 'after',
|
||||
'button' => [
|
||||
'id' => 'm'.substr(hash('sha256', 'kantarci-reisen-anfrage'), 0, 10), 'shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal',
|
||||
'fill' => '@orange', 'ink' => '@knopf-schrift', 'hoverFill' => '@tinte', 'hoverInk' => '@papier',
|
||||
'mobile' => 'bar', 'iconMotion' => 'slide',
|
||||
],
|
||||
]),
|
||||
], 0);
|
||||
echo "Menü angelegt\n";
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => $firma,
|
||||
'description' => 'Türkei im Herzen. Die Welt im Blick. Persönliche Reiseberatung in Baden-Baden: Türkei-Urlaub, Hotels weltweit und Flüge in die Türkei.',
|
||||
'email' => $mail, 'phone' => '+49 170 2945935',
|
||||
'social' => ['whatsapp' => $whatsapp],
|
||||
'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE',
|
||||
'areaServed' => implode("\n", ['Baden-Baden', 'Rastatt', 'Bühl', 'Gaggenau', 'Karlsruhe']),
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
'faviconMediaId' => $medien['favicon']['id'],
|
||||
'stickyHeader' => true, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
|
||||
'consentBanner' => false, 'scrollTop' => true, '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";
|
||||
@@ -158,6 +158,9 @@ final class Consent
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
$decision = $this->decision($request);
|
||||
// Auf einer Seite in einer weiteren Sprache der Hinweis in dieser Sprache (ADR 111).
|
||||
$locale = $request->attributes->get('kant_locale');
|
||||
$translated = is_string($locale) ? ($settings['consentTexts'][$locale] ?? []) : [];
|
||||
|
||||
return [
|
||||
'groups' => array_map(
|
||||
@@ -171,8 +174,8 @@ final class Consent
|
||||
),
|
||||
'decided' => null !== $decision,
|
||||
'decidedAt' => $decision['at'] ?? null,
|
||||
'text' => $settings['consentText'],
|
||||
'title' => $settings['consentTitle'],
|
||||
'text' => $translated['text'] ?? $settings['consentText'],
|
||||
'title' => $translated['title'] ?? $settings['consentTitle'],
|
||||
'layout' => $settings['consentLayout'],
|
||||
'privacyPath' => $settings['privacyPath'],
|
||||
'imprintPath' => $settings['imprintPath'],
|
||||
|
||||
@@ -239,6 +239,14 @@ final class CustomCode
|
||||
if (1 !== preg_match('/^-{0,2}[a-z][a-z0-9-]{0,40}$/D', $property)) {
|
||||
throw new InvalidDocument(sprintf('„%s“ ist kein CSS-Eigenschaftsname.', mb_substr($property, 0, 40)));
|
||||
}
|
||||
// `content` braucht einen Text in Anfuehrungszeichen, sonst gibt es kein ::before und
|
||||
// ::after fuer Striche, Verlaeufe oder ein Wasserzeichen. Erlaubt ist ein kurzer Text
|
||||
// aus Buchstaben, Ziffern und wenigen Satzzeichen: kein <, kein Backslash, kein
|
||||
// weiteres Anfuehrungszeichen, also kein Weg aus dem String oder dem <style>.
|
||||
if ('content' === $property && 1 === preg_match('/^(["\'])[\p{L}\p{N} .,:!?·•–-]{0,40}\1$/uD', $value)) {
|
||||
$out[] = $property.':'.$value;
|
||||
continue;
|
||||
}
|
||||
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value)) {
|
||||
throw new InvalidDocument(sprintf('Der Wert von „%s“ enthält Zeichen, die in eigenem CSS nicht vorgesehen sind.', $property));
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ final class DocumentValidator
|
||||
/** 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', 'arrow-right', 'arrow-left', 'arrow-up-right'];
|
||||
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design'];
|
||||
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
|
||||
public const BUTTON_DEFAULT_ICON = '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'];
|
||||
@@ -39,8 +41,10 @@ final class DocumentValidator
|
||||
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
|
||||
public const V23_TYPES = ['social'];
|
||||
public const V24_TYPES = ['html'];
|
||||
/** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */
|
||||
public const V27_TYPES = ['language'];
|
||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||
public const SCHEMA_VERSION = 24;
|
||||
public const SCHEMA_VERSION = 27;
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -60,7 +64,10 @@ final class DocumentValidator
|
||||
$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.');
|
||||
$this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.');
|
||||
// Die Grenze gilt für die Seite selbst; jede Übersetzung bringt ihre Texte noch einmal mit
|
||||
// und bekommt deshalb insgesamt Platz für bis zu drei weitere Fassungen.
|
||||
$this->require(strlen(json_encode(Translations::strip($document), JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.');
|
||||
$this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 1048576, 'Die Seite ist mit ihren Übersetzungen größer als 1 MiB.');
|
||||
$ids = [];
|
||||
$headings = 0;
|
||||
foreach ($document['blocks'] as $block) {
|
||||
@@ -80,6 +87,55 @@ final class DocumentValidator
|
||||
$requireHeading ? 1 === $headings : 0 === $headings,
|
||||
$requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.',
|
||||
);
|
||||
if (array_key_exists('translations', $document)) {
|
||||
$this->translations($document, $requireHeading);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Übersetzungen eines Dokuments (ADR 111). Geprüft wird zuerst die Form, dann jede
|
||||
* Sprache so, wie sie ausgeliefert wird: als Dokument mit den übersetzten Texten, durch
|
||||
* dieselbe Prüfung wie die Hauptsprache. Eine Übersetzung kann so keine Länge, keine
|
||||
* Auszeichnung und keine Überschriftenregel umgehen.
|
||||
*/
|
||||
private function translations(array $document, bool $requireHeading): void
|
||||
{
|
||||
$translations = $document['translations'];
|
||||
$this->require(Translations::supports($document['schemaVersion']), 'Übersetzungen brauchen Dokumentschema v27.');
|
||||
$this->require(is_array($translations) && ([] === $translations || !array_is_list($translations)), 'Die Übersetzungen sind ein Objekt je Sprache.');
|
||||
foreach ($translations as $locale => $translation) {
|
||||
$this->require(is_string($locale) && isset(Languages::CATALOG[$locale]), 'Unbekannte Sprache in den Übersetzungen.');
|
||||
$this->require(is_array($translation) && ([] === $translation || !array_is_list($translation)), 'Eine Übersetzung ist ein Objekt.');
|
||||
foreach ($translation as $key => $value) {
|
||||
$this->require(in_array($key, ['slug', 'blocks', 'source', ...array_keys(Translations::PAGE_FIELDS)], true), sprintf('Unbekanntes Feld „%s“ in einer Übersetzung.', $key));
|
||||
}
|
||||
foreach (Translations::PAGE_FIELDS as $field => $max) {
|
||||
if (isset($translation[$field])) {
|
||||
$this->text($translation[$field], $max, true);
|
||||
}
|
||||
}
|
||||
if (isset($translation['slug'])) {
|
||||
$this->require(is_string($translation['slug']) && strlen($translation['slug']) <= 80 && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $translation['slug']), 'Die übersetzte Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.');
|
||||
}
|
||||
$blocks = $translation['blocks'] ?? [];
|
||||
$this->require(is_array($blocks) && count($blocks) <= Translations::MAX_BLOCKS && ([] === $blocks || !array_is_list($blocks)), 'Die übersetzten Texte sind ein Objekt je Block.');
|
||||
foreach ($blocks as $id => $fields) {
|
||||
$this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && is_array($fields), 'Übersetzte Texte gehören zu einem Block.');
|
||||
foreach ($fields as $prop => $value) {
|
||||
$this->require(is_string($prop) && 1 === preg_match('/^[a-zA-Z]{1,40}$/D', $prop) && is_string($value), 'Ein übersetzter Text ist Text.');
|
||||
}
|
||||
}
|
||||
$source = $translation['source'] ?? [];
|
||||
$this->require(is_array($source) && count($source) <= Translations::MAX_BLOCKS * 3 + 2, 'Ungültige Vermerke zur Übersetzung.');
|
||||
foreach ($source as $key => $hash) {
|
||||
$this->require(is_string($key) && strlen($key) <= 110 && is_string($hash) && 1 === preg_match('/^[a-f0-9]{8}$/D', $hash), 'Ungültiger Vermerk zur Übersetzung.');
|
||||
}
|
||||
try {
|
||||
$this->validate(Translations::apply($document, $locale), $requireHeading, $this->writing);
|
||||
} catch (InvalidDocument $error) {
|
||||
throw new InvalidDocument(sprintf('%s (%s): %s', Languages::CATALOG[$locale]['name'], $locale, $error->getMessage()), previous: $error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion, ?string $parentType): void
|
||||
@@ -99,7 +155,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)) || ($schemaVersion >= 24 && in_array($type, self::V24_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)) || ($schemaVersion >= 27 && in_array($type, self::V27_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.');
|
||||
@@ -236,14 +292,32 @@ final class DocumentValidator
|
||||
}
|
||||
}
|
||||
if ('button' === $type) {
|
||||
$this->url($props['href'] ?? null);
|
||||
$this->link($props['href'] ?? null);
|
||||
// Schema v25: das Symbol am Button ist wählbar. Fehlt es, bleibt es beim Pfeil wie
|
||||
// bisher - ältere Dokumente sehen also unverändert aus. "none" heißt ohne Symbol.
|
||||
if (array_key_exists('icon', $props)) {
|
||||
$this->require($schemaVersion >= 25 && ('none' === $props['icon'] || in_array($props['icon'], self::ICONS, true)), 'Unbekanntes Symbol am Button oder Dokumentschema vor v25.');
|
||||
}
|
||||
if (array_key_exists('iconPosition', $props)) {
|
||||
$this->require($schemaVersion >= 25 && in_array($props['iconPosition'], ['start', 'end'], true), 'Das Symbol steht vor oder nach dem Text und braucht Schema v25.');
|
||||
}
|
||||
// Schema v26 (ADR 109): was das Symbol beim Überfahren tut, und ein Button nur aus
|
||||
// Symbol. Der Text bleibt dann für Vorlesesoftware da - ohne Symbol wäre der Button
|
||||
// unsichtbar, deshalb braucht "nur Symbol" ein Symbol.
|
||||
if (array_key_exists('iconMotion', $props)) {
|
||||
$this->require($schemaVersion >= 26 && in_array($props['iconMotion'], ['none', 'slide', 'reveal'], true), 'Unbekannte Bewegung des Symbols oder Dokumentschema vor v26.');
|
||||
}
|
||||
if (array_key_exists('iconOnly', $props)) {
|
||||
$this->require($schemaVersion >= 26 && is_bool($props['iconOnly']), 'Nur Symbol ist an oder aus und braucht Schema v26.');
|
||||
$this->require(!$props['iconOnly'] || 'none' !== ($props['icon'] ?? self::BUTTON_DEFAULT_ICON), 'Ein Button nur aus Symbol braucht ein Symbol.');
|
||||
}
|
||||
}
|
||||
if ('iconcard' === $type) {
|
||||
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
||||
$this->text($props['title'] ?? null, 120);
|
||||
$this->text($props['text'] ?? null, 600, true);
|
||||
if ('' !== ($props['href'] ?? '')) {
|
||||
$this->url($props['href']);
|
||||
$this->link($props['href']);
|
||||
}
|
||||
}
|
||||
if ('stat' === $type) {
|
||||
@@ -261,7 +335,7 @@ final class DocumentValidator
|
||||
$this->text($props['title'] ?? null, 160);
|
||||
$this->text($props['text'] ?? null, 600, true);
|
||||
$this->text($props['buttonText'] ?? null, 80);
|
||||
$this->url($props['href'] ?? null);
|
||||
$this->link($props['href'] ?? null);
|
||||
}
|
||||
if ('contact' === $type) {
|
||||
foreach (['phone', 'email', 'address'] as $field) {
|
||||
@@ -313,6 +387,10 @@ final class DocumentValidator
|
||||
$this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.');
|
||||
}
|
||||
}
|
||||
if ('language' === $type) {
|
||||
// Welche Sprachen es gibt, weiß nur die Website - der Block sagt nur, wie er sie nennt.
|
||||
$this->require(in_array($props['display'] ?? null, ['code', 'name'], true), 'Die Sprachwahl zeigt Kürzel („code“) oder Namen („name“).');
|
||||
}
|
||||
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.
|
||||
@@ -329,7 +407,7 @@ final class DocumentValidator
|
||||
$this->text($props['label'] ?? null, 120, $props['decorative']);
|
||||
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
|
||||
if ('' !== ($props['href'] ?? '')) {
|
||||
$this->url($props['href']);
|
||||
$this->link($props['href']);
|
||||
}
|
||||
}
|
||||
if ('social' === $type) {
|
||||
@@ -414,7 +492,7 @@ final class DocumentValidator
|
||||
// Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht.
|
||||
if ('' !== ($props['href'] ?? '')) {
|
||||
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.');
|
||||
$this->url($props['href']);
|
||||
$this->link($props['href']);
|
||||
}
|
||||
// Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
|
||||
if (isset($props['click'])) {
|
||||
@@ -498,6 +576,38 @@ final class DocumentValidator
|
||||
$this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Linkziel: alles, was url() erlaubt, dazu tel: und mailto: - dieselben Ziele, die Menü
|
||||
* (MenuRepository::url) und formatierter Text (RichText::safeHref) schon kennen. Ein Knopf
|
||||
* „Jetzt anrufen“ war sonst nur als Textlink im Knopf-Aussehen zu bauen (Hüfner, 06.10.2026).
|
||||
* Bildquellen und eingebettete Inhalte bleiben bei url(): dort ist tel: kein Ziel.
|
||||
*/
|
||||
private function link(mixed $url): void
|
||||
{
|
||||
if (is_string($url) && strlen($url) <= 2048 && !preg_match('/[\x00-\x20\\\\]/', $url)) {
|
||||
if (1 === preg_match('/^tel:\+?[0-9()-]{3,32}$/D', $url)) {
|
||||
return;
|
||||
}
|
||||
// Adresse vor dem ersten ?, danach höchstens Betreff und Text, prozentkodiert.
|
||||
if (1 === preg_match('/^mailto:([^?]+)(\?[A-Za-z0-9%._~=&+-]{0,500})?$/D', $url, $teile)
|
||||
&& false !== filter_var(rawurldecode($teile[1]), FILTER_VALIDATE_EMAIL)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->require(true === $this->isUrl($url), 'Erlaubt sind lokale Pfade, Sprungmarken, HTTPS-Adressen sowie tel: und mailto:.');
|
||||
}
|
||||
|
||||
private function isUrl(mixed $url): bool
|
||||
{
|
||||
try {
|
||||
$this->url($url);
|
||||
|
||||
return true;
|
||||
} catch (InvalidDocument) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function require(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
|
||||
@@ -84,21 +84,41 @@ final class FontLibrary
|
||||
$bytes = 0;
|
||||
foreach ($wanted as $family => $weights) {
|
||||
$css = $this->stylesheet($family, $weights);
|
||||
// Eine variable Schrift (etwa DM Sans) liefert Google als eine Datei für alle
|
||||
// Schnitte, mit einer @font-face-Regel je Schnitt. Die Datei liegt trotzdem je Schnitt
|
||||
// im Ordner, damit missing() weiter nach Schnitten fragen kann, aber das Stylesheet
|
||||
// nennt sie nur einmal, mit einem Gewichtsbereich. Sonst lädt der Browser dieselben
|
||||
// Bytes je Schnitt einmal, und preload() holt sie dreimal vorab.
|
||||
$groups = [];
|
||||
foreach ($this->parse($css, $family) as $face) {
|
||||
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
|
||||
$path = $this->directory.'/'.$name;
|
||||
if (!is_file($path)) {
|
||||
file_put_contents($path, $this->download($face['url']));
|
||||
++$files;
|
||||
if (isset($groups[$face['url']])) {
|
||||
copy($this->directory.'/'.$groups[$face['url']]['name'], $path);
|
||||
} else {
|
||||
file_put_contents($path, $this->download($face['url']));
|
||||
++$files;
|
||||
}
|
||||
}
|
||||
if (isset($groups[$face['url']])) {
|
||||
$groups[$face['url']]['weights'][] = $face['weight'];
|
||||
continue;
|
||||
}
|
||||
$bytes += (int) filesize($path);
|
||||
$groups[$face['url']] = ['name' => $name, 'weights' => [$face['weight']], 'face' => $face];
|
||||
}
|
||||
foreach ($groups as $group) {
|
||||
$face = $group['face'];
|
||||
$low = min($group['weights']);
|
||||
$high = max($group['weights']);
|
||||
$rules[] = sprintf(
|
||||
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%d;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
|
||||
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%s;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
|
||||
$face['subset'],
|
||||
$family,
|
||||
$face['style'],
|
||||
$face['weight'],
|
||||
$name,
|
||||
$low === $high ? (string) $low : $low.' '.$high,
|
||||
$group['name'],
|
||||
$face['range'],
|
||||
);
|
||||
}
|
||||
@@ -106,11 +126,40 @@ final class FontLibrary
|
||||
if ([] === $rules) {
|
||||
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
|
||||
}
|
||||
file_put_contents($this->directory.'/fonts.css', implode("\n", $rules)."\n");
|
||||
$stylesheet = $this->directory.'/fonts.css';
|
||||
$existing = is_file($stylesheet) ? (string) file_get_contents($stylesheet) : '';
|
||||
file_put_contents($stylesheet, $this->combine($existing, $rules, array_keys($wanted)));
|
||||
|
||||
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt fonts.css aus den neuen Regeln und den Regeln anderer Familien zusammen.
|
||||
*
|
||||
* Bisher schrieb jeder Abruf die Datei neu und nur mit den Familien des aktuellen Designs.
|
||||
* Teilen sich mehrere Websites den Ordner (lokal alle Installationen) oder wechselt ein Design
|
||||
* die Schrift und wieder zurück, verschwanden damit Schriften, deren Dateien noch daliegen.
|
||||
* Regeln für Familien, die gerade neu geholt wurden, werden ersetzt; die übrigen bleiben.
|
||||
* Eine unbenutzte @font-face-Regel kostet nichts: der Browser lädt die Datei erst, wenn Text
|
||||
* in dieser Familie steht.
|
||||
*
|
||||
* @param list<string> $rules je Regel ein Kommentar mit dem Bereich und die @font-face
|
||||
* @param list<string> $families die Familien, zu denen $rules gehören
|
||||
*/
|
||||
public function combine(string $existing, array $rules, array $families): string
|
||||
{
|
||||
$kept = [];
|
||||
preg_match_all('~(?:/\*[^*]*\*/\s*)?@font-face\s*\{[^}]*\}~', $existing, $matches);
|
||||
foreach ($matches[0] as $rule) {
|
||||
if (1 !== preg_match("~font-family:\\s*'([^']+)'~", $rule, $family) || in_array($family[1], $families, true)) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = trim($rule);
|
||||
}
|
||||
|
||||
return implode("\n", [...$kept, ...$rules])."\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
|
||||
*
|
||||
@@ -124,11 +173,16 @@ final class FontLibrary
|
||||
*/
|
||||
public function preload(array $theme): array
|
||||
{
|
||||
// Nur Dateien, die das Stylesheet auch nennt: bei einer variablen Schrift steht dort eine
|
||||
// Datei für alle Schnitte, die Kopien je Schnitt holt der Browser nie.
|
||||
$stylesheet = is_file($this->directory.'/fonts.css') ? (string) file_get_contents($this->directory.'/fonts.css') : null;
|
||||
$preload = [];
|
||||
foreach ($this->wanted($theme) as $family => $weights) {
|
||||
foreach ($weights as $weight) {
|
||||
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
|
||||
if (is_file($this->directory.'/'.$name)) {
|
||||
$genannt = null === $stylesheet || !str_contains($stylesheet, '/assets/fonts/'.$this->slug($family).'-')
|
||||
|| str_contains($stylesheet, '/assets/fonts/'.$name.')');
|
||||
if (is_file($this->directory.'/'.$name) && $genannt) {
|
||||
$preload[] = '/assets/fonts/'.$name;
|
||||
}
|
||||
}
|
||||
|
||||
+145
-13
@@ -322,6 +322,10 @@ final class FormRepository
|
||||
// Skript steht das ganze Formular ohnehin auf einer Seite.
|
||||
'summary' => (bool) ($settings['summary'] ?? false),
|
||||
];
|
||||
$translations = $this->validateTranslations($settings['translations'] ?? [], $fields);
|
||||
if ([] !== $translations) {
|
||||
$clean['translations'] = $translations;
|
||||
}
|
||||
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
|
||||
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
|
||||
foreach (self::COLORS as $field => $label) {
|
||||
@@ -335,6 +339,130 @@ final class FormRepository
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Übersetzungen eines Formulars (ADR 111): je Sprache Erfolgsmeldung, Button und je Feld
|
||||
* Beschriftung, Hilfetext, Einheit und die angezeigten Optionen. Übersetzt wird nur, was
|
||||
* Besucher lesen. Abgeschickt werden weiter die Optionen der Hauptsprache - Bedingungen,
|
||||
* Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger bleiben dieselben.
|
||||
*/
|
||||
private function validateTranslations(mixed $input, array $fields): array
|
||||
{
|
||||
if (!is_array($input)) {
|
||||
throw new InvalidForm('Die Übersetzungen des Formulars sind ungültig.');
|
||||
}
|
||||
$byName = array_column($fields, null, 'name');
|
||||
$clean = [];
|
||||
foreach ($input as $locale => $translation) {
|
||||
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($translation)) {
|
||||
throw new InvalidForm('Eine Übersetzung des Formulars gehört zu einer unbekannten Sprache.');
|
||||
}
|
||||
$out = [];
|
||||
foreach (['success' => 300, 'buttonLabel' => 40] as $key => $max) {
|
||||
$text = trim((string) ($translation[$key] ?? ''));
|
||||
if ('' !== $text) {
|
||||
$out[$key] = mb_substr($text, 0, $max);
|
||||
}
|
||||
}
|
||||
foreach ((is_array($translation['fields'] ?? null) ? $translation['fields'] : []) as $name => $values) {
|
||||
$field = $byName[$name] ?? null;
|
||||
if (null === $field || !is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
$entry = [];
|
||||
foreach (['label' => 120, 'help' => 200, 'unit' => 12] as $key => $max) {
|
||||
$text = trim((string) ($values[$key] ?? ''));
|
||||
if ('' !== $text) {
|
||||
$entry[$key] = mb_substr($text, 0, $max);
|
||||
}
|
||||
}
|
||||
foreach ((is_array($values['options'] ?? null) ? $values['options'] : []) as $original => $text) {
|
||||
$text = trim((string) $text);
|
||||
if (in_array((string) $original, $field['options'], true) && '' !== $text) {
|
||||
$entry['options'][(string) $original] = mb_substr($text, 0, 120);
|
||||
}
|
||||
}
|
||||
if ([] !== $entry) {
|
||||
$out['fields'][$name] = $entry;
|
||||
}
|
||||
}
|
||||
if ([] !== $out) {
|
||||
$clean[$locale] = $out;
|
||||
}
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Formular in einer weiteren Sprache: Beschriftungen, Hilfetexte, Einheiten, Button und
|
||||
* Erfolgsmeldung übersetzt, wo es Übersetzungen gibt. Optionen behalten ihren Wert; nur die
|
||||
* Anzeige steht in `optionLabels`.
|
||||
*/
|
||||
public static function localise(array $form, ?string $locale): array
|
||||
{
|
||||
$translation = null === $locale ? [] : ($form['settings']['translations'][$locale] ?? []);
|
||||
foreach ($form['fields'] as $index => $field) {
|
||||
$values = $translation['fields'][$field['name']] ?? [];
|
||||
foreach (['label', 'help', 'unit'] as $key) {
|
||||
if (isset($values[$key])) {
|
||||
$form['fields'][$index][$key] = $values[$key];
|
||||
}
|
||||
}
|
||||
$form['fields'][$index]['optionLabels'] = $values['options'] ?? [];
|
||||
}
|
||||
foreach (['success', 'buttonLabel'] as $key) {
|
||||
if (isset($translation[$key])) {
|
||||
$form['settings'][$key] = $translation[$key];
|
||||
}
|
||||
}
|
||||
$form['locale'] = $locale;
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Meldungen der Prüfung in weiteren Sprachen. Schlüssel ist die deutsche Vorlage samt
|
||||
* Platzhaltern; ohne eigene Liste gilt Englisch.
|
||||
*/
|
||||
public const MESSAGES = [
|
||||
'en' => [
|
||||
'Bitte wähle nur aus den angebotenen Optionen.' => 'Please choose only from the options offered.',
|
||||
'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.',
|
||||
'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Please shorten your entry to at most %d characters.',
|
||||
'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'We cannot process your request without this consent.',
|
||||
'Bitte wähle eine der angebotenen Optionen.' => 'Please choose one of the options offered.',
|
||||
'Dieses Feld wird benötigt.' => 'This field is required.',
|
||||
'Bitte gib eine gültige E-Mail-Adresse an.' => 'Please enter a valid email address.',
|
||||
'Bitte gib eine gültige Telefonnummer an.' => 'Please enter a valid phone number.',
|
||||
'Bitte gib eine Zahl an.' => 'Please enter a number.',
|
||||
'Bitte gib höchstens %s an.' => 'Please enter at most %s.',
|
||||
'Bitte gib mindestens %s an.' => 'Please enter at least %s.',
|
||||
'Bitte gib einen Wert zwischen %s und %s an.' => 'Please enter a value between %s and %s.',
|
||||
],
|
||||
];
|
||||
|
||||
private static function message(?string $locale, string $text, mixed ...$args): string
|
||||
{
|
||||
$template = null === $locale || 'de' === $locale ? $text : (self::MESSAGES[$locale][$text] ?? self::MESSAGES['en'][$text] ?? $text);
|
||||
|
||||
return [] === $args ? $template : sprintf($template, ...$args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Einsendungen je Formular eingegangen sind, für die Übersicht.
|
||||
*
|
||||
* @return array<string, int> Formular-ID => Anzahl
|
||||
*/
|
||||
public function submissionCounts(): array
|
||||
{
|
||||
$counts = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT form_id, COUNT(*) AS n FROM form_submission GROUP BY form_id') as $row) {
|
||||
$counts[(string) $row['form_id']] = (int) $row['n'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
|
||||
public function needsScript(array $form): bool
|
||||
{
|
||||
@@ -401,8 +529,12 @@ final class FormRepository
|
||||
*
|
||||
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
|
||||
*/
|
||||
public function check(array $form, array $input): array
|
||||
/**
|
||||
* @param string|null $locale eine weitere Sprache: die Meldungen stehen dann in ihr
|
||||
*/
|
||||
public function check(array $form, array $input, ?string $locale = null): array
|
||||
{
|
||||
$t = static fn (string $text, mixed ...$args): string => self::message($locale, $text, ...$args);
|
||||
$values = [];
|
||||
$errors = [];
|
||||
$consent = false;
|
||||
@@ -427,10 +559,10 @@ final class FormRepository
|
||||
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.';
|
||||
$errors[$name] = $t('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.';
|
||||
$errors[$name] = $t('Bitte wähle mindestens eine Option.');
|
||||
}
|
||||
$chosen[$name] = $picked;
|
||||
$values[$name] = implode(', ', $picked);
|
||||
@@ -439,39 +571,39 @@ final class FormRepository
|
||||
$value = is_string($raw) ? trim($raw) : '';
|
||||
$limit = 'textarea' === $field['type'] ? 5000 : 200;
|
||||
if (mb_strlen($value) > $limit) {
|
||||
$errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
|
||||
$errors[$name] = $t('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[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
|
||||
$errors[$name] = $t('Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.');
|
||||
}
|
||||
$values[$name] = $consent ? 'ja' : '';
|
||||
continue;
|
||||
}
|
||||
if ('' === $value) {
|
||||
if ($field['required']) {
|
||||
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
|
||||
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? $t('Bitte wähle eine der angebotenen Optionen.') : $t('Dieses Feld wird benötigt.');
|
||||
}
|
||||
$values[$name] = '';
|
||||
continue;
|
||||
}
|
||||
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
$errors[$name] = $t('Bitte gib eine gültige E-Mail-Adresse an.');
|
||||
}
|
||||
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
|
||||
$errors[$name] = 'Bitte gib eine gültige Telefonnummer an.';
|
||||
$errors[$name] = $t('Bitte gib eine gültige Telefonnummer an.');
|
||||
}
|
||||
if ('number' === $field['type']) {
|
||||
$zahl = str_replace(',', '.', $value);
|
||||
if (!is_numeric($zahl)) {
|
||||
$errors[$name] = 'Bitte gib eine Zahl an.';
|
||||
$errors[$name] = $t('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'])),
|
||||
null === $field['min'] => $t('Bitte gib höchstens %s an.', self::zahl($field['max'])),
|
||||
null === $field['max'] => $t('Bitte gib mindestens %s an.', self::zahl($field['min'])),
|
||||
default => $t('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.
|
||||
@@ -480,7 +612,7 @@ final class FormRepository
|
||||
}
|
||||
if (in_array($field['type'], ['select', 'radio'], true)) {
|
||||
if (!in_array($value, $field['options'], true)) {
|
||||
$errors[$name] = 'Bitte wähle eine der angebotenen Optionen.';
|
||||
$errors[$name] = $t('Bitte wähle eine der angebotenen Optionen.');
|
||||
} else {
|
||||
$chosen[$name] = [$value];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Die Sprachen einer Website (ADR 111).
|
||||
*
|
||||
* Es gibt genau eine Hauptsprache - die der Seiten selbst (`website.default_locale`, bei allen
|
||||
* bisherigen Installationen Deutsch). Jede weitere Sprache ist eine Übersetzungsebene über
|
||||
* denselben Seiten: Aufbau und Gestaltung bleiben einmal da, je Sprache kommen nur Texte und
|
||||
* die Adresse dazu. Ohne weitere Sprache ist von alldem nichts zu sehen.
|
||||
*/
|
||||
final class Languages
|
||||
{
|
||||
/**
|
||||
* Die Sprachen, die zur Wahl stehen: Name in der Sprache selbst (für die Sprachwahl auf der
|
||||
* Website), deutscher Name (für die Verwaltung) und die Form, die Open Graph erwartet.
|
||||
*/
|
||||
public const CATALOG = [
|
||||
'de' => ['native' => 'Deutsch', 'name' => 'Deutsch', 'og' => 'de_DE'],
|
||||
'en' => ['native' => 'English', 'name' => 'Englisch', 'og' => 'en_US'],
|
||||
'fr' => ['native' => 'Français', 'name' => 'Französisch', 'og' => 'fr_FR'],
|
||||
'it' => ['native' => 'Italiano', 'name' => 'Italienisch', 'og' => 'it_IT'],
|
||||
'es' => ['native' => 'Español', 'name' => 'Spanisch', 'og' => 'es_ES'],
|
||||
'nl' => ['native' => 'Nederlands', 'name' => 'Niederländisch', 'og' => 'nl_NL'],
|
||||
'pl' => ['native' => 'Polski', 'name' => 'Polnisch', 'og' => 'pl_PL'],
|
||||
'tr' => ['native' => 'Türkçe', 'name' => 'Türkisch', 'og' => 'tr_TR'],
|
||||
'ru' => ['native' => 'Русский', 'name' => 'Russisch', 'og' => 'ru_RU'],
|
||||
'ar' => ['native' => 'العربية', 'name' => 'Arabisch', 'og' => 'ar_AR'],
|
||||
];
|
||||
|
||||
/** Sprachen, die von rechts nach links geschrieben werden. */
|
||||
public const RTL = ['ar'];
|
||||
|
||||
/** Mehr Sprachen als diese braucht keine Handwerkerseite, und jede kostet Pflege. */
|
||||
public const MAX_EXTRA = 5;
|
||||
|
||||
private ?string $main = null;
|
||||
|
||||
public function __construct(private Connection $db, private SiteSettings $settings)
|
||||
{
|
||||
}
|
||||
|
||||
/** Die Hauptsprache: die Sprache, in der Seiten gebaut werden. */
|
||||
public function main(): string
|
||||
{
|
||||
if (null !== $this->main) {
|
||||
return $this->main;
|
||||
}
|
||||
try {
|
||||
$locale = $this->db->fetchOne('SELECT default_locale FROM website WHERE id = ?', ['default']);
|
||||
} catch (\Doctrine\DBAL\Exception) {
|
||||
$locale = false;
|
||||
}
|
||||
|
||||
return $this->main = is_string($locale) && isset(self::CATALOG[$locale]) ? $locale : 'de';
|
||||
}
|
||||
|
||||
/**
|
||||
* Die weiteren Sprachen, in der eingestellten Reihenfolge. Leer bei einer einsprachigen
|
||||
* Website - und dann zeigt weder der Editor noch die Website etwas davon.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function extra(): array
|
||||
{
|
||||
$stored = $this->settings->load()['languages'] ?? [];
|
||||
$main = $this->main();
|
||||
|
||||
return array_values(array_filter(is_array($stored) ? $stored : [], static fn (mixed $code): bool => is_string($code) && isset(self::CATALOG[$code]) && $code !== $main));
|
||||
}
|
||||
|
||||
/** @return list<string> Hauptsprache zuerst */
|
||||
public function all(): array
|
||||
{
|
||||
return [$this->main(), ...$this->extra()];
|
||||
}
|
||||
|
||||
public function isExtra(string $code): bool
|
||||
{
|
||||
return in_array($code, $this->extra(), true);
|
||||
}
|
||||
|
||||
/** Für den Editor und die Einstellungen: Kennung, Name und Name in der Sprache selbst. */
|
||||
public function describe(string $code): array
|
||||
{
|
||||
return ['code' => $code, 'name' => self::CATALOG[$code]['name'] ?? $code, 'native' => self::CATALOG[$code]['native'] ?? $code, 'rtl' => in_array($code, self::RTL, true)];
|
||||
}
|
||||
|
||||
public static function ogLocale(string $code): string
|
||||
{
|
||||
return self::CATALOG[$code]['og'] ?? $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die weiteren Sprachen fest.
|
||||
*
|
||||
* Eine Sprache wird zum ersten Teil der Adresse (/en/...). Gibt es schon eine Seite, die
|
||||
* genau so heißt, wäre sie danach nicht mehr erreichbar - das wird abgelehnt statt still
|
||||
* hingenommen. Eine entfernte Sprache verliert ihre Übersetzungen nicht: sie bleiben in den
|
||||
* Seiten liegen und sind wieder da, wenn die Sprache zurückkommt.
|
||||
*
|
||||
* @param list<string> $codes
|
||||
*/
|
||||
public function save(array $codes): void
|
||||
{
|
||||
$main = $this->main();
|
||||
$clean = [];
|
||||
foreach ($codes as $code) {
|
||||
$code = strtolower(trim((string) $code));
|
||||
if ('' === $code || $code === $main) {
|
||||
continue;
|
||||
}
|
||||
if (!isset(self::CATALOG[$code])) {
|
||||
throw new InvalidPage(sprintf('Die Sprache „%s“ steht nicht zur Wahl.', $code));
|
||||
}
|
||||
$clean[$code] = true;
|
||||
}
|
||||
$clean = array_keys($clean);
|
||||
if (count($clean) > self::MAX_EXTRA) {
|
||||
throw new InvalidPage(sprintf('Mehr als %d weitere Sprachen sind nicht vorgesehen.', self::MAX_EXTRA));
|
||||
}
|
||||
foreach ($clean as $code) {
|
||||
if (in_array($code, PageRepository::RESERVED_SLUGS, true)) {
|
||||
continue;
|
||||
}
|
||||
$taken = $this->db->fetchOne('SELECT path FROM page WHERE website_id = ? AND (path = ? OR path LIKE ?) AND deleted_at IS NULL', ['default', $code, $code.'/%']);
|
||||
if (false !== $taken) {
|
||||
throw new InvalidPage(sprintf('Die Seite /%s belegt die Adresse, unter der %s erscheinen würde. Benenne sie zuerst um.', $code, self::CATALOG[$code]['name']));
|
||||
}
|
||||
}
|
||||
$this->settings->saveLanguages($clean);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidMenu;
|
||||
|
||||
/**
|
||||
* Ein Menüeintrag, der als Button erscheint (ADR 110).
|
||||
*
|
||||
* Die Gestalt kommt aus demselben Wortschatz wie der Button-Baustein: Form, Darstellung und Größe
|
||||
* aus ButtonDesign, das Symbol aus dem Symbolsatz. Fehlt ein Wert, gilt der Website-Standard unter
|
||||
* Design → Buttons. Eigene Farben brauchen eine Kennung, weil die Sicherheitsrichtlinie keine
|
||||
* Inline-Stile erlaubt: sie werden als Regel für `.menu-button-<id>` ins Stylesheet der Seite
|
||||
* geschrieben.
|
||||
*
|
||||
* Gespeichert am Eintrag als `button`:
|
||||
* {id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?}
|
||||
*/
|
||||
final class MenuButton
|
||||
{
|
||||
/** Wo der Button auf dem Telefon steht, wenn das Menü zum Menü-Knopf zusammenklappt. */
|
||||
public const MOBILE = ['menu' => 'Im aufgeklappten Menü', 'bar' => 'Neben dem Menü-Knopf', 'bar-icon' => 'Neben dem Menü-Knopf, nur als Symbol'];
|
||||
public const MOTION = ['none', 'slide', 'reveal'];
|
||||
/** Formularfeld => gespeicherter Schlüssel. */
|
||||
public const COLORS = ['buttonFill' => 'fill', 'buttonInk' => 'ink', 'buttonHoverFill' => 'hoverFill', 'buttonHoverInk' => 'hoverInk', 'buttonIconColor' => 'iconColor'];
|
||||
private const DESIGN = ['buttonShape' => 'shape', 'buttonAppearance' => 'appearance', 'buttonSize' => 'size'];
|
||||
|
||||
/**
|
||||
* Aus den flachen Formularfeldern einer Zeile. Ohne Häkchen „Als Button“ kein Button.
|
||||
*/
|
||||
public static function fromForm(array $row): ?array
|
||||
{
|
||||
if ('1' !== (string) ($row['button'] ?? '')) {
|
||||
return null;
|
||||
}
|
||||
$id = (string) ($row['buttonId'] ?? '');
|
||||
$button = ['id' => 1 === preg_match('/^m[a-f0-9]{10}$/D', $id) ? $id : 'm'.bin2hex(random_bytes(5))];
|
||||
foreach ([...self::DESIGN, ...self::COLORS] as $field => $key) {
|
||||
$value = trim((string) ($row[$field] ?? ''));
|
||||
if ('' !== $value) {
|
||||
$button[$key] = $value;
|
||||
}
|
||||
}
|
||||
$button['mobile'] = (string) ($row['buttonMobile'] ?? 'menu');
|
||||
$motion = (string) ($row['iconMotion'] ?? 'none');
|
||||
if ('none' !== $motion) {
|
||||
$button['iconMotion'] = $motion;
|
||||
}
|
||||
if ('1' === (string) ($row['iconOnly'] ?? '')) {
|
||||
$button['iconOnly'] = true;
|
||||
}
|
||||
|
||||
return $button;
|
||||
}
|
||||
|
||||
public static function validate(mixed $button, array $item): void
|
||||
{
|
||||
if (!is_array($button) || 1 !== preg_match('/^m[a-f0-9]{10}$/D', (string) ($button['id'] ?? ''))) {
|
||||
throw new InvalidMenu('Ein Menü-Button braucht eine Kennung.');
|
||||
}
|
||||
$known = ['id', 'mobile', 'iconMotion', 'iconOnly', ...array_values(self::DESIGN), ...array_values(self::COLORS)];
|
||||
if ([] !== array_diff(array_keys($button), $known)) {
|
||||
throw new InvalidMenu('Unbekannte Einstellung am Menü-Button.');
|
||||
}
|
||||
foreach (self::DESIGN as $key) {
|
||||
if (isset($button[$key]) && !isset(ButtonDesign::OPTIONS[$key][$button[$key]])) {
|
||||
throw new InvalidMenu('Unbekannte Form, Darstellung oder Größe am Menü-Button.');
|
||||
}
|
||||
}
|
||||
foreach (self::COLORS as $key) {
|
||||
if (isset($button[$key]) && (!is_string($button[$key]) || 1 !== preg_match(StyleSchema::COLOR, $button[$key]))) {
|
||||
throw new InvalidMenu('Eine Farbe am Menü-Button ist ein Hexwert wie #b71c22 oder eine Farbe der Palette.');
|
||||
}
|
||||
}
|
||||
if (!isset(self::MOBILE[$button['mobile'] ?? ''])) {
|
||||
throw new InvalidMenu('Unbekannte Anzeige auf dem Telefon.');
|
||||
}
|
||||
// Auf dem Telefon nur als Symbol: ohne Symbol stünde dort ein leerer Knopf.
|
||||
if ('bar-icon' === $button['mobile'] && '' === ($item['icon'] ?? '')) {
|
||||
throw new InvalidMenu('Nur als Symbol neben dem Menü-Knopf braucht ein Symbol.');
|
||||
}
|
||||
if (isset($button['iconMotion']) && !in_array($button['iconMotion'], self::MOTION, true)) {
|
||||
throw new InvalidMenu('Unbekannte Bewegung des Symbols.');
|
||||
}
|
||||
if (isset($button['iconOnly']) && (true !== $button['iconOnly'] || '' === ($item['icon'] ?? ''))) {
|
||||
throw new InvalidMenu('Ein Menü-Button nur aus Symbol braucht ein Symbol.');
|
||||
}
|
||||
// Ein Knopf öffnet kein Untermenü: mit Unterpunkten wäre er Knopf und Aufklapper zugleich.
|
||||
if ([] !== ($item['children'] ?? [])) {
|
||||
throw new InvalidMenu(sprintf('„%s“ hat Unterpunkte und kann deshalb kein Button sein.', $item['label'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Farbregeln aller Menü-Buttons. Gesetzt werden die Variablen, aus denen die Button-Stile
|
||||
* ihre Flächen berechnen, plus die abgeleiteten Werte der gewählten oder der Standard-
|
||||
* Darstellung - sonst rechnete ein Button ohne eigene Darstellung weiter mit den Farben vom
|
||||
* Seitenanfang.
|
||||
*
|
||||
* @param array<string, array{items: array}> $menus
|
||||
*/
|
||||
public static function css(array $menus, array $themeButtons): string
|
||||
{
|
||||
$color = static fn (string $v): string => str_starts_with($v, '@') ? 'var(--k-'.substr($v, 1).')' : $v;
|
||||
$css = '';
|
||||
$walk = function (array $items) use (&$walk, &$css, $color, $themeButtons): void {
|
||||
foreach ($items as $item) {
|
||||
$walk($item['children'] ?? []);
|
||||
$button = $item['button'] ?? null;
|
||||
if (!is_array($button) || !isset($button['id'])) {
|
||||
continue;
|
||||
}
|
||||
$vars = [];
|
||||
foreach (['fill' => '--kb-fill', 'ink' => '--kb-ink', 'hoverFill' => '--kb-hover-fill', 'hoverInk' => '--kb-hover-ink'] as $key => $var) {
|
||||
if (isset($button[$key])) {
|
||||
$vars[] = $var.':'.$color($button[$key]);
|
||||
}
|
||||
}
|
||||
if ([] !== $vars) {
|
||||
foreach (ButtonDesign::appearance($button['appearance'] ?? $themeButtons['appearance'] ?? 'solid') as $key => $value) {
|
||||
$vars[] = '--kb-'.$key.':'.$value;
|
||||
}
|
||||
$css .= sprintf('.menu-button-%s{%s}', $button['id'], implode(';', $vars));
|
||||
}
|
||||
if (isset($button['iconColor'])) {
|
||||
$css .= sprintf('.menu-button-%s .button-icon{color:%s}', $button['id'], $color($button['iconColor']));
|
||||
}
|
||||
}
|
||||
};
|
||||
foreach ($menus as $menu) {
|
||||
$walk($menu['items'] ?? []);
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,11 @@ final class MenuRepository
|
||||
*/
|
||||
private ?array $pages = null;
|
||||
|
||||
public function resolve(array $items, string $currentPath, string $locale = 'de'): array
|
||||
/**
|
||||
* @param string|null $language eine weitere Sprache (ADR 111): Einträge zeigen dann ihre
|
||||
* Beschriftung in dieser Sprache, sofern sie eine haben
|
||||
*/
|
||||
public function resolve(array $items, string $currentPath, string $locale = 'de', ?string $language = null): array
|
||||
{
|
||||
if (null === $this->pages) {
|
||||
$this->pages = [];
|
||||
@@ -104,15 +108,16 @@ final class MenuRepository
|
||||
}
|
||||
}
|
||||
|
||||
return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath());
|
||||
return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath(), $language);
|
||||
}
|
||||
|
||||
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array
|
||||
private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath, ?string $language = null): array
|
||||
{
|
||||
$links = [];
|
||||
foreach ($items as $item) {
|
||||
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath);
|
||||
$label = $item['label'] ?? '';
|
||||
$children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath, $language);
|
||||
$translated = null === $language ? '' : trim((string) ($item['labels'][$language] ?? ''));
|
||||
$label = '' !== $translated ? $translated : ($item['label'] ?? '');
|
||||
if ('page' === $item['type']) {
|
||||
$page = $pages[$item['pageId']] ?? null;
|
||||
if (null === $page || null === $page['published_revision_id']) {
|
||||
@@ -132,12 +137,18 @@ final class MenuRepository
|
||||
if ('' === trim($label)) {
|
||||
continue;
|
||||
}
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : [])];
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : []), ...(!empty($item['icon']) ? ['icon' => $item['icon'], 'iconPosition' => $item['iconPosition'] ?? 'before'] : []), ...(isset($item['button']) ? ['button' => $item['button']] : [])];
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
/** Die Farben der Menü-Buttons aller Plätze, fürs Stylesheet der Seite (ADR 110). */
|
||||
public function buttonCss(array $themeButtons, string $locale = 'de'): string
|
||||
{
|
||||
return MenuButton::css($this->all($locale), $themeButtons);
|
||||
}
|
||||
|
||||
private function validate(array $items, int $depth = 1, int &$count = 0): void
|
||||
{
|
||||
if (!array_is_list($items)) {
|
||||
@@ -153,6 +164,18 @@ final class MenuRepository
|
||||
if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) {
|
||||
throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.');
|
||||
}
|
||||
// Beschriftungen in weiteren Sprachen (ADR 111): je Sprache ein Text, wie die
|
||||
// Beschriftung höchstens 80 Zeichen. Eine leere zeigt die Beschriftung der Hauptsprache.
|
||||
if (array_key_exists('labels', $item)) {
|
||||
if (!is_array($item['labels']) || ([] !== $item['labels'] && array_is_list($item['labels']))) {
|
||||
throw new InvalidMenu('Die übersetzten Beschriftungen sind ungültig.');
|
||||
}
|
||||
foreach ($item['labels'] as $code => $text) {
|
||||
if (!is_string($code) || !isset(Languages::CATALOG[$code]) || !is_string($text) || '' === trim($text) || mb_strlen($text) > 80) {
|
||||
throw new InvalidMenu('Eine übersetzte Beschriftung hat höchstens 80 Zeichen und gehört zu einer bekannten Sprache.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
|
||||
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
@@ -175,6 +198,9 @@ final class MenuRepository
|
||||
} else {
|
||||
throw new InvalidMenu('Unbekannte Zielart.');
|
||||
}
|
||||
if (array_key_exists('button', $item)) {
|
||||
MenuButton::validate($item['button'], $item);
|
||||
}
|
||||
$this->validate($item['children'] ?? [], $depth + 1, $count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ final class PageRepository
|
||||
$path = $parent['path'].'/'.$slug;
|
||||
} elseif (in_array($slug, self::RESERVED_SLUGS, true)) {
|
||||
throw new InvalidPage('Diese Adresse ist für das CMS reserviert.');
|
||||
} elseif (in_array($slug, $this->languageCodes(), true)) {
|
||||
throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug));
|
||||
}
|
||||
if (false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', $locale, $path])) {
|
||||
throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $path));
|
||||
@@ -119,6 +121,8 @@ final class PageRepository
|
||||
throw new InvalidPage('Eine Seite kann nicht unter eine ihrer eigenen Unterseiten verschoben werden.');
|
||||
}
|
||||
$newPath = $parent['path'].'/'.$slug;
|
||||
} elseif (in_array($slug, $this->languageCodes(), true)) {
|
||||
throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug));
|
||||
}
|
||||
if ($newPath === $page['path']) {
|
||||
return 0;
|
||||
@@ -148,6 +152,11 @@ final class PageRepository
|
||||
$redirects->remember($row['path'], $moved);
|
||||
}
|
||||
$this->db->executeStatement('UPDATE page SET slug = ?, parent_id = ?, version = version + 1 WHERE id = ?', [$slug, $parentId, $pageId]);
|
||||
// Die Übersetzungen hängen an derselben Hierarchie: unter einer verschobenen Seite
|
||||
// ändern sich auch die Adressen der anderen Sprachen.
|
||||
if ($this->multilingual()) {
|
||||
$this->writeTranslationPaths($this->translationPaths(), $redirects);
|
||||
}
|
||||
|
||||
return count($affected);
|
||||
});
|
||||
@@ -324,6 +333,9 @@ final class PageRepository
|
||||
// Detach both pointers first: the revision rows are referenced by the page row.
|
||||
$this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]);
|
||||
$this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]);
|
||||
if ($this->multilingual() || $this->db->createSchemaManager()->tablesExist(['page_translation'])) {
|
||||
$this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ?', [$pageId]);
|
||||
}
|
||||
$this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]);
|
||||
$this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]);
|
||||
});
|
||||
@@ -333,10 +345,15 @@ 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);
|
||||
$document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
|
||||
$this->validator->validate($document, writing: true);
|
||||
|
||||
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
|
||||
// Eine übersetzte Adresse, die schon vergeben ist, fällt hier auf und nicht erst beim
|
||||
// Veröffentlichen. Geschrieben wird sie erst dann - bis dahin ist sie Entwurf.
|
||||
if ($this->multilingual($document)) {
|
||||
$this->translationPaths($pageId, $this->translatedSlugs($pageId, $document));
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?',
|
||||
@@ -364,7 +381,8 @@ final class PageRepository
|
||||
if (false === $raw) {
|
||||
throw new \InvalidArgumentException('Revision does not belong to this page.');
|
||||
}
|
||||
$this->validator->validate(json_decode($raw, true, 512, JSON_THROW_ON_ERROR));
|
||||
$document = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->validator->validate($document);
|
||||
$changed = $this->db->executeStatement(
|
||||
'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||
[$revisionId, $pageId, $expectedVersion],
|
||||
@@ -372,6 +390,9 @@ final class PageRepository
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('The page changed. Reload before publishing.');
|
||||
}
|
||||
// Mit der Fassung gehen auch ihre Übersetzungen online - oder offline, wenn sie keine
|
||||
// mehr hat. Die Adressen spiegeln damit genau den veröffentlichten Stand.
|
||||
$this->publishTranslations($pageId, $document);
|
||||
// A post that goes public without its own date gets one, so the archive can order it.
|
||||
$this->db->executeStatement(
|
||||
"UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL",
|
||||
@@ -391,10 +412,230 @@ final class PageRepository
|
||||
}
|
||||
$page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->validator->validate($page['document']);
|
||||
$page['document'] = Translations::strip($page['document']);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine veröffentlichte Seite in einer weiteren Sprache, gefunden über ihre übersetzte
|
||||
* Adresse. Das Dokument kommt mit den übersetzten Texten; `path` bleibt die Adresse der
|
||||
* Hauptsprache, weil Navigation, Brotkrumen und Formulare an ihr hängen.
|
||||
*/
|
||||
public function findPublishedTranslation(string $path, string $locale): ?array
|
||||
{
|
||||
$page = $this->db->fetchAssociative(
|
||||
'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name, t.path AS translated_path FROM page_translation t JOIN page p ON p.id = t.page_id JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE t.path = ? AND t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL',
|
||||
[$path, $locale, 'default'],
|
||||
);
|
||||
if (false === $page) {
|
||||
return null;
|
||||
}
|
||||
$document = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$this->validator->validate($document);
|
||||
if (!in_array($locale, Translations::locales($document), true)) {
|
||||
return null;
|
||||
}
|
||||
$translation = $document['translations'][$locale];
|
||||
$page['document'] = Translations::apply($document, $locale);
|
||||
$page['main_locale'] = $page['locale'];
|
||||
$page['locale'] = $locale;
|
||||
// Ohne eigenen Suchtitel nimmt die Vorlage den übersetzten Seitentitel - nie den Suchtitel
|
||||
// der Hauptsprache, der in der falschen Sprache in den Suchergebnissen stünde.
|
||||
$page['seo_title'] = $translation['seoTitle'] ?? '';
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unter welchen Adressen eine Seite in den weiteren Sprachen veröffentlicht ist, als Pfad
|
||||
* ohne Schrägstrich vorn.
|
||||
*
|
||||
* @return array<string, string> Sprache => Pfad
|
||||
*/
|
||||
public function translatedPaths(string $pageId): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT locale, path FROM page_translation WHERE page_id = ? AND website_id = ? ORDER BY locale', [$pageId, 'default']) as $row) {
|
||||
$paths[$row['locale']] = $row['path'];
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle veröffentlichten Seiten einer Sprache: Pfad der Hauptsprache => übersetzter Pfad.
|
||||
* Damit zeigen Links auf einer übersetzten Seite auf die übersetzten Fassungen.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function translationMap(string $locale): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
'SELECT p.path AS main_path, t.path FROM page_translation t JOIN page p ON p.id = t.page_id WHERE t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL AND p.published_revision_id IS NOT NULL',
|
||||
[$locale, 'default'],
|
||||
) as $row) {
|
||||
$map[$row['main_path']] = $row['path'];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die weiteren Sprachen, direkt gelesen: über SiteSettings::load() bliebe sonst eine ältere
|
||||
* Startseite für den Rest der Anfrage zwischengespeichert.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function languageCodes(): array
|
||||
{
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
$settings = is_string($raw) ? json_decode($raw, true) : null;
|
||||
|
||||
return is_array($settings['languages'] ?? null) ? $settings['languages'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob die übersetzten Adressen überhaupt gepflegt werden müssen: nur bei einer Website mit
|
||||
* weiteren Sprachen oder einem Dokument mit Übersetzungen. Eine einsprachige Website fasst
|
||||
* die Tabelle so nie an - auch nicht, wenn nach einem Update die Migration noch aussteht.
|
||||
* Abgewählte Sprachen holt das nächste Veröffentlichen nach: es rechnet alle Pfade neu.
|
||||
*/
|
||||
private function multilingual(?array $document = null): bool
|
||||
{
|
||||
return [] !== $this->languageCodes() || (null !== $document && [] !== Translations::locales($document));
|
||||
}
|
||||
|
||||
/** @return array<string, string> Sprache => übersetzte Adresse, aus dem Dokument */
|
||||
private function translatedSlugs(string $pageId, array $document): array
|
||||
{
|
||||
$slug = (string) $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$pageId]);
|
||||
$slugs = [];
|
||||
foreach (Translations::locales($document) as $locale) {
|
||||
$slugs[$locale] = (string) ($document['translations'][$locale]['slug'] ?? $slug);
|
||||
}
|
||||
|
||||
return $slugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die übersetzten Adressen einer gerade veröffentlichten Seite: neue Sprachen kommen
|
||||
* dazu, entfernte fallen weg, und eine geänderte Adresse bekommt eine Weiterleitung.
|
||||
*/
|
||||
private function publishTranslations(string $pageId, array $document): void
|
||||
{
|
||||
if ($this->multilingual($document)) {
|
||||
$this->writeTranslationPaths($this->translationPaths($pageId, $this->translatedSlugs($pageId, $document)), new RedirectRepository($this->db));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet die übersetzten Adressen aller Seiten aus. Eine Seite steht in einer Sprache unter
|
||||
* dem übersetzten Pfad ihrer Elternseite - oder, wenn die nicht übersetzt ist, unter deren
|
||||
* Pfad in der Hauptsprache - und dahinter ihre übersetzte Adresse.
|
||||
*
|
||||
* Mit $pageId ersetzt $slugs die Sprachen dieser einen Seite (Entwurf oder Veröffentlichung);
|
||||
* sonst gilt der gespeicherte Stand (nach dem Verschieben). Doppelte Adressen werden
|
||||
* abgelehnt, bevor irgendetwas geschrieben ist.
|
||||
*
|
||||
* @param array<string, string> $slugs Sprache => Adresse
|
||||
*
|
||||
* @return array<string, array<string, array{slug: string, path: string, old: ?string}>> Sprache => Seite => Zeile
|
||||
*/
|
||||
private function translationPaths(?string $pageId = null, array $slugs = []): array
|
||||
{
|
||||
$pages = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, slug, parent_id FROM page WHERE website_id = ?', ['default']) as $row) {
|
||||
$pages[$row['id']] = $row;
|
||||
}
|
||||
$wanted = [];
|
||||
$old = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT page_id, locale, slug, path FROM page_translation WHERE website_id = ?', ['default']) as $row) {
|
||||
$wanted[$row['locale']][$row['page_id']] = $row['slug'];
|
||||
$old[$row['locale']][$row['page_id']] = $row['path'];
|
||||
}
|
||||
if (null !== $pageId) {
|
||||
foreach (array_keys($wanted) as $locale) {
|
||||
unset($wanted[$locale][$pageId]);
|
||||
}
|
||||
foreach ($slugs as $locale => $slug) {
|
||||
$wanted[$locale][$pageId] = $slug;
|
||||
}
|
||||
}
|
||||
$result = [];
|
||||
foreach ($wanted as $locale => $rows) {
|
||||
$memo = [];
|
||||
$pathOf = function (string $id, int $depth = 0) use (&$pathOf, &$memo, $pages, $rows): string {
|
||||
if (isset($memo[$id])) {
|
||||
return $memo[$id];
|
||||
}
|
||||
$page = $pages[$id] ?? null;
|
||||
if (null === $page || $depth > self::MAX_DEPTH) {
|
||||
return '';
|
||||
}
|
||||
$segment = $rows[$id] ?? $page['slug'];
|
||||
$parent = null === $page['parent_id'] ? '' : $pathOf($page['parent_id'], $depth + 1);
|
||||
|
||||
return $memo[$id] = '' === $parent ? $segment : $parent.'/'.$segment;
|
||||
};
|
||||
$taken = [];
|
||||
foreach ($rows as $id => $slug) {
|
||||
$id = (string) $id;
|
||||
if (!isset($pages[$id])) {
|
||||
continue;
|
||||
}
|
||||
$path = $pathOf($id);
|
||||
if (isset($taken[$path])) {
|
||||
throw new InvalidPage(sprintf('Die Adresse /%s/%s ist in %s bereits vergeben. Wähle für die Übersetzung eine andere Adresse.', $locale, $path, Languages::CATALOG[$locale]['name'] ?? $locale));
|
||||
}
|
||||
$taken[$path] = true;
|
||||
$result[$locale][$id] = ['slug' => $slug, 'path' => $path, 'old' => $old[$locale][$id] ?? null];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt die Tabelle auf den ausgerechneten Stand. Geänderte Pfade wandern in zwei Schritten,
|
||||
* damit zwei Seiten, die ihre Adressen tauschen, nicht am eindeutigen Index scheitern.
|
||||
*/
|
||||
private function writeTranslationPaths(array $paths, RedirectRepository $redirects): void
|
||||
{
|
||||
$keep = [];
|
||||
foreach ($paths as $locale => $rows) {
|
||||
foreach (array_keys($rows) as $id) {
|
||||
$keep[$id.'|'.$locale] = true;
|
||||
}
|
||||
}
|
||||
foreach ($this->db->fetchAllAssociative('SELECT page_id, locale FROM page_translation WHERE website_id = ?', ['default']) as $row) {
|
||||
if (!isset($keep[$row['page_id'].'|'.$row['locale']])) {
|
||||
$this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ? AND locale = ?', [$row['page_id'], $row['locale']]);
|
||||
}
|
||||
}
|
||||
$moved = [];
|
||||
foreach ($paths as $locale => $rows) {
|
||||
foreach ($rows as $id => $row) {
|
||||
if (null === $row['old']) {
|
||||
$this->db->insert('page_translation', ['page_id' => $id, 'website_id' => 'default', 'locale' => $locale, 'slug' => $row['slug'], 'path' => '~'.$id]);
|
||||
$moved[] = [$id, $locale, $row];
|
||||
} elseif ($row['old'] !== $row['path']) {
|
||||
$this->db->executeStatement('UPDATE page_translation SET path = ? WHERE page_id = ? AND locale = ?', ['~'.$id, $id, $locale]);
|
||||
$moved[] = [$id, $locale, $row];
|
||||
} else {
|
||||
$this->db->executeStatement('UPDATE page_translation SET slug = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $id, $locale]);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($moved as [$id, $locale, $row]) {
|
||||
$this->db->executeStatement('UPDATE page_translation SET slug = ?, path = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $row['path'], $id, $locale]);
|
||||
if (null !== $row['old']) {
|
||||
$redirects->remember($locale.'/'.$row['old'], $locale.'/'.$row['path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which header and footer a page wants: null for the standard one, 'none' for no area at all,
|
||||
* or the address of another one.
|
||||
|
||||
@@ -98,7 +98,7 @@ final class PartialRepository
|
||||
if ('section' !== $kind && null === $this->findByKind($kind, 'default', $locale)) {
|
||||
$slug = 'default';
|
||||
}
|
||||
$document = $this->richText->normaliseDocument($document);
|
||||
$document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
|
||||
$this->validator->validate($document, false, true);
|
||||
if (null !== $this->findByKind($kind, $slug, $locale)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug));
|
||||
@@ -116,7 +116,7 @@ final class PartialRepository
|
||||
|
||||
public function save(string $id, array $document, int $expectedVersion): void
|
||||
{
|
||||
$document = $this->richText->normaliseDocument($document);
|
||||
$document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText);
|
||||
$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.
|
||||
|
||||
@@ -114,7 +114,12 @@ final class PostRepository
|
||||
/**
|
||||
* @return array{posts: list<array>, total: int, pages: int, page: int}
|
||||
*/
|
||||
public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de'): array
|
||||
/**
|
||||
* @param string|null $language eine weitere Sprache (ADR 111): Titel und Auszug kommen aus
|
||||
* der Übersetzung des Beitrags, sofern er eine hat, die Namen der
|
||||
* Kategorien aus den Einstellungen
|
||||
*/
|
||||
public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de', ?string $language = null): array
|
||||
{
|
||||
$where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL";
|
||||
$params = ['default', $locale];
|
||||
@@ -138,25 +143,33 @@ final class PostRepository
|
||||
// einzeln nach seinem Bild und seinen Begriffen, eine Liste mit zwölf Einträgen kostete
|
||||
// also 24 Abfragen statt zwei. Bei neighbours(), das 500 Beiträge lädt, war das absurd.
|
||||
$images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows));
|
||||
$terms = $this->termsForMany(array_column($rows, 'id'));
|
||||
$terms = $this->termsForMany(array_column($rows, 'id'), $language);
|
||||
|
||||
return [
|
||||
'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []]), $rows),
|
||||
'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []], $language), $rows),
|
||||
'total' => $total,
|
||||
'pages' => $pages,
|
||||
'page' => $page,
|
||||
];
|
||||
}
|
||||
|
||||
private function present(array $row, array $images, array $terms): array
|
||||
private function present(array $row, array $images, array $terms, ?string $language = null): array
|
||||
{
|
||||
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$item = $images[$row['featured_media_id']] ?? null;
|
||||
// Übersetzt ist ein Beitrag, wenn seine veröffentlichte Fassung die Sprache trägt. Sonst
|
||||
// steht er in der Hauptsprache in der Liste - wie jedes nicht übersetzte Feld.
|
||||
$translated = null !== $language && in_array($language, Translations::locales($document), true);
|
||||
$excerpt = $row['excerpt'] ?? $document['description'] ?? '';
|
||||
if ($translated) {
|
||||
$excerpt = $document['translations'][$language]['description'] ?? $excerpt;
|
||||
$document = Translations::apply($document, $language);
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $document['title'] ?? $row['path'],
|
||||
'href' => $this->settings->publicPath($row['path']),
|
||||
'excerpt' => $row['excerpt'] ?? $document['description'] ?? '',
|
||||
'excerpt' => $excerpt,
|
||||
'author' => $row['author'],
|
||||
'publishedAt' => $row['published_at'],
|
||||
'image' => null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']],
|
||||
@@ -171,7 +184,7 @@ final class PostRepository
|
||||
*
|
||||
* @return array<string, array<string, list<array{slug: string, name: string, href: string}>>>
|
||||
*/
|
||||
private function termsForMany(array $pageIds): array
|
||||
private function termsForMany(array $pageIds, ?string $language = null): array
|
||||
{
|
||||
$pageIds = array_values(array_unique(array_filter($pageIds)));
|
||||
if ([] === $pageIds) {
|
||||
@@ -184,12 +197,28 @@ final class PostRepository
|
||||
$pageIds,
|
||||
) as $row) {
|
||||
$terms[$row['page_id']] ??= ['category' => [], 'tag' => []];
|
||||
$terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
|
||||
$terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $this->termName($row, $language), 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
|
||||
}
|
||||
|
||||
return $terms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Name einer Kategorie oder eines Schlagworts in einer weiteren Sprache, gepflegt unter
|
||||
* Einstellungen → Sprachen (ADR 111). Ohne Übersetzung der Name der Hauptsprache.
|
||||
*
|
||||
* @param array{kind: string, slug: string, name: string} $term
|
||||
*/
|
||||
public function termName(array $term, ?string $language): string
|
||||
{
|
||||
if (null === $language) {
|
||||
return $term['name'];
|
||||
}
|
||||
$name = $this->settings->load()['termNames'][$language][$term['kind'].':'.$term['slug']] ?? '';
|
||||
|
||||
return is_string($name) && '' !== $name ? $name : $term['name'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array{slug: string, name: string, href: string}>>
|
||||
*/
|
||||
@@ -203,14 +232,14 @@ final class PostRepository
|
||||
*
|
||||
* @return array{previous: ?array, next: ?array}
|
||||
*/
|
||||
public function neighbours(string $pageId, string $locale = 'de'): array
|
||||
public function neighbours(string $pageId, string $locale = 'de', ?string $language = null): array
|
||||
{
|
||||
$termId = (string) $this->db->fetchOne(
|
||||
'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1',
|
||||
[$pageId, 'category'],
|
||||
);
|
||||
// Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier.
|
||||
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale)['posts'];
|
||||
$all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['posts'];
|
||||
// present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse.
|
||||
// Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist.
|
||||
$own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId]));
|
||||
@@ -249,11 +278,11 @@ final class PostRepository
|
||||
|
||||
private ?bool $anyPublished = null;
|
||||
|
||||
public function termsFor(string $pageId): array
|
||||
public function termsFor(string $pageId, ?string $language = null): array
|
||||
{
|
||||
$terms = ['category' => [], 'tag' => []];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? ORDER BY t.name', [$pageId]) as $row) {
|
||||
$terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
|
||||
$terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $this->termName($row, $language), 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']];
|
||||
}
|
||||
|
||||
return $terms;
|
||||
|
||||
@@ -89,6 +89,13 @@ final class SiteSettings
|
||||
'consentAccentInk' => '',
|
||||
'privacyPath' => '',
|
||||
'imprintPath' => '',
|
||||
// Die weiteren Sprachen neben der Hauptsprache (ADR 111), gepflegt über Languages::save.
|
||||
// Leer heißt: einsprachig - und dann ist nirgends etwas von Übersetzungen zu sehen.
|
||||
'languages' => [],
|
||||
// Namen von Kategorien und Schlagwörtern je weiterer Sprache: {en: {"category:projekte": "Projects"}}.
|
||||
'termNames' => [],
|
||||
// Überschrift und Text des Einwilligungshinweises je weiterer Sprache: {en: {title, text}}.
|
||||
'consentTexts' => [],
|
||||
];
|
||||
|
||||
/** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */
|
||||
@@ -191,6 +198,10 @@ final class SiteSettings
|
||||
$values = self::DEFAULTS;
|
||||
// Assigned separately, so saving company data cannot clear the error page.
|
||||
$values['notFoundPageId'] = $this->load()['notFoundPageId'];
|
||||
// Ebenso die Sprachen: sie haben einen eigenen Bildschirm mit eigener Prüfung.
|
||||
$values['languages'] = $this->load()['languages'];
|
||||
$values['termNames'] = $this->load()['termNames'];
|
||||
$values['consentTexts'] = $this->load()['consentTexts'];
|
||||
foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
|
||||
$values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
|
||||
}
|
||||
@@ -403,6 +414,71 @@ final class SiteSettings
|
||||
$this->loaded = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur die Sprachen ändern, alles andere unberührt lassen. Geprüft wird in Languages::save.
|
||||
*
|
||||
* @param list<string> $codes
|
||||
*/
|
||||
public function saveLanguages(array $codes): void
|
||||
{
|
||||
$this->db->transactional(function () use ($codes): void {
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$stored = is_array($stored) ? $stored : [];
|
||||
$stored['languages'] = array_values($codes);
|
||||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
|
||||
]);
|
||||
});
|
||||
$this->loaded = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Texte der Einstellungen in weiteren Sprachen (ADR 111): Namen von Kategorien und
|
||||
* Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises. Alles andere bleibt.
|
||||
*
|
||||
* @param array<string, array<string, string>> $names Sprache => "art:adresse" => Name
|
||||
* @param array<string, array<string, string>> $consent Sprache => {title, text}
|
||||
*/
|
||||
public function saveLanguageTexts(array $names, array $consent = []): void
|
||||
{
|
||||
$consentClean = [];
|
||||
foreach ($consent as $locale => $texts) {
|
||||
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($texts)) {
|
||||
continue;
|
||||
}
|
||||
foreach (['title' => 80, 'text' => 600] as $key => $max) {
|
||||
$text = mb_substr(trim((string) ($texts[$key] ?? '')), 0, $max);
|
||||
if ('' !== $text) {
|
||||
$consentClean[$locale][$key] = $text;
|
||||
}
|
||||
}
|
||||
}
|
||||
$clean = [];
|
||||
foreach ($names as $locale => $entries) {
|
||||
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($entries)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $key => $name) {
|
||||
$name = mb_substr(trim((string) $name), 0, 120);
|
||||
if ('' !== $name && 1 === preg_match('/^(category|tag):[a-z0-9-]{1,80}$/D', (string) $key)) {
|
||||
$clean[$locale][(string) $key] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->db->transactional(function () use ($clean, $consentClean): void {
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$stored = is_array($stored) ? $stored : [];
|
||||
$stored['termNames'] = $clean;
|
||||
$stored['consentTexts'] = $consentClean;
|
||||
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
|
||||
]);
|
||||
});
|
||||
$this->loaded = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two mandatory pages are missing, by their settings key.
|
||||
*
|
||||
|
||||
@@ -208,6 +208,16 @@ final class StyleCompiler
|
||||
if ('' !== $hover) {
|
||||
$rules .= sprintf('#%s:hover,#%s:focus-within{%s}', $id, $id, $hover);
|
||||
}
|
||||
// Das Symbol im Button (ADR 109): Farbe, Fläche, Größe und Drehung liegen am Button,
|
||||
// gelten aber seiner Symbolhülle - auch beim Überfahren und bei Tastaturfokus.
|
||||
$icon = $this->declarations($values, 'icon', $device);
|
||||
if ('' !== $icon) {
|
||||
$rules .= sprintf('#%s .button-icon{%s}', $id, $icon);
|
||||
}
|
||||
$iconHover = is_array($values['hover'] ?? null) ? $this->declarations($values['hover'], 'icon', $device) : '';
|
||||
if ('' !== $iconHover) {
|
||||
$rules .= sprintf('#%s:hover .button-icon,#%s:focus-visible .button-icon{%s}', $id, $id, $iconHover);
|
||||
}
|
||||
// The scrolled state: scrolled.js puts `is-scrolled` on <html> once the page has moved.
|
||||
// The change eases in, so a header does not snap from glass to solid.
|
||||
$scrolled = is_array($values['scrolled'] ?? null) ? $this->declarations($this->inheritShadow($values['scrolled'], $values), 'self', $device) : '';
|
||||
|
||||
@@ -219,6 +219,19 @@ final class StyleSchema
|
||||
'burgerBorderWidth' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-width', 'label' => 'Menü-Knopf: Rahmenstärke', 'target' => 'burger'],
|
||||
'burgerBorderRadius' => ['group' => 'menu', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Menü-Knopf: Ecken', 'target' => 'burger'],
|
||||
'burgerFontSize' => ['group' => 'menu', 'type' => 'length', 'css' => 'font-size', 'label' => 'Menü-Knopf: Schriftgröße', 'target' => 'burger'],
|
||||
// Das Symbol im Button (ADR 109). Die Werte liegen am Button und wirken auf seine
|
||||
// Symbolhülle, je Gerät und mit eigenem Wert beim Überfahren. Ohne Angabe folgt das
|
||||
// Symbol der Schriftfarbe und hat 1,15 em Größe wie bisher.
|
||||
'iconColor' => ['group' => 'icon', 'type' => 'color', 'css' => 'color', 'label' => 'Symbolfarbe', 'target' => 'icon', 'hover' => true],
|
||||
'iconBackgroundColor' => ['group' => 'icon', 'type' => 'color', 'css' => 'background-color', 'label' => 'Fläche hinter dem Symbol', 'target' => 'icon', 'hover' => true],
|
||||
'iconSize' => ['group' => 'icon', 'type' => 'length', 'css' => '--icon-size', 'label' => 'Symbolgröße', 'target' => 'icon'],
|
||||
'iconPadding' => ['group' => 'icon', 'type' => 'length', 'css' => 'padding', 'label' => 'Innenabstand der Fläche', 'target' => 'icon'],
|
||||
'iconRadius' => ['group' => 'icon', 'type' => 'length', 'css' => 'border-radius', 'label' => 'Ecken der Fläche', 'target' => 'icon'],
|
||||
'iconRotate' => ['group' => 'icon', 'type' => 'choice', 'css' => 'rotate', 'label' => 'Drehung', 'target' => 'icon', 'hover' => true, 'values' => [
|
||||
'0deg' => 'Keine', '45deg' => '45° im Uhrzeigersinn', '90deg' => '90° im Uhrzeigersinn', '180deg' => '180°', '-45deg' => '45° gegen den Uhrzeigersinn', '-90deg' => '90° gegen den Uhrzeigersinn',
|
||||
]],
|
||||
// Am Button selbst: der Abstand gilt auch, wenn das Symbol erst beim Überfahren erscheint.
|
||||
'iconGap' => ['group' => 'icon', 'type' => 'length', 'css' => '--icon-gap', 'label' => 'Abstand zum Text'],
|
||||
'sliderDotColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot', 'label' => 'Punkte', 'target' => 'controls'],
|
||||
'sliderDotActiveColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-dot-active', 'label' => 'Aktiver Punkt', 'target' => 'controls'],
|
||||
'sliderControlColor' => ['group' => 'slider', 'type' => 'color', 'css' => '--slider-ink', 'label' => 'Pfeile und Zähler', 'target' => 'controls'],
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
/**
|
||||
* Übersetzungen als Ebene über dem Blockbaum (ADR 111, Dokumentschema v27).
|
||||
*
|
||||
* Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter `translations`
|
||||
* nur die Texte, die sich unterscheiden - adressiert über die stabile ID des Blocks:
|
||||
*
|
||||
* translations.en = {
|
||||
* slug: "services", title: "…", description: "…", seoTitle: "…",
|
||||
* blocks: { "blk_a1": { "text": "Our services" } },
|
||||
* source: { "blk_a1.text": "1a2b3c4d" } // so sah der Text der Hauptsprache aus
|
||||
* }
|
||||
*
|
||||
* Aufbau, Gestaltung, Bilder und Links bleiben dieselben. Ausgeliefert wird eine Sprache, indem
|
||||
* die Texte auf eine Kopie des Dokuments gelegt werden (apply) - danach läuft dieselbe Prüfung
|
||||
* und dieselbe Ausgabe wie für die Hauptsprache, ohne dass ein Renderer davon wissen muss. Ein
|
||||
* Feld ohne Übersetzung zeigt den Text der Hauptsprache.
|
||||
*
|
||||
* Weil die Übersetzungen im Dokument stehen, gehören sie zu jeder Fassung: Entwurf,
|
||||
* Veröffentlichung, Wiederherstellung und Undo im Editor gelten für alle Sprachen zugleich.
|
||||
*/
|
||||
final class Translations
|
||||
{
|
||||
public const SCHEMA = 27;
|
||||
|
||||
/**
|
||||
* Welche Eigenschaften eines Blocks Text sind, den eine Übersetzung ersetzt - mit derselben
|
||||
* Höchstlänge wie im DocumentValidator. Dieselbe Liste steht in translate.js.
|
||||
*/
|
||||
public const FIELDS = [
|
||||
'heading' => ['text'],
|
||||
'text' => ['text'],
|
||||
'button' => ['text'],
|
||||
'image' => ['alt', 'caption'],
|
||||
'iconcard' => ['title', 'text'],
|
||||
'stat' => ['value', 'label'],
|
||||
'quote' => ['text', 'author', 'role'],
|
||||
'cta' => ['title', 'text', 'buttonText'],
|
||||
'contact' => ['address'],
|
||||
'accordionitem' => ['title'],
|
||||
'tabitem' => ['title'],
|
||||
'legal' => ['label'],
|
||||
'postnav' => ['previousLabel', 'nextLabel'],
|
||||
'icon' => ['label'],
|
||||
'progress' => ['label'],
|
||||
'embed' => ['title', 'hint'],
|
||||
];
|
||||
|
||||
/** Felder der Seite selbst und ihre Höchstlänge. */
|
||||
public const PAGE_FIELDS = ['title' => 160, 'description' => 320, 'seoTitle' => 160];
|
||||
|
||||
/** Wie viele Einträge eine Sprache höchstens trägt - so viele, wie eine Seite Blöcke haben darf. */
|
||||
public const MAX_BLOCKS = 300;
|
||||
|
||||
public static function supports(mixed $schemaVersion): bool
|
||||
{
|
||||
return is_int($schemaVersion) && $schemaVersion >= self::SCHEMA;
|
||||
}
|
||||
|
||||
/** Die Sprachen, für die das Dokument überhaupt etwas übersetzt hat. @return list<string> */
|
||||
public static function locales(array $document): array
|
||||
{
|
||||
$locales = [];
|
||||
foreach (($document['translations'] ?? []) as $locale => $translation) {
|
||||
if (is_string($locale) && is_array($translation) && self::hasContent($translation)) {
|
||||
$locales[] = $locale;
|
||||
}
|
||||
}
|
||||
|
||||
return $locales;
|
||||
}
|
||||
|
||||
/** Eine Übersetzung ohne Titel und ohne einen einzigen Text ist keine - nur ein Anfang. */
|
||||
public static function hasContent(array $translation): bool
|
||||
{
|
||||
return '' !== trim((string) ($translation['title'] ?? '')) || [] !== array_filter($translation['blocks'] ?? [], static fn (mixed $fields): bool => is_array($fields) && [] !== $fields);
|
||||
}
|
||||
|
||||
/** Das Dokument ohne Übersetzungen - so, wie die Hauptsprache es ausliefert. */
|
||||
public static function strip(array $document): array
|
||||
{
|
||||
unset($document['translations']);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Dokument in einer Sprache: die übersetzten Texte auf einer Kopie, die Übersetzungen
|
||||
* selbst entfernt. Fehlt ein Text, bleibt der der Hauptsprache stehen.
|
||||
*/
|
||||
public static function apply(array $document, string $locale): array
|
||||
{
|
||||
$translation = $document['translations'][$locale] ?? [];
|
||||
$document = self::strip($document);
|
||||
if (!is_array($translation)) {
|
||||
return $document;
|
||||
}
|
||||
foreach (['title', 'description'] as $field) {
|
||||
if (is_string($translation[$field] ?? null) && '' !== trim($translation[$field])) {
|
||||
$document[$field] = $translation[$field];
|
||||
}
|
||||
}
|
||||
$blocks = is_array($translation['blocks'] ?? null) ? $translation['blocks'] : [];
|
||||
if ([] !== $blocks) {
|
||||
$document['blocks'] = self::overlay($document['blocks'] ?? [], $blocks);
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
private static function overlay(array $list, array $translated): array
|
||||
{
|
||||
foreach ($list as $index => $block) {
|
||||
if (!is_array($block)) {
|
||||
continue;
|
||||
}
|
||||
$fields = $translated[$block['id'] ?? ''] ?? null;
|
||||
$type = $block['type'] ?? '';
|
||||
if (is_array($fields) && isset(self::FIELDS[$type])) {
|
||||
foreach (self::FIELDS[$type] as $prop) {
|
||||
$value = $fields[$prop] ?? null;
|
||||
if (!is_string($value) || '' === trim($value) || !array_key_exists($prop, $block['props'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
// Ein dekoratives Bild oder Symbol hat keinen Text - auch nicht übersetzt.
|
||||
if (in_array($prop, ['alt', 'label'], true) && true === ($block['props']['decorative'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$list[$index]['props'][$prop] = $value;
|
||||
}
|
||||
}
|
||||
if (is_array($block['children'] ?? null)) {
|
||||
$list[$index]['children'] = self::overlay($block['children'], $translated);
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Räumt die Übersetzungen vor dem Speichern auf: formatierter Text geht durch denselben
|
||||
* Filter wie in der Hauptsprache, leere Felder fallen weg, und Einträge für Blöcke, die es
|
||||
* nicht mehr gibt, auch. Was danach noch ungültig ist, lehnt der DocumentValidator ab.
|
||||
*/
|
||||
public static function normalise(array $document, RichText $richText): array
|
||||
{
|
||||
if (!isset($document['translations']) || !is_array($document['translations'])) {
|
||||
return $document;
|
||||
}
|
||||
$types = [];
|
||||
$collect = static function (array $blocks) use (&$collect, &$types): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (is_array($block) && is_string($block['id'] ?? null)) {
|
||||
$types[$block['id']] = (string) ($block['type'] ?? '');
|
||||
$collect(is_array($block['children'] ?? null) ? $block['children'] : []);
|
||||
}
|
||||
}
|
||||
};
|
||||
$collect(is_array($document['blocks'] ?? null) ? $document['blocks'] : []);
|
||||
$rich = RichText::supports($document['schemaVersion'] ?? null);
|
||||
$richHeadings = RichText::headings($document['schemaVersion'] ?? null);
|
||||
|
||||
foreach ($document['translations'] as $locale => $translation) {
|
||||
if (!is_array($translation)) {
|
||||
continue;
|
||||
}
|
||||
foreach (array_keys(self::PAGE_FIELDS) as $field) {
|
||||
if (is_string($translation[$field] ?? null)) {
|
||||
$translation[$field] = trim($translation[$field]);
|
||||
if ('' === $translation[$field]) {
|
||||
unset($translation[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_string($translation['slug'] ?? null)) {
|
||||
$translation['slug'] = strtolower(trim($translation['slug']));
|
||||
if ('' === $translation['slug']) {
|
||||
unset($translation['slug']);
|
||||
}
|
||||
}
|
||||
$blocks = [];
|
||||
foreach ((is_array($translation['blocks'] ?? null) ? $translation['blocks'] : []) as $id => $fields) {
|
||||
$type = $types[$id] ?? null;
|
||||
if (null === $type || !isset(self::FIELDS[$type]) || !is_array($fields)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($fields as $prop => $value) {
|
||||
if (!in_array($prop, self::FIELDS[$type], true) || !is_string($value) || '' === trim($value)) {
|
||||
continue;
|
||||
}
|
||||
if ('text' === $prop && (('text' === $type && $rich) || ('heading' === $type && $richHeadings))) {
|
||||
$value = $richText->sanitise($value);
|
||||
}
|
||||
$blocks[$id][$prop] = $value;
|
||||
}
|
||||
}
|
||||
$translation['blocks'] = $blocks;
|
||||
if ([] === $blocks) {
|
||||
unset($translation['blocks']);
|
||||
}
|
||||
// Nur Vermerke zu Texten, die es gibt; der Rest wäre ein Vermerk ohne Gegenstand.
|
||||
$source = [];
|
||||
foreach ((is_array($translation['source'] ?? null) ? $translation['source'] : []) as $key => $hash) {
|
||||
[$id, $prop] = array_pad(explode('.', (string) $key, 2), 2, null);
|
||||
$present = null === $prop ? isset($translation[$id]) : isset($blocks[$id][$prop]);
|
||||
if ($present && is_string($hash)) {
|
||||
$source[$key] = $hash;
|
||||
}
|
||||
}
|
||||
$translation['source'] = $source;
|
||||
if ([] === $source) {
|
||||
unset($translation['source']);
|
||||
}
|
||||
$document['translations'][$locale] = $translation;
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah. FNV-1a, 32 Bit - kurz,
|
||||
* und in PHP wie in JavaScript (translate.js) ohne Bibliothek gleich zu rechnen.
|
||||
*/
|
||||
public static function fingerprint(string $text): string
|
||||
{
|
||||
return hash('fnv1a32', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie weit eine Sprache ist: übersetzbare Texte, davon übersetzt, davon seitdem in der
|
||||
* Hauptsprache geändert.
|
||||
*
|
||||
* @return array{total: int, done: int, stale: int}
|
||||
*/
|
||||
public static function progress(array $document, string $locale): array
|
||||
{
|
||||
$translation = $document['translations'][$locale] ?? [];
|
||||
$translation = is_array($translation) ? $translation : [];
|
||||
$count = ['total' => 0, 'done' => 0, 'stale' => 0];
|
||||
$tally = static function (string $key, string $original, mixed $value) use (&$count, $translation): void {
|
||||
if ('' === trim($original)) {
|
||||
return;
|
||||
}
|
||||
++$count['total'];
|
||||
if (!is_string($value) || '' === trim($value)) {
|
||||
return;
|
||||
}
|
||||
++$count['done'];
|
||||
$hash = $translation['source'][$key] ?? null;
|
||||
if (is_string($hash) && $hash !== self::fingerprint($original)) {
|
||||
++$count['stale'];
|
||||
}
|
||||
};
|
||||
foreach (['title', 'description'] as $field) {
|
||||
$tally($field, (string) ($document[$field] ?? ''), $translation[$field] ?? null);
|
||||
}
|
||||
$walk = static function (array $blocks) use (&$walk, $tally, $translation): void {
|
||||
foreach ($blocks as $block) {
|
||||
foreach (self::FIELDS[$block['type'] ?? ''] ?? [] as $prop) {
|
||||
if (in_array($prop, ['alt', 'label'], true) && true === ($block['props']['decorative'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$tally($block['id'].'.'.$prop, (string) ($block['props'][$prop] ?? ''), $translation['blocks'][$block['id']][$prop] ?? null);
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,18 @@ use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostLayouts;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -31,7 +34,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, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview)
|
||||
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, private Languages $languages, private SiteSettings $siteSettings)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -440,8 +443,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||
}
|
||||
if (strlen($request->getContent()) > 270000) {
|
||||
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt).'], 413);
|
||||
// Eine Seite darf 256 KiB haben, mit ihren Übersetzungen zusammen 1 MiB (ADR 111).
|
||||
if (strlen($request->getContent()) > 1100000) {
|
||||
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413);
|
||||
}
|
||||
$page = $this->page($id);
|
||||
try {
|
||||
@@ -456,20 +460,30 @@ final class EditorController extends AbstractController
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
$this->validator->validate($document, writing: true);
|
||||
// Die Vorschau einer Übersetzung ist dieselbe Seite mit den übersetzten Texten -
|
||||
// genau so, wie die Website sie in dieser Sprache ausliefert.
|
||||
$language = $body['language'] ?? null;
|
||||
$locale = is_string($language) && $this->languages->isExtra($language) ? $language : $this->languages->main();
|
||||
$seoTitle = $locale === $this->languages->main() ? ($page['seo_title'] ?? null) : ($document['translations'][$locale]['seoTitle'] ?? '');
|
||||
$document = $locale === $this->languages->main() ? Translations::strip($document) : Translations::apply($document, $locale);
|
||||
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
|
||||
|
||||
$request->attributes->set('kant_locale', $locale);
|
||||
$localise = fn (array $doc): array => $locale === $this->languages->main() ? Translations::strip($doc) : Translations::apply($doc, $locale);
|
||||
|
||||
$theme = $this->theme->load();
|
||||
$areas = [];
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
$area = $this->partials->findByKind($kind);
|
||||
if (null !== $area) {
|
||||
$areas[$kind] = $area['document'];
|
||||
$areas[$kind] = $localise($area['document']);
|
||||
}
|
||||
}
|
||||
$sections = [];
|
||||
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
||||
$section = $this->partials->resolveSection($slug);
|
||||
if (null !== $section) {
|
||||
$sections[$slug] = $section['document'];
|
||||
$sections[$slug] = $localise($section['document']);
|
||||
}
|
||||
}
|
||||
// Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen.
|
||||
@@ -485,25 +499,25 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path'], language: $locale === $this->languages->main() ? null : $locale);
|
||||
}
|
||||
|
||||
// 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);
|
||||
$styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']);
|
||||
foreach ($documents as $doc) {
|
||||
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
|
||||
}
|
||||
$forms = $this->previewForms($documents, $page);
|
||||
$forms = $this->previewForms($documents, $page, $locale === $this->languages->main() ? null : $locale);
|
||||
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) : []]);
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document, 'locale' => $locale, 'seo_title' => $seoTitle], '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();
|
||||
@@ -536,6 +550,9 @@ final class EditorController extends AbstractController
|
||||
return $this->json(['error' => 'Inzwischen wurde ein neuerer Stand gespeichert. Deine Änderungen bleiben im Editor. Lade sie als JSON herunter, bevor du den aktuellen Serverstand lädst.', 'conflict' => true], 409);
|
||||
} catch (InvalidDocument $e) {
|
||||
return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422);
|
||||
} catch (InvalidPage $e) {
|
||||
// Etwa eine übersetzte Adresse, die eine andere Seite schon hat.
|
||||
return $this->json(['error' => $e->getMessage()], 422);
|
||||
} catch (\JsonException|\InvalidArgumentException) {
|
||||
return $this->json(['error' => 'Die Anfrage enthält kein gültiges Seitendokument oder keine gültige Version.'], 422);
|
||||
}
|
||||
@@ -549,13 +566,16 @@ final class EditorController extends AbstractController
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function previewForms(array $documents, array $page): array
|
||||
private function previewForms(array $documents, array $page, ?string $language = null): array
|
||||
{
|
||||
$states = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page): void {
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page, $language): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||||
if (null !== $form && null !== $language) {
|
||||
$form = FormRepository::localise($form, $language);
|
||||
}
|
||||
$states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
@@ -575,6 +595,15 @@ 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(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor()];
|
||||
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(),
|
||||
// Für den Sprachumschalter (ADR 111). Ohne weitere Sprache leer - dann gibt es ihn nicht.
|
||||
'languages' => [
|
||||
'main' => $this->languages->describe($this->languages->main()),
|
||||
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
'slug' => $page['slug'],
|
||||
'home' => $page['path'] === $this->siteSettings->homePath(),
|
||||
'published' => [] === $this->languages->extra() ? [] : $this->pages->translatedPaths($page['id']),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
@@ -15,25 +16,49 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class FormAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media)
|
||||
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media, private Languages $languages)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Übersicht: jedes Formular eine Zeile, dazu die Einsendungen. Bearbeitet wird ein
|
||||
* Formular auf seiner eigenen Seite - mit mehreren Formularen war die eine lange Seite mit
|
||||
* allen Editoren und Vorschauen nicht mehr zu überblicken (Umut, 09.10.2026).
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Empfaenger, Felder und die Einsendungen selbst: nichts davon gehoert in die Hand einer
|
||||
// Rolle, die nicht einmal eine Seite veroeffentlichen darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'forms' => $this->forms->all(),
|
||||
'counts' => $this->forms->submissionCounts(),
|
||||
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
|
||||
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Ein Formular bearbeiten, oder mit der Adresse „neu“ eines anlegen. */
|
||||
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_edit', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['GET'])]
|
||||
public function edit(string $slug): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$current = 'neu' === $slug ? null : $this->forms->findBySlug($slug);
|
||||
if ('neu' !== $slug && null === $current) {
|
||||
throw $this->createNotFoundException('Dieses Formular gibt es nicht.');
|
||||
}
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'editing' => true,
|
||||
'current' => $current,
|
||||
'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)),
|
||||
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
// Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und
|
||||
// kennt die Palette, damit eine Farbe gewaehlt statt getippt wird.
|
||||
'schema' => ['colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttons' => $theme['buttons'] ?? ButtonDesign::defaults($theme['base']), 'base' => $theme['base']],
|
||||
@@ -48,10 +73,27 @@ final class FormAdminController extends AbstractController
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$fields = [];
|
||||
// Je weitere Sprache: Felder tr_<sprache>_label, _help, _unit, _options am Feld, dazu
|
||||
// success_<sprache> und buttonLabel_<sprache> am Formular (ADR 111). Die Optionen stehen
|
||||
// als Kommaliste in derselben Reihenfolge wie die der Hauptsprache.
|
||||
$translations = [];
|
||||
foreach ($this->languages->extra() as $code) {
|
||||
$translations[$code] = ['success' => $request->request->getString('success_'.$code), 'buttonLabel' => $request->request->getString('buttonLabel_'.$code), 'fields' => []];
|
||||
}
|
||||
foreach ($request->request->all('fields') as $row) {
|
||||
if (!is_array($row) || '' === trim((string) ($row['label'] ?? ''))) {
|
||||
continue;
|
||||
}
|
||||
$originals = array_map(trim(...), explode(',', (string) ($row['options'] ?? '')));
|
||||
foreach (array_keys($translations) as $code) {
|
||||
$translatedOptions = array_map(trim(...), explode(',', (string) ($row['tr_'.$code.'_options'] ?? '')));
|
||||
$translations[$code]['fields'][(string) ($row['name'] ?? '')] = [
|
||||
'label' => (string) ($row['tr_'.$code.'_label'] ?? ''),
|
||||
'help' => (string) ($row['tr_'.$code.'_help'] ?? ''),
|
||||
'unit' => (string) ($row['tr_'.$code.'_unit'] ?? ''),
|
||||
'options' => array_combine($originals, array_pad(array_slice($translatedOptions, 0, count($originals)), count($originals), '')),
|
||||
];
|
||||
}
|
||||
$fields[] = [
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'type' => (string) ($row['type'] ?? 'text'),
|
||||
@@ -82,21 +124,23 @@ final class FormAdminController extends AbstractController
|
||||
'replyToField' => $request->request->getString('replyToField'),
|
||||
'buttonLabel' => $request->request->getString('buttonLabel'),
|
||||
'summary' => '' !== $request->request->getString('summary'),
|
||||
'translations' => $translations,
|
||||
...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),
|
||||
);
|
||||
} catch (InvalidForm|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
return $this->back($e->getMessage(), 'error', $slug);
|
||||
}
|
||||
|
||||
return $this->back('Das Formular wurde gespeichert.', 'success');
|
||||
return $this->back('Das Formular wurde gespeichert.', 'success', 'neu' === $slug ? $request->request->getString('slug') : $slug);
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
private function back(string $message, string $type = 'error', ?string $slug = null): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_forms');
|
||||
// Zurück zum bearbeiteten Formular; ohne Adresse (abgelaufene Sitzung) zur Übersicht.
|
||||
return null === $slug ? $this->redirectToRoute('admin_forms') : $this->redirectToRoute('admin_form_edit', ['slug' => $slug]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
@@ -32,6 +33,7 @@ final class FormController extends AbstractController
|
||||
private Turnstile $turnstile,
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
private Languages $languages,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -56,10 +58,19 @@ final class FormController extends AbstractController
|
||||
// noch einmal.
|
||||
|| !$this->turnstile->passed($request->request->getString('cf-turnstile-response'), $request->getClientIp());
|
||||
|
||||
$checked = $this->forms->check($form, $request->request->all());
|
||||
// Abgeschickt von einer übersetzten Seite (ADR 111): Meldungen in dieser Sprache, und der
|
||||
// Weg zurück führt auf die übersetzte Seite - sofern es sie gibt.
|
||||
$language = $request->request->getString('language');
|
||||
$translatedPath = null;
|
||||
if ($this->languages->isExtra($language)) {
|
||||
$translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null;
|
||||
}
|
||||
$language = null === $translatedPath ? null : $language;
|
||||
|
||||
$checked = $this->forms->check($form, $request->request->all(), $language);
|
||||
if (!$silentlyDropped && [] !== $checked['errors']) {
|
||||
// Re-render the page the form sits on, with the entered values and the messages.
|
||||
return $this->renderPage($request, $page, $form, $checked);
|
||||
return $this->renderPage($request, $page, $form, $checked, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
||||
}
|
||||
if (!$silentlyDropped) {
|
||||
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
|
||||
@@ -69,7 +80,9 @@ final class FormController extends AbstractController
|
||||
}
|
||||
|
||||
// Post/Redirect/Get so a reload does not send the message twice.
|
||||
return $this->redirect($this->settings->publicPath($path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
|
||||
$back = null === $language ? $this->settings->publicPath($path) : '/'.$language.($page['path'] === $this->settings->homePath() ? '' : '/'.$translatedPath);
|
||||
|
||||
return $this->redirect($back.'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
|
||||
}
|
||||
|
||||
/** The signed timestamp makes the age of the form checkable without storing anything. */
|
||||
@@ -128,7 +141,7 @@ final class FormController extends AbstractController
|
||||
* vergass, sah eine Seite ohne Gestaltung. Jetzt geht der Weg durch dieselbe Stelle wie jeder
|
||||
* andere Aufruf, und die Formularzustaende werden als Anfrageattribut mitgereicht.
|
||||
*/
|
||||
private function renderPage(Request $request, array $page, array $form, array $checked): Response
|
||||
private function renderPage(Request $request, array $page, array $form, array $checked, ?string $translatedAddress = null): Response
|
||||
{
|
||||
// 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.
|
||||
@@ -137,7 +150,7 @@ final class FormController extends AbstractController
|
||||
|
||||
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
|
||||
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
|
||||
$adresse = $page['path'] === $this->settings->homePath() ? '' : (string) $page['path'];
|
||||
$adresse = $translatedAddress ?? ($page['path'] === $this->settings->homePath() ? '' : (string) $page['path']);
|
||||
$antwort = $this->forward('App\Controller\PageController::page', [
|
||||
'path' => $adresse,
|
||||
'request' => $request->duplicate(null, null, ['kant_form_state' => $zustaende, '_route' => 'page']),
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MenuButton;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -14,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class MenuController extends AbstractController
|
||||
{
|
||||
public function __construct(private MenuRepository $menus, private PageRepository $pages)
|
||||
public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme, private Languages $languages)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -67,6 +71,7 @@ final class MenuController extends AbstractController
|
||||
$slot = $request->query->getString('slot', 'header');
|
||||
$slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header';
|
||||
$menu = $this->menus->all()[$slot] ?? null;
|
||||
$theme = $this->theme->load();
|
||||
$rows = $this->flatten($menu['items'] ?? []);
|
||||
$selected = [];
|
||||
foreach ($rows as &$row) {
|
||||
@@ -81,6 +86,9 @@ final class MenuController extends AbstractController
|
||||
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
|
||||
'menu' => $menu, 'rows' => $rows,
|
||||
'menuIcons' => \App\Content\DocumentValidator::ICONS,
|
||||
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
// Fuer die Button-Vorschau und die Farbwahl am Eintrag: dieselben Daten wie im Editor.
|
||||
'buttonSchema' => ['buttons' => $theme['buttons'], 'colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttonOptions' => ButtonDesign::OPTIONS, 'mobile' => MenuButton::MOBILE],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,6 +131,8 @@ final class MenuController extends AbstractController
|
||||
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
|
||||
'newTab' => $item['newTab'] ?? false,
|
||||
'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before',
|
||||
'button' => $item['button'] ?? null,
|
||||
'labels' => $item['labels'] ?? [],
|
||||
];
|
||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||
}
|
||||
@@ -152,6 +162,12 @@ final class MenuController extends AbstractController
|
||||
'icon' => (string) ($row['icon'] ?? ''), 'iconPosition' => (string) ($row['iconPosition'] ?? 'before'),
|
||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||
'position' => (int) ($row['position'] ?? 0),
|
||||
'button' => MenuButton::fromForm($row),
|
||||
// Je weitere Sprache ein Feld label_<sprache>; leer heißt: wie in der Hauptsprache.
|
||||
'labels' => array_filter(
|
||||
array_combine($this->languages->extra(), array_map(static fn (string $code): string => mb_substr(trim((string) ($row['label_'.$code] ?? '')), 0, 80), $this->languages->extra())) ?: [],
|
||||
static fn (string $text): bool => '' !== $text,
|
||||
),
|
||||
];
|
||||
}
|
||||
usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']);
|
||||
@@ -166,6 +182,12 @@ final class MenuController extends AbstractController
|
||||
if ($row['newTab']) {
|
||||
$item['newTab'] = true;
|
||||
}
|
||||
if (null !== $row['button']) {
|
||||
$item['button'] = $row['button'];
|
||||
}
|
||||
if ([] !== $row['labels']) {
|
||||
$item['labels'] = $row['labels'];
|
||||
}
|
||||
if ('url' === $row['type']) {
|
||||
$item['url'] = $row['url'];
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\NotFoundPage;
|
||||
@@ -18,6 +19,7 @@ use App\Content\SiteSettings;
|
||||
use App\Content\StyleBundle;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Content\Turnstile;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -27,21 +29,40 @@ 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, private PluginRegistry $plugins)
|
||||
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, private Languages $languages)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Sprache dieser Anfrage, wenn es nicht die Hauptsprache ist: Kennung und die Zuordnung
|
||||
* Pfad der Hauptsprache => übersetzter Pfad (ADR 111). Null für die Hauptsprache.
|
||||
*
|
||||
* @var array{locale: string, map: array<string, string>}|null
|
||||
*/
|
||||
private ?array $translation = null;
|
||||
|
||||
/** 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}';
|
||||
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,4}';
|
||||
|
||||
#[Route('/', name: 'home', methods: ['GET'])]
|
||||
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
|
||||
public function page(Request $request, string $path = ''): Response
|
||||
{
|
||||
$this->translation = null;
|
||||
// Eine weitere Sprache steht vorn in der Adresse: /en ist ihre Startseite, /en/services
|
||||
// eine übersetzte Seite. Ohne weitere Sprache gibt es diesen Weg gar nicht.
|
||||
$segments = explode('/', $path, 2);
|
||||
if ('' !== $path && $this->languages->isExtra($segments[0])) {
|
||||
return $this->translatedPage($request, $segments[0], $segments[1] ?? '');
|
||||
}
|
||||
// Mehr als vier Ebenen gibt es nur mit einer Sprache davor.
|
||||
if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$home = $this->settings->homePath();
|
||||
$path = '' === $path ? $home : $path;
|
||||
// One canonical address for the root page: its own path redirects to "/".
|
||||
@@ -71,6 +92,134 @@ final class PageController extends AbstractController
|
||||
return $this->renderPage($request, $page, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Seite in einer weiteren Sprache. Gefunden wird sie über ihre übersetzte Adresse; ihre
|
||||
* Startseite antwortet unter /en und nicht zusätzlich unter /en/home.
|
||||
*/
|
||||
private function translatedPage(Request $request, string $locale, string $rest): Response
|
||||
{
|
||||
$map = $this->pages->translationMap($locale);
|
||||
// Schon hier: auch eine 404 unter /en/ antwortet auf Englisch.
|
||||
$this->translation = ['locale' => $locale, 'map' => $map];
|
||||
$homeTranslated = $map[$this->settings->homePath()] ?? null;
|
||||
if ('' !== $rest && $rest === $homeTranslated && null === $request->attributes->get('kant_form_state')) {
|
||||
return $this->shell($this->redirect('/'.$locale, 301));
|
||||
}
|
||||
// Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache.
|
||||
if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) {
|
||||
$this->translation = ['locale' => $locale, 'map' => $map];
|
||||
|
||||
return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
|
||||
}
|
||||
$target = '' === $rest ? $homeTranslated : $rest;
|
||||
$page = null === $target ? null : $this->pages->findPublishedTranslation($target, $locale);
|
||||
if (null === $page) {
|
||||
$redirect = '' === $rest ? null : $this->redirects->find($locale.'/'.$rest);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
}
|
||||
|
||||
return $this->notFound($request, null !== $redirect ? 410 : 404);
|
||||
}
|
||||
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
|
||||
return $this->renderPage($request, $page, $page['path']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopf, Fuß und Sektionen in der Sprache dieser Anfrage: übersetzt, wo es Übersetzungen gibt,
|
||||
* und ohne die Übersetzungen selbst in der Hauptsprache.
|
||||
*/
|
||||
private function localise(array $document): array
|
||||
{
|
||||
return null === $this->translation ? Translations::strip($document) : Translations::apply($document, $this->translation['locale']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die öffentliche Adresse eines Pfades der Hauptsprache in der Sprache dieser Anfrage - oder
|
||||
* unverändert, wenn die Seite dort nicht übersetzt ist.
|
||||
*/
|
||||
private function localHref(string $href): string
|
||||
{
|
||||
if (null === $this->translation || !str_starts_with($href, '/') || str_starts_with($href, '//')) {
|
||||
return $href;
|
||||
}
|
||||
$suffix = '';
|
||||
if (false !== ($cut = strcspn($href, '?#')) && $cut < strlen($href)) {
|
||||
$suffix = substr($href, $cut);
|
||||
$href = substr($href, 0, $cut);
|
||||
}
|
||||
$path = trim($href, '/');
|
||||
$home = $this->settings->homePath();
|
||||
$locale = $this->translation['locale'];
|
||||
if ('' === $path || $path === $home) {
|
||||
return isset($this->translation['map'][$home]) ? '/'.$locale.$suffix : $href.$suffix;
|
||||
}
|
||||
if ('beitraege' === $path || str_starts_with($path, 'kategorie/') || str_starts_with($path, 'schlagwort/')) {
|
||||
return '/'.$locale.'/'.$path.$suffix;
|
||||
}
|
||||
|
||||
return isset($this->translation['map'][$path]) ? '/'.$locale.'/'.$this->translation['map'][$path].$suffix : $href.$suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf
|
||||
* deren übersetzte Fassung - in Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen.
|
||||
* Ein Link auf eine nicht übersetzte Seite bleibt, wie er ist. Ausgenommen sind die Links
|
||||
* der Sprachwahl, die mit hreflang bewusst auf eine bestimmte Sprache zeigen.
|
||||
*/
|
||||
private function localiseLinks(string $html): string
|
||||
{
|
||||
if (null === $this->translation) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return preg_replace_callback('/<a\b[^>]*>/i', function (array $tag): string {
|
||||
if (str_contains($tag[0], 'hreflang=')) {
|
||||
return $tag[0];
|
||||
}
|
||||
|
||||
return preg_replace_callback('/\bhref="(\/[^"]*)"/', fn (array $href): string => 'href="'.$this->localHref($href[1]).'"', $tag[0]) ?? $tag[0];
|
||||
}, $html) ?? $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unter welchen Adressen diese Seite in welcher Sprache steht - für hreflang und die
|
||||
* Sprachwahl. Leer, solange die Website nur eine Sprache hat.
|
||||
*
|
||||
* @return list<array{locale: string, native: string, href: string, url: string, current: bool}>
|
||||
*/
|
||||
private function alternates(array $page, int $status): array
|
||||
{
|
||||
$extra = $this->languages->extra();
|
||||
if ([] === $extra || $status >= 400 || !isset($page['id']) || 'archive' === ($page['type'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
$origin = rtrim($this->siteOrigin, '/');
|
||||
$home = $this->settings->homePath();
|
||||
$current = $this->translation['locale'] ?? $this->languages->main();
|
||||
$main = $this->languages->main();
|
||||
$list = [['locale' => $main, 'href' => $this->settings->publicPath($page['path'], $home)]];
|
||||
$translated = $this->pages->translatedPaths($page['id']);
|
||||
foreach ($extra as $locale) {
|
||||
if (isset($translated[$locale])) {
|
||||
$list[] = ['locale' => $locale, 'href' => $page['path'] === $home ? '/'.$locale : '/'.$locale.'/'.$translated[$locale]];
|
||||
}
|
||||
}
|
||||
if (count($list) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn (array $entry): array => [
|
||||
...$entry,
|
||||
'native' => $this->languages->describe($entry['locale'])['native'],
|
||||
'url' => $origin.$entry['href'],
|
||||
'current' => $entry['locale'] === $current,
|
||||
], $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die
|
||||
* Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript.
|
||||
@@ -94,7 +243,45 @@ final class PageController extends AbstractController
|
||||
|
||||
public function notFound(Request $request, int $status = 404): Response
|
||||
{
|
||||
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
|
||||
$page = $this->notFoundPage->page($status);
|
||||
$locale = $this->translation['locale'] ?? null;
|
||||
// Die eigene 404-Seite ist eine Seite wie jede andere und wird im Editor übersetzt; die
|
||||
// mitgelieferte bekommt ihre Texte aus dem Wörterbuch der festen Texte (ADR 111).
|
||||
if (null !== $locale && in_array($locale, Translations::locales($page['document']), true)) {
|
||||
$page['document'] = Translations::apply($page['document'], $locale);
|
||||
} else {
|
||||
$page['document'] = Translations::strip($page['document']);
|
||||
if (null !== $locale && str_starts_with((string) $page['id'], 'error-')) {
|
||||
$page['document'] = $this->translateFixedTexts($page['document'], $locale);
|
||||
}
|
||||
}
|
||||
if (null !== $locale) {
|
||||
$page['locale'] = $locale;
|
||||
}
|
||||
|
||||
return $this->renderPage($request, $page, '', $status);
|
||||
}
|
||||
|
||||
/** Titel, Beschreibung und Texte eines mitgelieferten Dokuments über das Wörterbuch. */
|
||||
private function translateFixedTexts(array $document, string $locale): array
|
||||
{
|
||||
$t = static fn (string $text): string => \App\Twig\LanguageExtension::UI[$locale][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text;
|
||||
foreach (['title', 'description'] as $field) {
|
||||
$document[$field] = $t((string) ($document[$field] ?? ''));
|
||||
}
|
||||
$walk = function (array $blocks) use (&$walk, $t): array {
|
||||
foreach ($blocks as $index => $block) {
|
||||
if (is_string($block['props']['text'] ?? null)) {
|
||||
$blocks[$index]['props']['text'] = $t($block['props']['text']);
|
||||
}
|
||||
$blocks[$index]['children'] = $walk($block['children'] ?? []);
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
};
|
||||
$document['blocks'] = $walk($document['blocks'] ?? []);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/** Published pages and error content share theme, areas, block rendering and CSP. */
|
||||
@@ -102,10 +289,12 @@ final class PageController extends AbstractController
|
||||
{
|
||||
$styles = '';
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($status >= 400 ? null : $page);
|
||||
$areas = array_map($this->localise(...), $this->areas($status >= 400 ? null : $page));
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
$sections = array_map($this->localise(...), $this->sections($page['document'], $areas));
|
||||
// Die festen Texte der Vorlagen folgen der Sprache der Seite (LanguageExtension).
|
||||
$request->attributes->set('kant_locale', $this->translation['locale'] ?? $this->languages->main());
|
||||
// Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen
|
||||
// Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß.
|
||||
$pristine = $sections;
|
||||
@@ -138,7 +327,7 @@ final class PageController extends AbstractController
|
||||
// for the small script that marks the page as scrolled.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
$needsScrolled = (isset($areas['header']) && $this->hasScrolled($areas['header'])) || (bool) ($this->settings->load()['scrollTop'] ?? false);
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
$styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->scrollTopStyles($this->settings->load(), $theme).$this->consent->styles($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
@@ -176,20 +365,25 @@ final class PageController extends AbstractController
|
||||
break;
|
||||
}
|
||||
}
|
||||
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath());
|
||||
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->localHref($this->settings->publicPath($path, $this->settings->homePath()));
|
||||
$alternates = $this->alternates($page, $status);
|
||||
// Die Sprachwahl ist ein Baustein und kann im Kopf, im Fuß oder im Inhalt stehen; die
|
||||
// Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen.
|
||||
$request->attributes->set('kant_alternates', $alternates);
|
||||
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => $canonical,
|
||||
'alternates' => $alternates,
|
||||
'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse],
|
||||
'navigation' => $this->navigation($path),
|
||||
'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,
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id'], $this->translation['locale'] ?? null) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id'], language: $this->translation['locale'] ?? null) : [],
|
||||
'needsTabs' => $needsTabs,
|
||||
'needsFormScript' => $needsFormScript,
|
||||
'pluginStyles' => $pluginAssets['styles'],
|
||||
@@ -244,6 +438,9 @@ final class PageController extends AbstractController
|
||||
if ($status >= 400) {
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
if (null !== $this->translation) {
|
||||
$response->setContent($this->localiseLinks((string) $response->getContent()));
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument);
|
||||
}
|
||||
@@ -317,6 +514,10 @@ final class PageController extends AbstractController
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
$slug = $block['props']['form'] ?? '';
|
||||
$form = $this->forms->findBySlug($slug);
|
||||
// In einer weiteren Sprache mit übersetzten Beschriftungen (ADR 111).
|
||||
if (null !== $form && null !== $this->translation) {
|
||||
$form = FormRepository::localise($form, $this->translation['locale']);
|
||||
}
|
||||
$states[$block['id']] = [
|
||||
'form' => $form,
|
||||
// Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt.
|
||||
@@ -420,9 +621,12 @@ final class PageController extends AbstractController
|
||||
$prefix = '';
|
||||
foreach (explode('/', (string) $page['path']) as $segment) {
|
||||
$prefix = '' === $prefix ? $segment : $prefix.'/'.$segment;
|
||||
$ancestor = $this->pages->findPublished($prefix);
|
||||
// In einer weiteren Sprache heißt jede Stufe so, wie sie dort heißt - sofern es sie
|
||||
// dort gibt. Sonst bleibt sie in der Hauptsprache stehen, mit ihrer Adresse.
|
||||
$translated = null === $this->translation || !isset($this->translation['map'][$prefix]) ? null : $this->pages->findPublishedTranslation($this->translation['map'][$prefix], $this->translation['locale']);
|
||||
$ancestor = $translated ?? $this->pages->findPublished($prefix);
|
||||
if (null !== $ancestor) {
|
||||
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => '/'.$prefix];
|
||||
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => $this->localHref('/'.$prefix)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,7 +709,7 @@ final class PageController extends AbstractController
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110),
|
||||
'mainEntityOfPage' => $origin.'/'.$page['path'],
|
||||
'mainEntityOfPage' => $origin.$this->localHref('/'.$page['path']),
|
||||
];
|
||||
foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) {
|
||||
if ('' !== (string) $value && null !== $value) {
|
||||
@@ -618,23 +822,32 @@ final class PageController extends AbstractController
|
||||
#[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
|
||||
public function archive(Request $request, ?string $slug = null): Response
|
||||
{
|
||||
$kind = 'category' === $request->attributes->get('_route') ? 'category' : 'tag';
|
||||
$this->translation = null;
|
||||
|
||||
return $this->renderArchive($request, 'category' === $request->attributes->get('_route') ? 'category' : 'tag', $slug);
|
||||
}
|
||||
|
||||
private function renderArchive(Request $request, string $kind, ?string $slug): Response
|
||||
{
|
||||
$language = $this->translation['locale'] ?? null;
|
||||
$t = static fn (string $text): string => null === $language ? $text : (\App\Twig\LanguageExtension::UI[$language][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text);
|
||||
$term = null;
|
||||
if (null !== $slug) {
|
||||
$term = $this->posts->findTerm($kind, $slug);
|
||||
if (null === $term) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$term['name'] = $this->posts->termName($term, $language);
|
||||
}
|
||||
$page = max(1, $request->query->getInt('seite', 1));
|
||||
$result = $this->posts->published($page, $term['id'] ?? null);
|
||||
$result = $this->posts->published($page, $term['id'] ?? null, language: $language);
|
||||
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
|
||||
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
|
||||
if (0 === $result['total']) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
$heading = null === $term ? 'Beiträge' : $term['name'];
|
||||
$heading = null === $term ? $t('Beiträge') : $term['name'];
|
||||
|
||||
// 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,
|
||||
@@ -642,15 +855,15 @@ final class PageController extends AbstractController
|
||||
// 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')]],
|
||||
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('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']]]]);
|
||||
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? $t('Kategorie') : $t('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']),
|
||||
'description' => null === $term ? $t('Alle Beiträge, die neuesten zuerst.') : sprintf($t('category' === $kind ? 'Beiträge in der Kategorie „%s“.' : 'Beiträge zum Schlagwort „%s“.'), $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' => [
|
||||
@@ -659,7 +872,7 @@ final class PageController extends AbstractController
|
||||
],
|
||||
];
|
||||
$seite = [
|
||||
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => 'de',
|
||||
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => $language ?? $this->languages->main(),
|
||||
'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document,
|
||||
];
|
||||
|
||||
@@ -705,7 +918,7 @@ final class PageController extends AbstractController
|
||||
// immer die erste - sonst würden zwei Listen auf einer Seite einander
|
||||
// umblättern.
|
||||
$paginate = (bool) ($props['paginate'] ?? false);
|
||||
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count']);
|
||||
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count'], language: $this->translation['locale'] ?? null);
|
||||
$lists[$block['id']] = $paginate ? $result : $result['posts'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
@@ -986,7 +1199,7 @@ final class PageController extends AbstractController
|
||||
{
|
||||
$links = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath);
|
||||
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath, language: $this->translation['locale'] ?? null);
|
||||
}
|
||||
|
||||
return $links;
|
||||
|
||||
@@ -6,10 +6,12 @@ use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -42,6 +44,7 @@ final class PartialController extends AbstractController
|
||||
private CsrfTokenManagerInterface $csrf,
|
||||
private PluginRegistry $plugins,
|
||||
private FormRepository $forms,
|
||||
private Languages $languages,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -125,8 +128,8 @@ final class PartialController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||
}
|
||||
if (strlen($request->getContent()) > 270000) {
|
||||
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413);
|
||||
if (strlen($request->getContent()) > 1100000) {
|
||||
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413);
|
||||
}
|
||||
$partial = $this->partial($id);
|
||||
try {
|
||||
@@ -137,8 +140,9 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
if ('admin_partial_preview' === $request->attributes->get('_route')) {
|
||||
$this->validator->validate($document, false, true);
|
||||
$language = $body['language'] ?? null;
|
||||
|
||||
return $this->renderPreview($partial, $document);
|
||||
return $this->renderPreview($partial, $document, $request, is_string($language) && $this->languages->isExtra($language) ? $language : null);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
|
||||
throw new \InvalidArgumentException();
|
||||
@@ -163,31 +167,41 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
|
||||
/** The area on its own, in the shell of a page, so the editor preview looks like the result. */
|
||||
private function renderPreview(array $partial, array $document): Response
|
||||
private function renderPreview(array $partial, array $document, Request $request, ?string $language = null): Response
|
||||
{
|
||||
// Ein Bereich in einer weiteren Sprache (ADR 111): seine Texte, die seiner Sektionen und
|
||||
// die Menübeschriftungen in dieser Sprache - wie auf einer übersetzten Seite.
|
||||
$localise = static fn (array $doc): array => null === $language ? Translations::strip($doc) : Translations::apply($doc, $language);
|
||||
$document = $localise($document);
|
||||
$locale = $language ?? $this->languages->main();
|
||||
$request->attributes->set('kant_locale', $locale);
|
||||
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
|
||||
$theme = $this->theme->load();
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], '');
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], '', language: $language);
|
||||
}
|
||||
$sections = [];
|
||||
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
||||
$section = $this->partials->resolveSection($slug);
|
||||
if (null !== $section) {
|
||||
$sections[$slug] = $section['document'];
|
||||
$sections[$slug] = $localise($section['document']);
|
||||
}
|
||||
}
|
||||
|
||||
// Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben.
|
||||
$styles = $this->theme->css($theme).$this->styles->compileDocument($document).$this->code->compileDocument($document);
|
||||
$styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$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 {
|
||||
$walk = function (array $blocks) use (&$walk, &$forms, $language): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||||
if (null !== $form && null !== $language) {
|
||||
$form = FormRepository::localise($form, $language);
|
||||
}
|
||||
$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);
|
||||
@@ -202,7 +216,7 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', [
|
||||
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
||||
'page' => ['locale' => $locale, 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
||||
'canonical' => '',
|
||||
'editing' => true,
|
||||
'navigation' => $navigation,
|
||||
@@ -232,6 +246,12 @@ final class PartialController extends AbstractController
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
'plugins' => $this->plugins->forEditor(),
|
||||
'presets' => $this->plugins->presetsForEditor(),
|
||||
// Kopf, Fuß und Sektionen werden wie Seiten übersetzt, nur ohne Adresse und Suchtitel.
|
||||
'languages' => [
|
||||
'main' => $this->languages->describe($this->languages->main()),
|
||||
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
'area' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\Languages;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use Doctrine\DBAL\Connection;
|
||||
@@ -22,6 +23,7 @@ final class SeoController extends AbstractController
|
||||
private PostRepository $posts,
|
||||
private string $siteOrigin,
|
||||
private string $adminPath,
|
||||
private Languages $languages,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -44,6 +46,24 @@ final class SeoController extends AbstractController
|
||||
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
||||
];
|
||||
}
|
||||
// Jede weitere Sprache mit ihren veröffentlichten Seiten (ADR 111), unter denselben
|
||||
// Bedingungen wie die Hauptsprache. Die Zuordnung der Sprachen zueinander steht als
|
||||
// hreflang in den Seiten selbst.
|
||||
$extra = $this->languages->extra();
|
||||
if ([] !== $extra) {
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
"SELECT t.locale, t.path AS translated, p.path, p.published_at, r.created_at FROM page_translation t JOIN page p ON p.id = t.page_id JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE t.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY t.locale, t.path",
|
||||
['default', $this->settings->load()['notFoundPageId']],
|
||||
) as $row) {
|
||||
if (!in_array($row['locale'], $extra, true)) {
|
||||
continue;
|
||||
}
|
||||
$entries[] = [
|
||||
'loc' => $origin.'/'.$row['locale'].($row['path'] === $home ? '' : '/'.$row['translated']),
|
||||
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
||||
];
|
||||
}
|
||||
}
|
||||
// Archives exist only while they list something.
|
||||
if ([] !== $this->posts->published(1, null, 1)['posts']) {
|
||||
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\Languages;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\SocialProfiles;
|
||||
@@ -19,7 +21,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 Theme $theme, private NotFoundPage $notFoundPage)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage, private Languages $languages, private PostRepository $posts)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -67,9 +69,52 @@ final class SettingsController extends AbstractController
|
||||
'socialNetworks' => SocialProfiles::NETWORKS,
|
||||
'weekdays' => SiteSettings::DAYS,
|
||||
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
|
||||
'mainLanguage' => $this->languages->describe($this->languages->main()),
|
||||
'extraLanguages' => $this->languages->extra(),
|
||||
'terms' => [] === $this->languages->extra() ? [] : $this->posts->allTerms(),
|
||||
'languageCatalog' => array_map(fn (string $code): array => $this->languages->describe($code), array_values(array_diff(array_keys(Languages::CATALOG), [$this->languages->main()]))),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die weiteren Sprachen (ADR 111). Eine Sprache dazunehmen ändert an der Website nichts,
|
||||
* solange keine Seite übersetzt und veröffentlicht ist; eine Sprache wegnehmen nimmt ihre
|
||||
* Seiten vom Netz, lässt die Übersetzungen aber in den Seiten liegen.
|
||||
*/
|
||||
/** Kategorien, Schlagwörter und Einwilligungshinweis in den weiteren Sprachen (ADR 111). */
|
||||
#[Route('/%kant.admin_path%/settings/sprachen/texte', name: 'admin_language_texts_save', methods: ['POST'])]
|
||||
public function saveLanguageTexts(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$all = $request->request->all();
|
||||
$this->settings->saveLanguageTexts(is_array($all['termNames'] ?? null) ? $all['termNames'] : [], is_array($all['consentTexts'] ?? null) ? $all['consentTexts'] : []);
|
||||
$this->addFlash('success', 'Die Übersetzungen wurden gespeichert.');
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/sprachen', name: 'admin_languages_save', methods: ['POST'])]
|
||||
public function saveLanguages(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->languages->save(array_values(array_map('strval', (array) ($request->request->all()['languages'] ?? []))));
|
||||
} catch (InvalidPage $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||||
}
|
||||
$this->addFlash('success', [] === $this->languages->extra() ? 'Die Website ist einsprachig.' : 'Die Sprachen wurden gespeichert. Übersetzt wird im Editor jeder Seite über den Sprachumschalter oben.');
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Content\Languages;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/** Sprachangaben für die Vorlagen (ADR 111). */
|
||||
final class LanguageExtension extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* Die festen Texte der öffentlichen Vorlagen, die kein Redakteur pflegt: Sprunglink,
|
||||
* Beschriftungen von Navigationen, Einwilligung, Beitragslisten. Schlüssel ist der deutsche
|
||||
* Text. Für Sprachen ohne eigene Liste gilt Englisch - auf einer französischen Seite ist
|
||||
* „Skip to content“ näher am Besucher als „Zum Inhalt springen“.
|
||||
*/
|
||||
public const UI = [
|
||||
'en' => [
|
||||
'Zum Inhalt springen' => 'Skip to content',
|
||||
'Menü' => 'Menu',
|
||||
'Brotkrumen' => 'Breadcrumb',
|
||||
'Start' => 'Home',
|
||||
'Soziale Profile' => 'Social profiles',
|
||||
'Zum Blättern seitwärts scrollen' => 'Scroll sideways to browse',
|
||||
'Anbieter:' => 'Provider:',
|
||||
'Inhalt laden und einwilligen' => 'Load content and consent',
|
||||
'Einwilligung ändern' => 'Change consent',
|
||||
'Bildergalerie' => 'Image gallery',
|
||||
'Bild' => 'Image',
|
||||
'vergrößern' => 'enlarge',
|
||||
'Ansehen' => 'View',
|
||||
'Weiterlesen' => 'Read more',
|
||||
'Weitere Beiträge' => 'More posts',
|
||||
'Vorheriger Beitrag' => 'Previous post',
|
||||
'Nächster Beitrag' => 'Next post',
|
||||
'Alle akzeptieren' => 'Accept all',
|
||||
'Nur notwendige' => 'Necessary only',
|
||||
'Einzeln wählen' => 'Choose individually',
|
||||
'Auswahl speichern' => 'Save selection',
|
||||
'Datenschutz' => 'Privacy',
|
||||
'Impressum' => 'Legal notice',
|
||||
'Hauptnavigation' => 'Main navigation',
|
||||
'Zweite Navigation' => 'Secondary navigation',
|
||||
'Fußzeile' => 'Footer',
|
||||
'Rechtliches' => 'Legal',
|
||||
'Navigation' => 'Navigation',
|
||||
'Nach oben' => 'Back to top',
|
||||
'Sprache' => 'Language',
|
||||
// Formulare
|
||||
'Dieses Formular ist nicht mehr vorhanden.' => 'This form is no longer available.',
|
||||
'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check 1 entry. The field concerned is marked below.',
|
||||
'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check %d entries. The fields concerned are marked below.',
|
||||
'Pflichtfeld' => 'required',
|
||||
'in' => 'in',
|
||||
'bitte wählen' => 'please choose',
|
||||
'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.' => 'Sending this form requires JavaScript. Otherwise, feel free to email us directly.',
|
||||
'Zusammenfassung' => 'Summary',
|
||||
'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.' => 'Please check your details. “Change” takes you back to the step.',
|
||||
'Schritte des Formulars' => 'Form steps',
|
||||
'Schritt' => 'Step',
|
||||
'Zurück' => 'Back',
|
||||
'Weiter' => 'Next',
|
||||
'Ändern' => 'Change',
|
||||
'Ja' => 'Yes',
|
||||
'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.',
|
||||
'Absenden' => 'Send',
|
||||
'Vielen Dank. Deine Nachricht ist angekommen.' => 'Thank you. Your message has arrived.',
|
||||
// Beiträge und Archive
|
||||
'Beiträge' => 'Posts',
|
||||
'Beitrag' => 'post',
|
||||
'Kategorie' => 'Category',
|
||||
'Schlagwort' => 'Tag',
|
||||
'Alle Beiträge, die neuesten zuerst.' => 'All posts, newest first.',
|
||||
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
|
||||
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
|
||||
'von' => 'by',
|
||||
'← Neuere' => '← Newer',
|
||||
'Ältere →' => 'Older →',
|
||||
'Seite %d von %d' => 'Page %d of %d',
|
||||
'Externe Inhalte und Einwilligung' => 'External content and consent',
|
||||
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.',
|
||||
'Statistik' => 'Statistics',
|
||||
'Externe Inhalte' => 'External content',
|
||||
'Messung, wie die Website benutzt wird.' => 'Measuring how the website is used.',
|
||||
'Inhalte von anderen Anbietern, etwa Karten, Videos, Bewertungen oder ein Chat. Sie sehen die Adresse dieser Seite und deine IP-Adresse, sobald sie geladen werden.' => 'Content from other providers, such as maps, videos, reviews or a chat. They see the address of this page and your IP address as soon as they load.',
|
||||
// 404 und Einwilligung
|
||||
'Seite nicht gefunden' => 'Page not found',
|
||||
'Diese Seite ist nicht mehr verfügbar.' => 'This page is no longer available.',
|
||||
'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'The page you are looking for is not available. The home page will take you back to the right content.',
|
||||
'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'The address may be out of date or mistyped. The home page will take you back to the right content.',
|
||||
'Zur Startseite' => 'To the home page',
|
||||
],
|
||||
'fr' => [
|
||||
'Zum Inhalt springen' => 'Aller au contenu',
|
||||
'Menü' => 'Menu',
|
||||
'Start' => 'Accueil',
|
||||
'Datenschutz' => 'Confidentialité',
|
||||
'Impressum' => 'Mentions légales',
|
||||
'Hauptnavigation' => 'Navigation principale',
|
||||
'Nach oben' => 'Haut de page',
|
||||
'Weiterlesen' => 'Lire la suite',
|
||||
'Alle akzeptieren' => 'Tout accepter',
|
||||
'Nur notwendige' => 'Uniquement nécessaires',
|
||||
'Sprache' => 'Langue',
|
||||
],
|
||||
'tr' => [
|
||||
'Zum Inhalt springen' => 'İçeriğe geç',
|
||||
'Menü' => 'Menü',
|
||||
'Start' => 'Ana sayfa',
|
||||
'Datenschutz' => 'Gizlilik',
|
||||
'Impressum' => 'Künye',
|
||||
'Hauptnavigation' => 'Ana menü',
|
||||
'Nach oben' => 'Yukarı',
|
||||
'Weiterlesen' => 'Devamını oku',
|
||||
'Alle akzeptieren' => 'Tümünü kabul et',
|
||||
'Nur notwendige' => 'Yalnızca gerekli',
|
||||
'Sprache' => 'Dil',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(private RequestStack $requests)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('kant_og_locale', Languages::ogLocale(...)),
|
||||
];
|
||||
}
|
||||
|
||||
public function getFilters(): array
|
||||
{
|
||||
return [
|
||||
new TwigFilter('kant_t', $this->translate(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein fester Text in der Sprache der Seite. Ohne weitere Sprache - oder in der Hauptsprache -
|
||||
* bleibt er, wie er ist; ebenso jeder Text, der nicht in der Liste steht.
|
||||
*/
|
||||
public function translate(string $text): string
|
||||
{
|
||||
$locale = $this->requests->getCurrentRequest()?->attributes->get('kant_locale');
|
||||
if (!is_string($locale) || 'de' === $locale) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return self::UI[$locale][$text] ?? self::UI['en'][$text] ?? $text;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||
nicht mehr. #}
|
||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'button-design', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search', 'icons', 'translate'] %}
|
||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
<button type="button" data-viewport="tablet" aria-pressed="false" title="Tablet"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Tablet</span></button>
|
||||
<button type="button" data-viewport="mobile" aria-pressed="false" title="Mobil"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg><span>Mobil</span></button>
|
||||
</div>
|
||||
{# Der Sprachumschalter (ADR 111). Er erscheint nur, wenn die Website weitere Sprachen hat. #}
|
||||
<div id="language-switch" class="language-switch" role="group" aria-label="Sprache der Bearbeitung" hidden></div>
|
||||
<div class="history-actions">
|
||||
<button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></button>
|
||||
<button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></button>
|
||||
@@ -71,6 +73,7 @@
|
||||
<div class="tree-tools"><button id="expand-all" class="text-button" type="button">Alle öffnen</button><button id="collapse-all" class="text-button" type="button">Einklappen</button><button id="nest" class="text-button" type="button" title="In vorherigen Container verschachteln">⇥ Einrücken</button><button id="outdent" class="text-button" type="button" title="Eine Ebene herausheben">⇤ Ausrücken</button></div>
|
||||
<nav id="structure" aria-label="Blöcke auswählen"></nav>
|
||||
</section>
|
||||
<section class="sidebar-view" data-view="translate" id="translate-panel" aria-label="Übersetzung" hidden></section>
|
||||
<section class="sidebar-view" data-view="element" hidden>
|
||||
<div id="properties"><p class="muted">Inhalte werden geladen …</p></div>
|
||||
</section>
|
||||
@@ -90,7 +93,7 @@
|
||||
</main>
|
||||
<div id="context-menu" class="context-menu" role="menu" aria-label="Elementmenü" hidden></div>
|
||||
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
|
||||
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
|
||||
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><p id="publish-languages" hidden>Alle Sprachen dieser Seite gehen gemeinsam online – auch die Übersetzungen im gespeicherten Stand.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
|
||||
<dialog id="preset-dialog" aria-labelledby="preset-title"><h2 id="preset-title">Gestaltung als Vorlage speichern</h2><p>Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.</p><label for="preset-name">Name der Vorlage</label><input id="preset-name" maxlength="60" placeholder="Heller Abschnitt"><p id="preset-error" class="field-error" hidden></p><div class="dialog-actions"><button id="cancel-preset" class="secondary">Abbrechen</button><button id="confirm-preset" class="primary">Vorlage speichern</button></div></dialog>
|
||||
<dialog id="reload-dialog" aria-labelledby="reload-title"><h2 id="reload-title">Serverstand laden?</h2><p>Lokale Änderungen und dein Undo-Verlauf werden ersetzt. Sichere deine Änderungen vorher als JSON.</p><div class="dialog-actions"><button id="cancel-reload" class="secondary">Abbrechen</button><button id="confirm-reload" class="primary">Serverstand übernehmen</button></div></dialog>
|
||||
</div>
|
||||
|
||||
@@ -4,35 +4,41 @@
|
||||
Seite dessen Stylesheet, genau wie die Designseite. #}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Formulare{% endblock %}
|
||||
{% block heading %}Formulare{% endblock %}
|
||||
{% block lead %}Felder, Empfänger, Einwilligungstext und Aussehen werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
|
||||
{% block actions %}<a class="primary" href="#neues-formular">Neues Formular</a>{% endblock %}
|
||||
{% set editing = editing|default(false) %}
|
||||
{% block title %}{{ editing ? (current ? current.name : 'Neues Formular') ~ ' · ' : '' }}Formulare{% endblock %}
|
||||
{% block heading %}{{ editing ? (current ? current.name : 'Neues Formular') : 'Formulare' }}{% endblock %}
|
||||
{% block lead %}{% if editing %}<a class="text-button back-to-list" href="{{ path('admin_forms') }}">← Alle Formulare</a>{% else %}Felder, Empfänger und Aussehen deiner Formulare. Auf einer Seite setzt du sie mit dem Baustein „Formular“ ein.{% endif %}{% endblock %}
|
||||
{% block actions %}{% if not editing %}<a class="primary" href="{{ path('admin_form_edit', {slug: 'neu'}) }}">Neues Formular</a>{% endif %}{% endblock %}
|
||||
|
||||
{# Eine Feldkarte. Der Index steht in den Feldnamen; die Vorlage fuer neue Felder traegt an seiner
|
||||
Stelle den Platzhalter __n__, den das Skript beim Einfuegen ersetzt. #}
|
||||
{% macro field(f, n, row, types) %}
|
||||
{% macro field(f, n, row, types, languages = [], translations = {}) %}
|
||||
{% set id = 'f' ~ f ~ '-' ~ n %}
|
||||
<li class="field-card{{ row.label == '' ? ' field-card-new' : '' }}" data-field>
|
||||
{# Eine Zeile je Feld: Beschriftung, Typ, Pflicht. Alles Weitere steht in den Details, die das
|
||||
Skript zuklappt - ohne Skript ist die Karte offen und vollständig bedienbar. #}
|
||||
<div class="field-card-head">
|
||||
<span class="field-card-index" aria-hidden="true" data-field-index></span>
|
||||
<div class="field-card-title">
|
||||
<label for="{{ id }}-label">Beschriftung</label>
|
||||
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : '' }}" data-field-label>
|
||||
<label class="visually-hidden" for="{{ id }}-label">Beschriftung</label>
|
||||
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : 'Beschriftung' }}" data-field-label>
|
||||
</div>
|
||||
<div class="field-card-type">
|
||||
<label for="{{ id }}-type">Typ</label>
|
||||
<label class="visually-hidden" for="{{ id }}-type">Typ</label>
|
||||
<select id="{{ id }}-type" name="fields[{{ n }}][type]" data-field-type>
|
||||
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<label class="check-label field-card-required" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflicht</label>
|
||||
<button type="button" class="field-toggle" data-field-toggle hidden aria-expanded="true" aria-controls="{{ id }}-body">Details<svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></button>
|
||||
<button type="button" class="field-remove" data-remove-field hidden aria-label="Feld entfernen" title="Feld entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</div>
|
||||
<div class="field-card-body">
|
||||
<p class="field-card-meta" data-field-meta hidden></p>
|
||||
<div class="field-card-body" id="{{ id }}-body" data-field-body>
|
||||
<div>
|
||||
<label for="{{ id }}-name">Technischer Name</label>
|
||||
<input id="{{ id }}-name" name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" placeholder="vorname" data-field-name>
|
||||
<p class="field-help">Kleinbuchstaben, Ziffern, Unterstrich. Steht in der E-Mail vor der Antwort.</p>
|
||||
<p class="field-help">Steht in der E-Mail vor der Antwort.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ id }}-help">Hilfetext</label>
|
||||
@@ -46,12 +52,9 @@
|
||||
<label for="{{ id }}-images">Bild je Option</label>
|
||||
<textarea id="{{ id }}-images" name="fields[{{ n }}][images]" rows="2" data-field-images-input>{% for option, kennung in row.images|default({}) %}{{ option }} = {{ kennung }}
|
||||
{% endfor %}</textarea>
|
||||
<p class="field-help">Eine Zeile je Option: <code>Option = Kennung aus der Mediathek</code>. Sobald eine Option ein Bild hat, wird die Auswahl auf der Website zur Bildauswahl – anklickbare Karten statt Kästchen.</p>
|
||||
<p class="field-help">Mit Bild wird die Auswahl zu anklickbaren Karten. Ohne Skript: je Zeile <code>Option = Kennung</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-card-flags">
|
||||
<label class="check-label" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflichtfeld</label>
|
||||
</div>
|
||||
<div class="field-card-number" data-field-number>
|
||||
<div>
|
||||
<label for="{{ id }}-min">Untergrenze</label>
|
||||
@@ -75,25 +78,41 @@
|
||||
<label for="{{ id }}-showvalues">… einen dieser Werte hat</label>
|
||||
<input id="{{ id }}-showvalues" name="fields[{{ n }}][showIfValues]" maxlength="500" value="{{ row.showIf.values|default([])|join(', ') }}" placeholder="Wohnung, Haus" data-field-showvalues>
|
||||
</div>
|
||||
<p class="field-help">Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Bei einem Schritt gilt die Bedingung für alle seine Felder. Leer: das Feld gilt immer.</p>
|
||||
<p class="field-help">Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Leer: das Feld gilt immer.</p>
|
||||
</div>
|
||||
{# Je weitere Sprache, was Besucher lesen (ADR 111). Leer heißt: wie in der Hauptsprache. #}
|
||||
{% for sprache in languages %}
|
||||
{% set tr = translations[sprache.code].fields[row.name]|default({}) %}
|
||||
<details class="field-translation" data-field-translation="{{ sprache.code }}">
|
||||
<summary>{{ sprache.name }}{% if tr.label|default('') %} <span class="muted">· {{ tr.label }}</span>{% endif %}</summary>
|
||||
<div class="fields">
|
||||
<div><label for="{{ id }}-tr-{{ sprache.code }}-label">Beschriftung {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-label" name="fields[{{ n }}][tr_{{ sprache.code }}_label]" maxlength="120" lang="{{ sprache.code }}" value="{{ tr.label|default('') }}" placeholder="{{ row.label }}"></div>
|
||||
<div><label for="{{ id }}-tr-{{ sprache.code }}-help">Hilfetext {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-help" name="fields[{{ n }}][tr_{{ sprache.code }}_help]" maxlength="200" lang="{{ sprache.code }}" value="{{ tr.help|default('') }}" placeholder="{{ row.help|default('') }}"></div>
|
||||
<div><label for="{{ id }}-tr-{{ sprache.code }}-options">Optionen {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-options" name="fields[{{ n }}][tr_{{ sprache.code }}_options]" maxlength="500" lang="{{ sprache.code }}" value="{% for option in row.options|default([]) %}{{ tr.options[option]|default('') }}{{ loop.last ? '' : ', ' }}{% endfor %}" placeholder="{{ row.options|default([])|join(', ') }}"><p class="field-help">Nur bei Auswahlfeldern: in derselben Reihenfolge, durch Komma.</p></div>
|
||||
<div><label for="{{ id }}-tr-{{ sprache.code }}-unit">Einheit {{ sprache.name }}</label><input id="{{ id }}-tr-{{ sprache.code }}-unit" name="fields[{{ n }}][tr_{{ sprache.code }}_unit]" maxlength="12" lang="{{ sprache.code }}" value="{{ tr.unit|default('') }}" placeholder="{{ row.unit|default('') }}"></div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as karte %}
|
||||
{% if editing %}
|
||||
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}" data-form-images="{{ images|json_encode|e('html_attr') }}">
|
||||
{% for form in forms|merge([null]) %}
|
||||
{% for form in [current] %}
|
||||
{% set new = form is null %}
|
||||
{% set f = loop.index %}
|
||||
{% set rows = new ? [] : form.fields %}
|
||||
{% set settings = new ? {} : form.settings %}
|
||||
{% if new %}
|
||||
<details class="card form-new" id="neues-formular"{{ forms is empty ? ' open' : '' }}>
|
||||
<summary>Neues Formular anlegen</summary>
|
||||
<section class="card form-new" id="neues-formular">
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<section class="card form-card" id="formular-{{ form.slug }}">
|
||||
<div class="form-card-summary">
|
||||
<span class="form-card-facts">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }} · an {{ settings.recipient|default('–') }} · Adresse <code>{{ form.slug }}</code>{% for sprache in languages %}{% if settings.translations[sprache.code]|default(null) %} · {{ sprache.code|upper }}{% endif %}{% endfor %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def" data-form-editor>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('forms') }}">
|
||||
@@ -102,15 +121,11 @@
|
||||
{# Die Zusammenfassung des Details ist keine Ueberschrift; ohne diese springt die
|
||||
Gliederung von der Seitenueberschrift direkt zu den Abschnittstiteln. #}
|
||||
<h2 class="visually-hidden">Neues Formular</h2>
|
||||
{% else %}
|
||||
<div class="card-head">
|
||||
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
|
||||
<span class="muted">Im Editor über den Formularblock wählbar · Adresse <code>{{ form.slug }}</code></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body form-editor">
|
||||
<div class="form-editor-main">
|
||||
<h3 class="form-part">Zustellung</h3>
|
||||
<details class="form-section"{{ new ? ' open' : '' }}>
|
||||
<summary><h3 class="form-part">Zustellung</h3><span class="form-section-hint">{{ new ? '' : 'an ' ~ settings.recipient|default('') ~ ' · Betreff „' ~ settings.subject|default('') ~ '“' }}</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="name-{{ f }}">Name</label>
|
||||
@@ -130,7 +145,7 @@
|
||||
<div>
|
||||
<label for="sender-{{ f }}">Absender</label>
|
||||
<input id="sender-{{ f }}" name="sender" type="email" value="{{ settings.sender|default('') }}" placeholder="formular@beispiel.de">
|
||||
<p class="field-help">Leer: die Empfängeradresse. Viele Anbieter stellen nur zu, was von einer Adresse der eigenen Domain kommt.</p>
|
||||
<p class="field-help">Leer: die Empfängeradresse. Am besten eine Adresse der eigenen Domain.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ f }}">Betreff der E-Mail</label>
|
||||
@@ -147,22 +162,24 @@
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<label class="check-label" for="summary-{{ f }}"><input id="summary-{{ f }}" type="checkbox" name="summary" value="1"{{ settings.summary|default(false) ? ' checked' : '' }}> Zusammenfassung vor dem Absenden zeigen</label>
|
||||
<p class="field-help">Wirkt bei Formularen mit Schritten: der letzte Schritt zeigt alle Angaben mit „Ändern“. Ohne Skript im Browser steht das Formular als Ganzes auf der Seite.</p>
|
||||
<p class="field-help">Nur bei Formularen mit Schritten.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="form-fields-head">
|
||||
<h3 class="form-part">Felder</h3>
|
||||
<button type="button" class="secondary" data-add-field hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Feld hinzufügen</button>
|
||||
</div>
|
||||
<ol class="field-list" data-field-list>
|
||||
{% for row in rows %}{{ karte.field(f, loop.index0, row, types) }}{% endfor %}
|
||||
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}
|
||||
{% for row in rows %}{{ karte.field(f, loop.index0, row, types, languages, settings.translations|default({})) }}{% endfor %}
|
||||
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types, languages) }}
|
||||
</ol>
|
||||
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}</template>
|
||||
<p class="field-help" data-fields-help>Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Ein Feld vom Typ „Neuer Schritt“ eröffnet einen Abschnitt: seine Beschriftung ist der Titel, sein Hilfetext die Einleitung; alle Felder bis zum nächsten Schritt gehören dazu. Ein Feld oder ein Schritt kann von einer früheren Auswahl abhängen – dann verlangt der Server es nur, wenn die Bedingung zutrifft.</p>
|
||||
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types, languages) }}</template>
|
||||
<p class="field-help" data-fields-help>Hilfetext, Optionen, Bedingungen und Übersetzungen stehen unter „Details“. Ein „Neuer Schritt“ beginnt einen Abschnitt; ein Feld ohne Beschriftung wird nicht gespeichert.</p>
|
||||
|
||||
<h3 class="form-part">Aussehen</h3>
|
||||
<details class="form-section">
|
||||
<summary><h3 class="form-part">Aussehen</h3><span class="form-section-hint">Button „{{ settings.buttonLabel|default('Absenden') }}“</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<div class="fields form-look">
|
||||
<div>
|
||||
<label for="buttonLabel-{{ f }}">Beschriftung des Buttons</label>
|
||||
@@ -175,7 +192,19 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field-help">Leer heißt: wie die Website. Sonst eine Farbe aus der Palette oder ein Wert wie <code>#1a2b3c</code>. Form und Größe des Buttons kommen aus dem Design.</p>
|
||||
<p class="field-help">Leer heißt: wie die Website. Form und Größe des Buttons kommen aus dem Design.</p>
|
||||
</details>
|
||||
{% for sprache in languages %}
|
||||
{% set tr = settings.translations[sprache.code]|default({}) %}
|
||||
<details class="form-section">
|
||||
<summary><h3 class="form-part">{{ sprache.name }}</h3><span class="form-section-hint">{{ tr ? (tr.fields|default({})|length ~ ' von ' ~ rows|length ~ ' Feldern übersetzt') : 'noch nicht übersetzt' }}</span><svg class="form-chevron" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<p class="field-help">Button und Meldung auf {{ sprache.name }}; die Felder übersetzt du unter „Details“ am Feld. Die E-Mail an dich bleibt deutsch.</p>
|
||||
<div class="fields">
|
||||
<div class="field-wide"><label for="success-{{ f }}-{{ sprache.code }}">Meldung nach dem Absenden ({{ sprache.name }})</label><textarea id="success-{{ f }}-{{ sprache.code }}" name="success_{{ sprache.code }}" maxlength="300" rows="2" lang="{{ sprache.code }}" placeholder="{{ settings.success|default('') }}">{{ tr.success|default('') }}</textarea></div>
|
||||
<div><label for="buttonLabel-{{ f }}-{{ sprache.code }}">Beschriftung des Buttons ({{ sprache.name }})</label><input id="buttonLabel-{{ f }}-{{ sprache.code }}" name="buttonLabel_{{ sprache.code }}" maxlength="40" lang="{{ sprache.code }}" value="{{ tr.buttonLabel|default('') }}" placeholder="{{ settings.buttonLabel|default('Absenden') }}"></div>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Die Vorschau: dieselben Felder, dieselben Farben, der Standardbutton der Website.
|
||||
@@ -212,13 +241,27 @@
|
||||
{% if not new %}<span class="muted">Änderungen gelten sofort auf jeder Seite, die dieses Formular zeigt.</span>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% if new %}
|
||||
</details>
|
||||
{% else %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# Die Übersicht: eine Zeile je Formular, ohne Editor und ohne Vorschau. #}
|
||||
{% if forms is empty %}
|
||||
<div class="empty"><h2>Noch kein Formular.</h2><p>Lege ein Formular an und setze es mit dem Baustein „Formular“ auf eine Seite.</p><p><a class="primary" href="{{ path('admin_form_edit', {slug: 'neu'}) }}">Neues Formular</a></p></div>
|
||||
{% else %}
|
||||
<div class="rows form-list">
|
||||
{% for form in forms %}
|
||||
<article class="row">
|
||||
<div class="row-main">
|
||||
<h2 class="form-list-name"><a href="{{ path('admin_form_edit', {slug: form.slug}) }}">{{ form.name }}</a></h2>
|
||||
<p>{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }} · an {{ form.settings.recipient|default('–') }} · Adresse <code>{{ form.slug }}</code>{% for sprache in languages %}{% if form.settings.translations[sprache.code]|default(null) %} · {{ sprache.code|upper }} übersetzt{% endif %}{% endfor %}</p>
|
||||
</div>
|
||||
<span class="pill">{{ counts[form.id]|default(0) }} {{ counts[form.id]|default(0) == 1 ? 'Einsendung' : 'Einsendungen' }}</span>
|
||||
<a class="secondary" href="{{ path('admin_form_edit', {slug: form.slug}) }}" aria-label="{{ form.name }} bearbeiten">Bearbeiten</a>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
|
||||
{% if submissions.items is empty %}
|
||||
@@ -238,5 +281,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'admin/pager.html.twig' with {result: submissions, route: 'admin_forms', label: 'Seiten der Einsendungen'} only %}
|
||||
{% endif %}
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -2,10 +2,16 @@
|
||||
<input type="hidden" data-key="position" name="items[{{ n }}][position]" value="{{ n }}">
|
||||
<input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}">
|
||||
<input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}">
|
||||
{# Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr: ein Knopf in einem Knopf ist
|
||||
für Screenreader und Tastatur nicht bedienbar (axe: nested-interactive). #}
|
||||
<button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button>
|
||||
<details>
|
||||
<summary><button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<div class="menu-entry-fields">
|
||||
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
|
||||
{% for sprache in languages|default([]) %}
|
||||
<label for="menu-label_{{ sprache.code }}-{{ n }}">Beschriftung {{ sprache.name }}</label><input id="menu-label_{{ sprache.code }}-{{ n }}" data-key="label_{{ sprache.code }}" name="items[{{ n }}][label_{{ sprache.code }}]" maxlength="80" lang="{{ sprache.code }}"{{ sprache.rtl ? ' dir="rtl"' : '' }} value="{{ row.labels[sprache.code]|default('') }}" placeholder="Leer: wie „{{ row.label }}“">
|
||||
{% endfor %}
|
||||
{% if row.type == 'page' %}
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'menu-target-' ~ n, name: 'items[' ~ n ~ '][pageId]', label: 'Verknüpfter Inhalt', selected: row.page} %}
|
||||
<label for="menu-anchor-{{ n }}">Sprungziel (optional)</label><input id="menu-anchor-{{ n }}" data-key="anchor" name="items[{{ n }}][anchor]" maxlength="65" value="{{ row.anchor }}" placeholder="kontakt">
|
||||
@@ -14,6 +20,27 @@
|
||||
{% endif %}
|
||||
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
|
||||
<div class="menu-icon-fields"><div><label for="menu-icon-{{ n }}">Icon</label><select id="menu-icon-{{ n }}" data-key="icon" name="items[{{ n }}][icon]"><option value="">Kein Icon</option>{% for icon in menuIcons %}<option value="{{ icon }}"{{ row.icon|default('') == icon ? ' selected' : '' }}>{{ icon }}</option>{% endfor %}</select></div><div><label for="menu-iconPosition-{{ n }}">Icon-Position</label><select id="menu-iconPosition-{{ n }}" data-key="iconPosition" name="items[{{ n }}][iconPosition]"><option value="before"{{ row.iconPosition|default('before') == 'before' ? ' selected' : '' }}>Vor dem Text</option><option value="after"{{ row.iconPosition|default('before') == 'after' ? ' selected' : '' }}>Nach dem Text</option></select></div></div>
|
||||
{# Als Button (ADR 110). Ohne Skript sind es gewöhnliche Felder; mit Skript kommen
|
||||
Vorschau, Symbolfenster und Palettenfarben dazu. Leer heißt Website-Standard. #}
|
||||
{% set btn = row.button|default({}) %}
|
||||
<fieldset class="menu-button-fields" data-button-fields>
|
||||
<legend>Button</legend>
|
||||
<label class="check-label" for="menu-button-{{ n }}"><input id="menu-button-{{ n }}" type="checkbox" data-key="button" name="items[{{ n }}][button]" value="1"{{ row.button ? ' checked' : '' }}> Als Button darstellen</label>
|
||||
<input type="hidden" data-key="buttonId" name="items[{{ n }}][buttonId]" value="{{ btn.id|default('') }}">
|
||||
<div class="menu-button-options" data-button-options{{ row.button ? '' : ' hidden' }}>
|
||||
{% for key, caption in {shape: 'Form', appearance: 'Darstellung', size: 'Größe'} %}
|
||||
{% set field = 'button' ~ key|capitalize %}
|
||||
<div><label for="menu-{{ field }}-{{ n }}">{{ caption }}</label><select id="menu-{{ field }}-{{ n }}" data-key="{{ field }}" name="items[{{ n }}][{{ field }}]"><option value="">Website-Standard</option>{% for value, name in buttonSchema.buttonOptions[key] %}<option value="{{ value }}"{{ btn[key]|default('') == value ? ' selected' : '' }}>{{ name }}</option>{% endfor %}</select></div>
|
||||
{% endfor %}
|
||||
{% for field, caption in {buttonFill: 'Fläche', buttonInk: 'Schrift', buttonHoverFill: 'Fläche beim Überfahren', buttonHoverInk: 'Schrift beim Überfahren', buttonIconColor: 'Symbolfarbe'} %}
|
||||
{% set stored = {buttonFill: 'fill', buttonInk: 'ink', buttonHoverFill: 'hoverFill', buttonHoverInk: 'hoverInk', buttonIconColor: 'iconColor'}[field] %}
|
||||
<div data-color-field><label for="menu-{{ field }}-{{ n }}">{{ caption }}</label><input id="menu-{{ field }}-{{ n }}" data-key="{{ field }}" name="items[{{ n }}][{{ field }}]" value="{{ btn[stored]|default('') }}" placeholder="Website-Standard" pattern="(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|@[a-z][a-z0-9-]{0,31})" maxlength="33"></div>
|
||||
{% endfor %}
|
||||
<div><label for="menu-iconMotion-{{ n }}">Symbol beim Überfahren</label><select id="menu-iconMotion-{{ n }}" data-key="iconMotion" name="items[{{ n }}][iconMotion]">{% for value, name in {none: 'Ruhig', slide: 'Rückt', reveal: 'Erscheint'} %}<option value="{{ value }}"{{ btn.iconMotion|default('none') == value ? ' selected' : '' }}>{{ name }}</option>{% endfor %}</select></div>
|
||||
<label class="check-label" for="menu-iconOnly-{{ n }}"><input id="menu-iconOnly-{{ n }}" type="checkbox" data-key="iconOnly" name="items[{{ n }}][iconOnly]" value="1"{{ btn.iconOnly|default(false) ? ' checked' : '' }}> Nur das Symbol zeigen</label>
|
||||
<div><label for="menu-buttonMobile-{{ n }}">Auf dem Telefon</label><select id="menu-buttonMobile-{{ n }}" data-key="buttonMobile" name="items[{{ n }}][buttonMobile]">{% for value, name in buttonSchema.mobile %}<option value="{{ value }}"{{ btn.mobile|default('menu') == value ? ' selected' : '' }}>{{ name }}</option>{% endfor %}</select></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
{% block title %}Menüs{% endblock %}
|
||||
{% block heading %}Menüs{% endblock %}
|
||||
{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block styles %}<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/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'admin/menu-icons.html.twig' %}
|
||||
<nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav>
|
||||
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}">
|
||||
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form" data-icons="{{ menuIcons|json_encode|e('html_attr') }}" data-button-schema="{{ buttonSchema|json_encode|e('html_attr') }}" data-languages="{{ languages|json_encode|e('html_attr') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
|
||||
<aside class="menu-add" aria-label="Menüeinträge hinzufügen">
|
||||
<section class="card card-body"><h2>Seiten und Beiträge</h2>
|
||||
|
||||
@@ -52,6 +52,53 @@
|
||||
{% else %}<p class="field-help">Die Zuordnung kann von einem Benutzer mit Veröffentlichungsrechten geändert werden.</p>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack-section" id="sprachen" aria-labelledby="sprachen-title">
|
||||
<div class="section-head"><h2 id="sprachen-title">Sprachen</h2><span class="muted">Hauptsprache: {{ mainLanguage.name }}</span></div>
|
||||
<div class="card card-body">
|
||||
<p>Jede weitere Sprache übersetzt dieselben Seiten: Aufbau und Gestaltung bleiben, je Sprache kommen nur die Texte und eine eigene Adresse dazu – zum Beispiel <code>/en/services</code>. Übersetzt wird im Editor über den Sprachumschalter. Eine Seite erscheint in einer Sprache erst, wenn ihre Übersetzung veröffentlicht ist.</p>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<form action="{{ path('admin_languages_save') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<fieldset class="check-grid">
|
||||
<legend>Weitere Sprachen</legend>
|
||||
{% for sprache in languageCatalog %}
|
||||
<label class="check-label"><input type="checkbox" name="languages[]" value="{{ sprache.code }}"{{ sprache.code in extraLanguages ? ' checked' : '' }}> {{ sprache.name }} <span class="muted" lang="{{ sprache.code }}">{{ sprache.native }}</span></label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<p class="field-help">Ohne Häkchen bleibt die Website einsprachig, und nirgends erscheint etwas davon. Wird eine Sprache wieder abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben gespeichert. Damit Besucher die Sprache wechseln können, setze den Baustein „Sprachwahl“ in den Kopfbereich.</p>
|
||||
<button class="primary" type="submit">Sprachen speichern</button>
|
||||
</form>
|
||||
{% if extraLanguages %}
|
||||
<form action="{{ path('admin_language_texts_save') }}" method="post" class="language-texts">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<h3>Texte in den weiteren Sprachen</h3>
|
||||
<p class="field-help">Seiten, Kopf und Fuß übersetzt du im Editor, Menüs unter Menüs, Formulare unter Formulare. Hier stehen die Texte, die zur Website als Ganzes gehören. Leer heißt: wie in {{ mainLanguage.name }}.</p>
|
||||
{% for code in extraLanguages %}
|
||||
{% set name = languageCatalog|filter(s => s.code == code)|first.name %}
|
||||
<fieldset class="language-text-group">
|
||||
<legend>{{ name }}</legend>
|
||||
{% if settings.consentBanner %}
|
||||
<label for="consent-title-{{ code }}">Überschrift des Einwilligungshinweises</label>
|
||||
<input id="consent-title-{{ code }}" name="consentTexts[{{ code }}][title]" maxlength="80" lang="{{ code }}" value="{{ settings.consentTexts[code].title|default('') }}" placeholder="{{ settings.consentTitle ?: 'Externe Inhalte und Einwilligung' }}">
|
||||
<label for="consent-text-{{ code }}">Text des Einwilligungshinweises</label>
|
||||
<textarea id="consent-text-{{ code }}" name="consentTexts[{{ code }}][text]" maxlength="600" rows="3" lang="{{ code }}" placeholder="{{ settings.consentText }}">{{ settings.consentTexts[code].text|default('') }}</textarea>
|
||||
{% endif %}
|
||||
{% for term in terms %}
|
||||
{% set key = term.kind ~ ':' ~ term.slug %}
|
||||
<label for="term-{{ code }}-{{ term.id }}">{{ term.kind == 'category' ? 'Kategorie' : 'Schlagwort' }} „{{ term.name }}“</label>
|
||||
<input id="term-{{ code }}-{{ term.id }}" name="termNames[{{ code }}][{{ key }}]" maxlength="120" lang="{{ code }}" value="{{ settings.termNames[code][key]|default('') }}" placeholder="{{ term.name }}">
|
||||
{% endfor %}
|
||||
{% if not settings.consentBanner and terms is empty %}<p class="field-help">Ohne Einwilligungshinweis und ohne Kategorien gibt es hier nichts zu übersetzen.</p>{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
<button class="primary" type="submit">Übersetzungen speichern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="field-help">{{ extraLanguages ? 'Weitere Sprachen: ' ~ extraLanguages|map(c => c|upper)|join(', ') ~ '.' : 'Die Website ist einsprachig.' }} Ändern kann das ein Administrator.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack-section">
|
||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
|
||||
@@ -26,7 +26,17 @@
|
||||
{% 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 class="button-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-up-right'} only %}</span></a>
|
||||
{# Schema v25: Symbol wählbar. Ohne Angabe der Pfeil wie bisher; er behält die Klasse
|
||||
button-arrow, damit eigenes CSS älterer Websites (ausblenden, drehen) weiter greift. #}
|
||||
{% set buttonIcon = props.icon|default('arrow-up-right') %}
|
||||
{% set iconMarkup = '' %}
|
||||
{% if buttonIcon != 'none' %}{% set iconMarkup %}<span class="button-icon{{ buttonIcon == 'arrow-up-right' ? ' button-arrow' }}" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: buttonIcon} only %}</span>{% endset %}{% endif %}
|
||||
{% set iconFirst = props.iconPosition|default('end') == 'start' %}
|
||||
{# Schema v26: Bewegung beim Überfahren und ein Button nur aus Symbol. Der Text steht dann
|
||||
in einer Beschriftung ohne Breite und bleibt der Name des Links für Vorlesesoftware. #}
|
||||
{% set iconOnly = iconMarkup and props.iconOnly|default(false) %}
|
||||
{% if iconMarkup %}{% set designClasses = designClasses ~ (iconFirst ? ' icon-start' : '') ~ (props.iconMotion|default('none') != 'none' ? ' icon-motion-' ~ props.iconMotion : '') ~ (iconOnly ? ' icon-only' : '') %}{% endif %}
|
||||
<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 %}>{% if iconMarkup and iconFirst %}{{ iconMarkup }} {% endif %}{% if iconOnly %}<span class="button-label">{{ props.text }}</span>{% else %}{{ props.text }}{% endif %}{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</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
|
||||
@@ -48,7 +58,7 @@
|
||||
{%- if klick == 'vergroessern' -%}
|
||||
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der
|
||||
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
|
||||
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild') }} vergrößern">
|
||||
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild'|kant_t) }} {{ 'vergrößern'|kant_t }}">
|
||||
{%- elseif imageLinked -%}
|
||||
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
@@ -102,12 +112,16 @@
|
||||
{% set menuLayout = props.layout|default('horizontal') %}
|
||||
{% set collapse = menuLayout == 'horizontal' ? props.collapse|default('tablet') : 'never' %}
|
||||
<nav id="{{ block.id }}" class="block-menu menu-{{ menuLayout }} menu-collapse-{{ collapse }}{{ designClasses }}"
|
||||
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}">
|
||||
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation')|kant_t }}">
|
||||
{% if menuLayout == 'burger' or collapse != 'never' %}
|
||||
{# Buttons, die auf dem Telefon neben dem Menü-Knopf stehen bleiben (ADR 110). Sichtbar nur,
|
||||
solange das Menü zusammengeklappt ist; im aufgeklappten Menü fehlen sie dann. #}
|
||||
{% set barLinks = links|filter(link => link.button is defined and link.button.mobile|default('menu') in ['bar', 'bar-icon']) %}
|
||||
{% if barLinks is not empty %}<ul class="menu-actions">{% for link in barLinks %}<li>{% include 'menu-button.html.twig' with {link: link, forceIconOnly: link.button.mobile == 'bar-icon'} only %}</li>{% endfor %}</ul>{% endif %}
|
||||
<details class="menu-burger">
|
||||
{# Nur Symbol behält den Namen für Vorlesesoftware; sichtbar ist dann nur der Strich. #}
|
||||
{% set burger = props.burgerLabel|default('both') %}
|
||||
<summary>{% if burger != 'text' %}<span class="burger-lines" aria-hidden="true"></span>{% endif %}<span class="burger-text{{ burger == 'icon' ? ' visually-hidden' : '' }}">Menü</span></summary>
|
||||
<summary>{% if burger != 'text' %}<span class="burger-lines" aria-hidden="true"></span>{% endif %}<span class="burger-text{{ burger == 'icon' ? ' visually-hidden' : '' }}">{{ 'Menü'|kant_t }}</span></summary>
|
||||
<div class="menu-burger-body">{% include 'nav.html.twig' with {links: links} only %}</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
@@ -133,9 +147,9 @@
|
||||
{% endif %}
|
||||
{% elseif type == 'breadcrumb' %}
|
||||
{% if breadcrumbs|default([])|length > 1 %}
|
||||
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="Brotkrumen">
|
||||
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="{{ 'Brotkrumen'|kant_t }}">
|
||||
<ol>
|
||||
<li><a href="/">Start</a></li>
|
||||
<li><a href="/">{{ 'Start'|kant_t }}</a></li>
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li>{% if loop.last %}<span aria-current="page">{{ crumb.name }}</span>{% else %}<a href="{{ crumb.href }}">{{ crumb.name }}</a>{% endif %}</li>
|
||||
{% endfor %}
|
||||
@@ -194,7 +208,7 @@
|
||||
{% if profiles[key]|default('') and (chosen is empty or key in chosen) %}{% set shown = shown|merge([{key: key, name: name, url: profiles[key]}]) %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if shown is not empty %}
|
||||
<ul id="{{ block.id }}" class="block-social social-{{ props.layout|default('horizontal') }} social-shape-{{ props.shape|default('plain') }} social-size-{{ props.size|default(24) }}{{ props.labels|default(false) ? ' social-labels' : '' }}{{ designClasses }}" aria-label="Soziale Profile">
|
||||
<ul id="{{ block.id }}" class="block-social social-{{ props.layout|default('horizontal') }} social-shape-{{ props.shape|default('plain') }} social-size-{{ props.size|default(24) }}{{ props.labels|default(false) ? ' social-labels' : '' }}{{ designClasses }}" aria-label="{{ 'Soziale Profile'|kant_t }}">
|
||||
{% for profile in shown %}
|
||||
<li><a href="{{ profile.url }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab is defined ? props.newTab : true} only %}>{% include 'blocks/social-icon.html.twig' with {network: profile.key} only %}<span{{ props.labels|default(false) ? '' : ' class="visually-hidden"' }}>{{ profile.name }}</span></a></li>
|
||||
{% endfor %}
|
||||
@@ -219,7 +233,7 @@
|
||||
browser that ignores snapping. No autoplay - a moving carousel is something people ask to
|
||||
stop, not something a page should start on its own. #}
|
||||
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}{{ props.dots ?? false ? ' slider-dots' : '' }}"{% if props.autoplay|default(0) >= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %}
|
||||
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
|
||||
tabindex="0" role="group" aria-label="{{ 'Zum Blättern seitwärts scrollen'|kant_t }}">
|
||||
{% for child in block.children|default([]) %}
|
||||
<div class="slider-slide visibility-{{ child.layout.visibility|default('all') }}">{% 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 %}</div>
|
||||
{% endfor %}
|
||||
@@ -241,7 +255,7 @@
|
||||
<div id="{{ block.id }}" class="block-embed embed-blocked{{ designClasses }}">
|
||||
<p class="embed-title">{{ props.title }}</p>
|
||||
<p class="embed-hint">{{ props.hint ?: 'Dieser Inhalt wird von einem anderen Anbieter geladen. Dabei erfährt er deine IP-Adresse und welche Seite du ansiehst.' }}</p>
|
||||
<p class="embed-provider">Anbieter: <strong>{{ props.provider ?: props.url|split('/')[2] }}</strong></p>
|
||||
<p class="embed-provider">{{ 'Anbieter:'|kant_t }} <strong>{{ props.provider ?: props.url|split('/')[2] }}</strong></p>
|
||||
{% if editing|default(false) %}
|
||||
<p class="embed-hint">Wird erst auf der veröffentlichten Seite geladen, und dort erst nach Einwilligung.</p>
|
||||
{% else %}
|
||||
@@ -251,7 +265,7 @@
|
||||
{# The groups already granted stay granted; this button adds this one. #}
|
||||
{% for granted in consentGate.state(app.request).groups %}{% if granted.granted %}<input type="hidden" name="groups[]" value="{{ granted.key }}">{% endif %}{% endfor %}
|
||||
<input type="hidden" name="groups[]" value="{{ props.group }}">
|
||||
<button class="consent-button" type="submit">Inhalt laden und einwilligen</button>
|
||||
<button class="consent-button" type="submit">{{ 'Inhalt laden und einwilligen'|kant_t }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -280,12 +294,24 @@
|
||||
erbt statt als heller Streifen unter einem dunklen Fuß zu kleben. #}
|
||||
{% set rechtliches = consentGate.legalLinks() %}
|
||||
{% set widerruflich = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
<nav id="{{ block.id }}" class="block-legal{{ designClasses }}" aria-label="{{ props.label|default('Rechtliches') }}">
|
||||
{% for path, label in rechtliches %}{% if not loop.first %}<span aria-hidden="true"> · </span>{% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if widerruflich %}{% if rechtliches is not empty %}<span aria-hidden="true"> · </span>{% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
<nav id="{{ block.id }}" class="block-legal{{ designClasses }}" aria-label="{{ props.label|default('Rechtliches'|kant_t) }}">
|
||||
{% for path, label in rechtliches %}{% if not loop.first %}<span aria-hidden="true"> · </span>{% endif %}<a href="/{{ path }}">{{ label|kant_t }}</a>{% endfor %}
|
||||
{% if widerruflich %}{% if rechtliches is not empty %}<span aria-hidden="true"> · </span>{% endif %}<a href="/einwilligung">{{ 'Einwilligung ändern'|kant_t }}</a>{% endif %}
|
||||
</nav>
|
||||
{% elseif type == 'language' %}
|
||||
{# Die Sprachwahl (ADR 111): dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist.
|
||||
Gibt es die Seite nur in einer Sprache, steht hier nichts - eine Wahl mit einer Möglichkeit
|
||||
ist keine. hreflang hält die Links aus der Umstellung auf übersetzte Adressen heraus. #}
|
||||
{% set sprachen = app.request.attributes.get('kant_alternates')|default([]) %}
|
||||
{% if editing|default(false) and sprachen|length < 2 %}{% set sprachen = [{locale: 'de', native: 'Deutsch', href: '#', current: true}, {locale: 'en', native: 'English', href: '#', current: false}] %}{% endif %}
|
||||
{% if sprachen|length > 1 %}
|
||||
<nav id="{{ block.id }}" class="block-language{{ designClasses }}" aria-label="{{ 'Sprache'|kant_t }}">
|
||||
<ul>{% for sprache in sprachen %}<li><a href="{{ sprache.href }}" hreflang="{{ sprache.locale }}" lang="{{ sprache.locale }}"{% if sprache.current %} aria-current="page"{% endif %}>{% if props.display|default('code') == 'name' %}{{ sprache.native }}{% else %}{{ sprache.locale|upper }}<span class="visually-hidden"> {{ sprache.native }}</span>{% endif %}</a></li>{% endfor %}</ul>
|
||||
</nav>
|
||||
{% elseif editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Sprachwahl</p>
|
||||
{% endif %}
|
||||
{% 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 %}>
|
||||
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="{{ 'Bildergalerie'|kant_t }}"{% if props.lightbox|default(false) %} data-zoom-group{% endif %}>
|
||||
{% 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) %}
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
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>
|
||||
<p class="form-missing">{{ 'Dieses Formular ist nicht mehr vorhanden.'|kant_t }}</p>
|
||||
{% elseif sent %}
|
||||
<p class="form-success" role="status">{{ form.settings.success }}</p>
|
||||
<p class="form-success" role="status">{{ form.settings.success|kant_t }}</p>
|
||||
{% else %}
|
||||
{% 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 %}>
|
||||
<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 %}{% if form.locale|default(null) %} data-texts="{{ {summary: 'Zusammenfassung'|kant_t, summaryIntro: 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'|kant_t, steps: 'Schritte des Formulars'|kant_t, step: 'Schritt'|kant_t, back: 'Zurück'|kant_t, next: 'Weiter'|kant_t, change: 'Ändern'|kant_t, yes: 'Ja'|kant_t, chooseOne: 'Bitte wähle mindestens eine Option.'|kant_t}|json_encode|e('html_attr') }}"{% endif %}>
|
||||
<input type="hidden" name="page" value="{{ page.path }}">
|
||||
{% if form.locale|default(null) %}<input type="hidden" name="language" value="{{ form.locale }}">{% endif %}
|
||||
<input type="hidden" name="block" value="{{ blockId }}">
|
||||
<input type="hidden" name="issued" value="{{ issued }}">
|
||||
<input type="hidden" name="issuedSignature" value="{{ issuedSignature }}">
|
||||
@@ -17,7 +18,7 @@
|
||||
<div class="form-trap" aria-hidden="true"><label for="website-{{ blockId }}">Website</label><input id="website-{{ blockId }}" name="website" type="text" tabindex="-1" autocomplete="off"></div>
|
||||
|
||||
{% if errors is not empty %}
|
||||
<p class="form-error-summary" role="alert">Bitte prüfe {{ errors|length }} {{ errors|length == 1 ? 'Eingabe' : 'Eingaben' }}. Die betroffenen Felder sind unten gekennzeichnet.</p>
|
||||
<p class="form-error-summary" role="alert">{{ (errors|length == 1 ? 'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' : 'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.')|kant_t|format(errors|length) }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% for field in form.fields %}
|
||||
@@ -26,7 +27,7 @@
|
||||
{% set value = values[field.name]|default('') %}
|
||||
{% set described = (field.help ? id ~ '-help ' : '') ~ (error ? id ~ '-error' : '') %}
|
||||
{% 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 %}
|
||||
{% set requiredMark %}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> ({{ 'Pflichtfeld'|kant_t }})</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 }}>
|
||||
@@ -58,7 +59,7 @@
|
||||
{% 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>
|
||||
<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">{{ field.optionLabels[option]|default(option) }}</span>{% else %}{{ field.optionLabels[option]|default(option) }}{% endif %}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if bildauswahl %}</div>{% endif %}
|
||||
@@ -67,15 +68,15 @@
|
||||
</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>
|
||||
<label for="{{ id }}">{{ field.label }}{{ requiredMark }}{% if field.unit|default('') %}<span class="visually-hidden"> {{ 'in'|kant_t }} {{ 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>
|
||||
{% elseif field.type == 'select' %}
|
||||
<select id="{{ id }}" name="{{ field.name }}"{{ field.required ? ' required' : '' }}
|
||||
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
||||
<option value="">— bitte wählen —</option>
|
||||
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ option }}</option>{% endfor %}
|
||||
<option value="">— {{ 'bitte wählen'|kant_t }} —</option>
|
||||
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ field.optionLabels[option]|default(option) }}</option>{% endfor %}
|
||||
</select>
|
||||
{% elseif field.type == 'number' %}
|
||||
<div class="form-number{{ field.unit|default('') ? ' has-unit' : '' }}">
|
||||
@@ -101,10 +102,10 @@
|
||||
{% 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. #}
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstileKey }}" data-language="de"></div>
|
||||
<noscript><p class="field-error">Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.</p></noscript>
|
||||
<div class="cf-turnstile" data-sitekey="{{ turnstileKey }}" data-language="{{ form.locale|default('de') }}"></div>
|
||||
<noscript><p class="field-error">{{ 'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.'|kant_t }}</p></noscript>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
<button class="button cms-button" type="submit">{{ form.settings.buttonLabel|default('Absenden') }}</button>
|
||||
<button class="button cms-button" type="submit">{{ form.settings.buttonLabel|default('Absenden')|kant_t }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -1,24 +1,102 @@
|
||||
{# Inline SVG from a fixed allowlist. No icon font, no external request, no extra stylesheet. #}
|
||||
{% set paths = {
|
||||
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||
check: 'm4 12.5 5.5 5.5L20 6',
|
||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9'
|
||||
'arrow-up': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
|
||||
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
|
||||
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
|
||||
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
|
||||
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
|
||||
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
|
||||
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||
close: 'M6 6l12 12M18 6 6 18',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
minus: 'M5 12h14',
|
||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
|
||||
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
|
||||
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
|
||||
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
|
||||
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
|
||||
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
|
||||
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
|
||||
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
|
||||
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
|
||||
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
|
||||
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
|
||||
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
|
||||
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
|
||||
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
|
||||
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
|
||||
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
|
||||
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
|
||||
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
|
||||
play: 'M8 5.5v13l10-6.5Z',
|
||||
pause: 'M8 5v14m8-14v14',
|
||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
|
||||
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
|
||||
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
|
||||
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
|
||||
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
|
||||
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
|
||||
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
|
||||
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
|
||||
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
|
||||
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
|
||||
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
|
||||
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
|
||||
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
|
||||
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
|
||||
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
|
||||
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
|
||||
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
|
||||
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
|
||||
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
|
||||
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
|
||||
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
|
||||
check: 'm4 12.5 5.5 5.5L20 6',
|
||||
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
|
||||
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
|
||||
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
|
||||
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
|
||||
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
|
||||
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
|
||||
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
|
||||
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
|
||||
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
|
||||
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
|
||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z'
|
||||
} %}
|
||||
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
||||
@@ -14,7 +14,7 @@
|
||||
srcset="{% for source in post.image.variants %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, 380px">
|
||||
{# Der Hinweis liegt über dem Bild und erscheint beim Zeigen. Für Vorlesesoftware ist
|
||||
er nichts Neues - der Titel darunter ist derselbe Verweis -, deshalb ausgeblendet. #}
|
||||
<span class="post-view" aria-hidden="true">Ansehen</span>
|
||||
<span class="post-view" aria-hidden="true">{{ 'Ansehen'|kant_t }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="post-body">
|
||||
@@ -22,14 +22,14 @@
|
||||
{% if (showMeta ?? true) and (post.publishedAt or post.author) %}
|
||||
<p class="post-meta">
|
||||
{% if post.publishedAt %}<time datetime="{{ post.publishedAt|date('Y-m-d') }}">{{ post.publishedAt|date('d.m.Y') }}</time>{% endif %}
|
||||
{% if post.author %}<span>von {{ post.author }}</span>{% endif %}
|
||||
{% if post.author %}<span>{{ 'von'|kant_t }} {{ post.author }}</span>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if (showExcerpt ?? true) and post.excerpt %}<p class="post-excerpt">{{ post.excerpt }}</p>{% endif %}
|
||||
{# Ein Element, das aussieht wie ein Knopf, muss eines sein. Vorher stand hier eine
|
||||
reine Beschriftung: sichtbar, aber nicht anklickbar. Der Titel steckt für
|
||||
Vorlesesoftware mit drin, damit "Weiterlesen" nicht zwölfmal gleich heißt. #}
|
||||
{% if art == 'wechsel' %}<a class="post-more" href="{{ post.href }}">Weiterlesen<span class="visually-hidden">: {{ post.title }}</span></a>{% endif %}
|
||||
{% if art == 'wechsel' %}<a class="post-more" href="{{ post.href }}">{{ 'Weiterlesen'|kant_t }}<span class="visually-hidden">: {{ post.title }}</span></a>{% endif %}
|
||||
{% if (showTerms ?? true) and post.terms.category is not empty %}
|
||||
<p class="post-terms">{% for term in post.terms.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}</p>
|
||||
{% endif %}
|
||||
@@ -40,9 +40,9 @@
|
||||
{% endif %}
|
||||
{% if pages|default(1) > 1 %}
|
||||
{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #}
|
||||
<nav class="post-pager" aria-label="Weitere Beiträge">
|
||||
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">← Neuere</a>{% endif %}
|
||||
<span>Seite {{ page }} von {{ pages }}{% if total %} · {{ total }} Beiträge{% endif %}</span>
|
||||
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">Ältere →</a>{% endif %}
|
||||
<nav class="post-pager" aria-label="{{ 'Weitere Beiträge'|kant_t }}">
|
||||
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ '← Neuere'|kant_t }}</a>{% endif %}
|
||||
<span>{{ 'Seite %d von %d'|kant_t|format(page, pages) }}{% if total %} · {{ total }} {{ 'Beiträge'|kant_t }}{% endif %}</span>
|
||||
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere →'|kant_t }}</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -1,16 +1,16 @@
|
||||
{# Weiter zum nächsten Beitrag. Die Beiträge kommen vom Renderer, nie aus gespeichertem Inhalt.
|
||||
Fehlt ein Nachbar, bleibt seine Seite leer statt im Kreis zu führen. #}
|
||||
{% if previous or next %}
|
||||
<nav class="post-nav" aria-label="Weitere Beiträge">
|
||||
<nav class="post-nav" aria-label="{{ 'Weitere Beiträge'|kant_t }}">
|
||||
{% if previous %}
|
||||
<a class="post-nav-link post-nav-previous" href="{{ previous.href }}">
|
||||
<span class="post-nav-label"><span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-left'} only %}</span> {{ previousLabel|default('Vorheriger Beitrag') }}</span>
|
||||
<span class="post-nav-label"><span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-left'} only %}</span> {{ previousLabel|default('Vorheriger Beitrag'|kant_t) }}</span>
|
||||
<span class="post-nav-title">{{ previous.title }}</span>
|
||||
</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
{% if next %}
|
||||
<a class="post-nav-link post-nav-next" href="{{ next.href }}">
|
||||
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag') }} <span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-right'} only %}</span></span>
|
||||
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag'|kant_t) }} <span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-right'} only %}</span></span>
|
||||
<span class="post-nav-title">{{ next.title }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -9,36 +9,36 @@
|
||||
Klasse, der Rest als Variablen im Kopf der Seite (Consent::styles). #}
|
||||
<section class="consent-bar consent-layout-{{ consent.layout|default('karte') }}" role="region" aria-labelledby="consent-heading">
|
||||
<div class="consent-body">
|
||||
<h2 id="consent-heading"><svg class="consent-icon" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5"/></svg><span>{{ consent.title|default('') ?: 'Externe Inhalte und Einwilligung' }}</span></h2>
|
||||
<p>{{ consent.text ?: 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' }}</p>
|
||||
<h2 id="consent-heading"><svg class="consent-icon" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5"/></svg><span>{{ consent.title|default('') ?: 'Externe Inhalte und Einwilligung'|kant_t }}</span></h2>
|
||||
<p>{{ consent.text ?: 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.'|kant_t }}</p>
|
||||
</div>
|
||||
<form method="post" action="{{ path('consent_save') }}" class="consent-form">
|
||||
<input type="hidden" name="back" value="{{ back }}">
|
||||
<div class="consent-actions">
|
||||
<button class="consent-button consent-accept" type="submit" name="choice" value="all">Alle akzeptieren</button>
|
||||
<button class="consent-button consent-decline" type="submit" name="choice" value="none">Nur notwendige</button>
|
||||
<button class="consent-button consent-accept" type="submit" name="choice" value="all">{{ 'Alle akzeptieren'|kant_t }}</button>
|
||||
<button class="consent-button consent-decline" type="submit" name="choice" value="none">{{ 'Nur notwendige'|kant_t }}</button>
|
||||
</div>
|
||||
{# Eine Zeile: links die Einzelwahl, rechts die Rechtslinks. Aufgeklappt nimmt die
|
||||
Einzelwahl die ganze Breite, die Links rutschen darunter. #}
|
||||
<div class="consent-foot">
|
||||
<details class="consent-details">
|
||||
<summary>Einzeln wählen</summary>
|
||||
<summary>{{ 'Einzeln wählen'|kant_t }}</summary>
|
||||
<ul class="consent-groups">
|
||||
{% for group in consent.groups %}
|
||||
<li>
|
||||
<label for="consent-{{ group.key }}">
|
||||
<input id="consent-{{ group.key }}" type="checkbox" name="groups[]" value="{{ group.key }}"{{ group.granted ? ' checked' : '' }}>
|
||||
<span><strong>{{ group.name }}</strong> {{ group.help }}</span>
|
||||
<span><strong>{{ group.name|kant_t }}</strong> {{ group.help|kant_t }}</span>
|
||||
</label>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button class="consent-button consent-choice" type="submit" name="choice" value="some">Auswahl speichern</button>
|
||||
<button class="consent-button consent-choice" type="submit" name="choice" value="some">{{ 'Auswahl speichern'|kant_t }}</button>
|
||||
</details>
|
||||
{% if consent.privacyPath or consent.imprintPath %}
|
||||
<p class="consent-links">
|
||||
{% if consent.privacyPath %}<a href="/{{ consent.privacyPath }}">Datenschutz</a>{% endif %}
|
||||
{% if consent.imprintPath %}<a href="/{{ consent.imprintPath }}">Impressum</a>{% endif %}
|
||||
{% if consent.privacyPath %}<a href="/{{ consent.privacyPath }}">{{ 'Datenschutz'|kant_t }}</a>{% endif %}
|
||||
{% if consent.imprintPath %}<a href="/{{ consent.imprintPath }}">{{ 'Impressum'|kant_t }}</a>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{# Ein Menüeintrag als Button (ADR 110): dieselben Klassen wie der Button-Baustein, also dieselben
|
||||
Website-Standards, Varianten, Symbole und Bewegungen. Eigene Farben stehen als Regel für
|
||||
.menu-button-<id> im Stylesheet der Seite. Die Beschriftung bleibt auch bei "nur Symbol" im
|
||||
Link - als zugänglicher Name. #}
|
||||
{% set b = link.button %}
|
||||
{% set iconFirst = link.iconPosition|default('before') == 'before' %}
|
||||
{% set hasIcon = link.icon|default('') != '' %}
|
||||
{# forceIconOnly: neben dem Menü-Knopf auf dem Telefon, wenn dort nur das Symbol stehen soll. #}
|
||||
{% set iconOnly = hasIcon and (b.iconOnly|default(false) or forceIconOnly|default(false)) %}
|
||||
{% set classes = 'button cms-button menu-button menu-button-' ~ b.id %}
|
||||
{% for key in ['shape', 'appearance', 'size'] %}{% if b[key] is defined %}{% set classes = classes ~ ' b-desktop-' ~ key ~ '-' ~ b[key] %}{% endif %}{% endfor %}
|
||||
{% if hasIcon %}{% set classes = classes ~ (iconFirst ? ' icon-start' : '') ~ (b.iconMotion|default('none') != 'none' ? ' icon-motion-' ~ b.iconMotion : '') ~ (iconOnly ? ' icon-only' : '') %}{% endif %}
|
||||
{% set iconMarkup = '' %}
|
||||
{% if hasIcon %}{% set iconMarkup %}<span class="button-icon" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: link.icon} only %}</span>{% endset %}{% endif %}
|
||||
<a class="{{ classes }}" href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{% if iconMarkup and iconFirst %}{{ iconMarkup }} {% endif %}<span class="button-label">{{ link.label }}</span>{% if iconMarkup and not iconFirst %} {{ iconMarkup }}{% endif %}</a>
|
||||
@@ -1,8 +1,10 @@
|
||||
{# One level of a resolved menu. Dropdowns use details/summary so submenus work without JavaScript. #}
|
||||
<ul class="menu-list menu-level-{{ level|default(1) }}">
|
||||
{% for link in links %}
|
||||
<li>
|
||||
{% if link.children is not empty %}
|
||||
<li{% if link.button is defined %} class="menu-item-button{{ link.button.mobile|default('menu') in ['bar', 'bar-icon'] ? ' menu-keep-visible' }}"{% endif %}>
|
||||
{% if link.button is defined %}
|
||||
{% include 'menu-button.html.twig' with {link: link} only %}
|
||||
{% elseif link.children is not empty %}
|
||||
<details class="menu-branch">
|
||||
<summary>{% include 'nav-label.html.twig' with {link: link} only %}</summary>
|
||||
<div class="menu-branch-body">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!doctype html>
|
||||
{# Ohne Klasse spielt die Website Bewegung immer ab; mit ihr gilt die Browservorgabe. #}
|
||||
<html lang="{{ page.locale }}"{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
|
||||
<html lang="{{ page.locale }}"{{ page.locale in ['ar'] ? ' dir="rtl"' : '' }}{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -9,6 +9,11 @@
|
||||
<title>{{ seoTitle }}</title>
|
||||
<meta name="description" content="{{ page.document.description }}">
|
||||
{% if canonical|default(null) %}<link rel="canonical" href="{{ canonical }}">{% endif %}
|
||||
{# Dieselbe Seite in den anderen Sprachen (ADR 111). Nur, wenn es sie gibt; die Hauptsprache
|
||||
ist zugleich die Vorgabe für alle, deren Sprache nicht dabei ist. #}
|
||||
{% for alternate in alternates|default([]) %}<link rel="alternate" hreflang="{{ alternate.locale }}" href="{{ alternate.url }}">
|
||||
{% endfor %}{% if alternates|default([]) %}<link rel="alternate" hreflang="x-default" href="{{ (alternates|first).url }}">
|
||||
{% endif %}
|
||||
{% if pagination|default(null) %}{% if pagination.page > 1 %}<link rel="prev" href="{{ pagination.base }}{{ pagination.page > 2 ? '?seite=' ~ (pagination.page - 1) : '' }}">{% endif %}{% if pagination.page < pagination.pages %}<link rel="next" href="{{ pagination.base }}?seite={{ pagination.page + 1 }}">{% endif %}{% endif %}
|
||||
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
|
||||
<meta property="og:title" content="{{ seoTitle }}">
|
||||
@@ -17,7 +22,9 @@
|
||||
{% if canonical|default(null) %}<meta property="og:url" content="{{ canonical }}">{% endif %}
|
||||
{# In der Vorschau des Editors gibt es den Websitenamen nicht - dort fehlt das Feld einfach. #}
|
||||
{% if page.website_name|default('') %}<meta property="og:site_name" content="{{ page.website_name }}">{% endif %}
|
||||
<meta property="og:locale" content="{{ page.locale == 'de' ? 'de_DE' : page.locale }}">
|
||||
<meta property="og:locale" content="{{ kant_og_locale(page.locale) }}">
|
||||
{% for alternate in alternates|default([]) %}{% if not alternate.current %}<meta property="og:locale:alternate" content="{{ kant_og_locale(alternate.locale) }}">
|
||||
{% endif %}{% endfor %}
|
||||
{% if socialImage|default(null) %}
|
||||
<meta property="og:image" content="{{ socialImage.url }}">
|
||||
{% if socialImage.alt %}<meta property="og:image:alt" content="{{ socialImage.alt }}">{% endif %}
|
||||
@@ -82,7 +89,7 @@
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
|
||||
<span id="seitenanfang" tabindex="-1"></span>
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
<a class="skip-link" href="#main">{{ 'Zum Inhalt springen'|kant_t }}</a>
|
||||
{# Header and footer are block documents like any page, so they are built from elements
|
||||
instead of being written into this template. #}
|
||||
{% if areas.header|default(null) %}
|
||||
@@ -94,7 +101,7 @@
|
||||
{% if postMeta is defined and postMeta is not null and siteSettings.load().postMetaHeader %}
|
||||
<p class="post-header shell">
|
||||
{% if page.published_at %}<time datetime="{{ page.published_at|date('Y-m-d') }}">{{ page.published_at|date('d.m.Y') }}</time>{% endif %}
|
||||
{% if page.author %}<span>von {{ page.author }}</span>{% endif %}
|
||||
{% if page.author %}<span>{{ 'von'|kant_t }} {{ page.author }}</span>{% endif %}
|
||||
{% for term in postMeta.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}
|
||||
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
|
||||
</p>
|
||||
@@ -116,7 +123,7 @@
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
|
||||
gibt ihm eine Landmarke: ein Verweis außerhalb jeder Landmarke ist für Vorlesesoftware
|
||||
Inhalt ohne Ort, und axe meldet ihn als Verstoß gegen „region“. #}
|
||||
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><span aria-hidden="true">↑</span></a></nav>
|
||||
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><span aria-hidden="true">↑</span></a></nav>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
+237
-1
@@ -187,7 +187,10 @@ final class CmsTest extends TestCase
|
||||
$bad = $base;
|
||||
$bad['blocks'][] = $base['blocks'][0];
|
||||
yield 'duplicate IDs' => [$bad];
|
||||
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) {
|
||||
// tel: und mailto: sind Linkziele, aber nur in sauberer Form: keine Buchstaben in der
|
||||
// Nummer, eine echte Adresse, kein Weg aus dem Attribut.
|
||||
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo",
|
||||
'tel:ruf-mich-an', 'tel:+49 7229 6992826', 'mailto:keine-adresse', 'mailto:a@b.de?subject="x"', 'mailto:a@b.de?subject=<script>'] as $url) {
|
||||
$bad = $base;
|
||||
$bad['blocks'][0]['children'][3]['props']['href'] = $url;
|
||||
yield $url => [$bad];
|
||||
@@ -243,6 +246,144 @@ final class CmsTest extends TestCase
|
||||
yield 'missing meaningful alt' => [$bad];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Knopf darf anrufen oder eine E-Mail öffnen - wie ein Menüeintrag oder ein Link im Text.
|
||||
* Vorher war „Jetzt anrufen“ nur als Textlink im Knopf-Aussehen zu bauen. Eine Bildquelle
|
||||
* bleibt dagegen bei Pfad und HTTPS.
|
||||
*/
|
||||
public function testButtonsAndLinkedBlocksMayCallOrWrite(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$ziele = ['tel:+4972296992826', 'mailto:info@huefner-gartenbau.de', 'mailto:info@huefner-gartenbau.de?subject=Bewerbung%3A%20Helfer'];
|
||||
foreach ($ziele as $i => $ziel) {
|
||||
$document['blocks'][0]['children'][3]['props']['href'] = $ziel;
|
||||
$this->pages->saveDraft('page-1', $document, $i);
|
||||
}
|
||||
$id = $this->pages->saveDraft('page-1', $document, 3);
|
||||
$this->pages->publish('page-1', $id, 4);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('href="mailto:info@huefner-gartenbau.de?subject=Bewerbung%3A%20Helfer"', (string) $html);
|
||||
|
||||
$bild = DemoContent::pages()['home'];
|
||||
$bild['blocks'][0]['children'][] = ['id' => 'bild-tel', 'type' => 'image', 'props' => ['src' => 'tel:+4972296992826', 'alt' => 'x', 'decorative' => false, 'width' => 10, 'height' => 10]];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $bild, 5);
|
||||
}
|
||||
|
||||
public function testTheButtonArrowTakesTheColourOfTheButtonText(): void
|
||||
{
|
||||
// Der Pfeil ist ein Symbol (block-icon) und bekam die Akzentfarbe: grün auf einem roten Knopf.
|
||||
self::assertStringContainsString('.button-icon .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema v25: Umut wollte Buttons mit Symbol und den festen Pfeil ändern können. Ohne Wahl
|
||||
* bleibt alles wie bisher - der Pfeil mit der Klasse button-arrow, auf die eigenes CSS älterer
|
||||
* Websites zielt (Hüfner blendet ihn so aus).
|
||||
*/
|
||||
public function testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$button = $document['blocks'][0]['children'][3];
|
||||
self::assertSame('button', $button['type']);
|
||||
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||
$this->pages->publish('page-1', $id, 1);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*>[^<]+ <span class="button-icon button-arrow" aria-hidden="true"><svg class="block-icon"[^>]*><path d="M6\.5 17\.5/', $html, 'Ohne Wahl: der schräge Pfeil hinter dem Text.');
|
||||
|
||||
$document['schemaVersion'] = 25;
|
||||
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'phone', 'iconPosition' => 'start'];
|
||||
$id = $this->pages->saveDraft('page-1', $document, 2);
|
||||
$this->pages->publish('page-1', $id, 3);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*><span class="button-icon" aria-hidden="true"><svg class="block-icon"[^>]*><path d="M6 3h4l2 5[^"]*"\/><\/svg>\s*<\/span> '.preg_quote($button['props']['text'], '/').'<\/a>/', $html, 'Telefon vor dem Text, ohne die Pfeil-Klasse.');
|
||||
|
||||
$document['blocks'][0]['children'][3]['props']['icon'] = 'none';
|
||||
$id = $this->pages->saveDraft('page-1', $document, 4);
|
||||
$this->pages->publish('page-1', $id, 5);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*>'.preg_quote($button['props']['text'], '/').'<\/a>/', $html, 'Kein Symbol: nur der Text.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, mixed}>
|
||||
*/
|
||||
public static function rejectedButtonIcons(): iterable
|
||||
{
|
||||
yield 'Symbol vor v25' => ['icon', 'phone', 24];
|
||||
yield 'unbekanntes Symbol' => ['icon', 'whatsapp', 25];
|
||||
yield 'Symbol ist kein Text' => ['icon', ['phone'], 25];
|
||||
yield 'Stellung vor v25' => ['iconPosition', 'start', 24];
|
||||
yield 'unbekannte Stellung' => ['iconPosition', 'middle', 25];
|
||||
yield 'Bewegung vor v26' => ['iconMotion', 'slide', 25];
|
||||
yield 'unbekannte Bewegung' => ['iconMotion', 'spin', 26];
|
||||
yield 'Nur Symbol vor v26' => ['iconOnly', true, 25];
|
||||
yield 'Nur Symbol ist kein Schalter' => ['iconOnly', 'ja', 26];
|
||||
}
|
||||
|
||||
public function testAButtonWithoutAnIconCannotBeIconOnly(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['schemaVersion'] = 26;
|
||||
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'none', 'iconOnly' => true];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema v26 (ADR 109): Bewegung beim Überfahren und ein Button nur aus Symbol. Der Text
|
||||
* bleibt als unsichtbare Beschriftung der Name des Links.
|
||||
*/
|
||||
public function testAButtonIconMovesOnHoverOrStandsAlone(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$button = $document['blocks'][0]['children'][3];
|
||||
$document['schemaVersion'] = 26;
|
||||
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'phone', 'iconMotion' => 'slide', 'iconOnly' => true];
|
||||
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||
$this->pages->publish('page-1', $id, 1);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'" class="button cms-button[^"]* icon-motion-slide icon-only"[^>]*><span class="button-label">'.preg_quote($button['props']['text'], '/').'<\/span> <span class="button-icon"/', $html);
|
||||
|
||||
$document['blocks'][0]['children'][3]['props'] = ['text' => $button['props']['text'], 'href' => $button['props']['href'], 'icon' => 'download', 'iconPosition' => 'start', 'iconMotion' => 'reveal'];
|
||||
$id = $this->pages->saveDraft('page-1', $document, 2);
|
||||
$this->pages->publish('page-1', $id, 3);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'" class="button cms-button[^"]* icon-start icon-motion-reveal"/', $html);
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||
self::assertStringContainsString('.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1', $css);
|
||||
self::assertStringContainsString('.cms-button .button-icon>svg{width:var(--icon-size,1.15em)', $css);
|
||||
}
|
||||
|
||||
/**
|
||||
* Farbe, Fläche, Größe und Drehung des Symbols liegen am Button und treffen seine
|
||||
* Symbolhülle - je Gerät und beim Überfahren, auch bei Tastaturfokus.
|
||||
*/
|
||||
public function testButtonIconStylesTargetTheIconPerDeviceAndOnHover(): void
|
||||
{
|
||||
$document = ['schemaVersion' => 26, 'title' => 'T', 'description' => '', 'blocks' => [['id' => 'b1', 'type' => 'button', 'props' => ['text' => 'Anrufen', 'href' => '/', 'icon' => 'phone'], 'style' => [
|
||||
'desktop' => ['iconColor' => '@accent', 'iconBackgroundColor' => '#ffffff', 'iconSize' => '20px', 'iconPadding' => '8px', 'iconRadius' => '999px', 'iconGap' => '12px', 'hover' => ['iconColor' => '#000000', 'iconRotate' => '45deg']],
|
||||
'mobile' => ['iconSize' => '16px'],
|
||||
]]]];
|
||||
$css = (new StyleCompiler())->compileDocument($document);
|
||||
self::assertStringContainsString('#b1 .button-icon{color:var(--k-accent);background-color:#ffffff;--icon-size:20px;padding:8px;border-radius:999px}', $css);
|
||||
self::assertStringContainsString('#b1:hover .button-icon,#b1:focus-visible .button-icon{color:#000000;rotate:45deg}', $css);
|
||||
self::assertStringContainsString('--icon-gap:12px', $css);
|
||||
self::assertMatchesRegularExpression('/@media \(max-width:\d+px\)\{#b1 \.button-icon\{--icon-size:16px\}\}/', $css);
|
||||
// Die Werte gelten nur der Symbolhülle, nicht dem Button selbst.
|
||||
self::assertStringNotContainsString('#b1{color:var(--k-accent)', $css);
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedButtonIcons')]
|
||||
public function testAButtonRejectsUnknownIconsAndOlderSchemas(string $key, mixed $value, int $version): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['schemaVersion'] = $version;
|
||||
$document['blocks'][0]['children'][3]['props'][$key] = $value;
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
#[DataProvider('invalidDocuments')]
|
||||
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
|
||||
{
|
||||
@@ -596,6 +737,80 @@ final class CmsTest extends TestCase
|
||||
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR 110: ein Menüeintrag als Button - dieselben Klassen wie der Button-Baustein, eigene
|
||||
* Farben als Regel im Stylesheet der Seite, und auf dem Telefon wahlweise neben dem Menü-Knopf.
|
||||
*/
|
||||
public function testAMenuEntryRendersAsAButtonWithItsOwnColoursAndStaysBesideTheBurger(): void
|
||||
{
|
||||
$this->menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []],
|
||||
['label' => 'Angebot anfragen', 'type' => 'url', 'url' => '#kontakt', 'icon' => 'arrow-right', 'iconPosition' => 'after', 'children' => [],
|
||||
'button' => ['id' => 'mabcdef0123', 'shape' => 'pill', 'appearance' => 'solid', 'fill' => '#b71c22', 'ink' => '#ffffff', 'hoverFill' => '@ink', 'iconColor' => '#ffd700', 'mobile' => 'bar', 'iconMotion' => 'slide']],
|
||||
['label' => 'Anrufen', 'type' => 'url', 'url' => 'tel:+4972296992826', 'icon' => 'phone', 'children' => [],
|
||||
'button' => ['id' => 'm0123456789', 'appearance' => 'outline', 'mobile' => 'menu', 'iconOnly' => true]],
|
||||
['label' => 'Hotline', 'type' => 'url', 'url' => 'tel:+497229', 'icon' => 'phone', 'children' => [],
|
||||
'button' => ['id' => 'm1111111111', 'mobile' => 'bar-icon']],
|
||||
], 0);
|
||||
$document = DemoContent::starter('Mit Menü');
|
||||
$document['schemaVersion'] = 22;
|
||||
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$response = $this->request('/');
|
||||
$html = (string) $response->getContent();
|
||||
$crawler = new \Symfony\Component\DomCrawler\Crawler($html);
|
||||
|
||||
$offer = $crawler->filter('#nav > .menu-list a.menu-button-mabcdef0123');
|
||||
self::assertSame('button cms-button menu-button menu-button-mabcdef0123 b-desktop-shape-pill b-desktop-appearance-solid icon-motion-slide', $offer->attr('class'));
|
||||
self::assertSame('#kontakt', $offer->attr('href'));
|
||||
self::assertSame('Angebot anfragen', $offer->filter('.button-label')->text());
|
||||
self::assertCount(1, $offer->filter('.button-icon svg'));
|
||||
self::assertSame('menu-item-button menu-keep-visible', $offer->closest('li')->attr('class'));
|
||||
// Beim Menü-Knopf steht er ein zweites Mal - sichtbar nur, solange das Menü zusammengeklappt ist.
|
||||
self::assertCount(1, $crawler->filter('#nav > ul.menu-actions a.menu-button-mabcdef0123'));
|
||||
self::assertCount(0, $crawler->filter('#nav > ul.menu-actions a.menu-button-m0123456789'));
|
||||
// Nur Symbol: die Beschriftung bleibt als Name des Links.
|
||||
$call = $crawler->filter('#nav > .menu-list a.menu-button-m0123456789');
|
||||
self::assertStringContainsString('icon-start icon-only', (string) $call->attr('class'));
|
||||
self::assertSame('Anrufen', $call->filter('.button-label')->text());
|
||||
// Neben dem Menü-Knopf nur als Symbol, im Menü selbst mit Beschriftung.
|
||||
self::assertStringContainsString('icon-only', (string) $crawler->filter('#nav > ul.menu-actions a.menu-button-m1111111111')->attr('class'));
|
||||
self::assertStringNotContainsString('icon-only', (string) $crawler->filter('#nav > .menu-list a.menu-button-m1111111111')->attr('class'));
|
||||
self::assertSame('Hotline', $crawler->filter('#nav > ul.menu-actions a.menu-button-m1111111111 .button-label')->text());
|
||||
// Gewöhnliche Einträge bleiben gewöhnliche Links.
|
||||
self::assertCount(1, $crawler->filter('#nav > .menu-list a[href="/"] .nav-label'));
|
||||
|
||||
self::assertStringContainsString('.menu-button-mabcdef0123{--kb-fill:#b71c22;--kb-ink:#ffffff;--kb-hover-fill:var(--k-ink);--kb-bg:var(--kb-fill)', $html);
|
||||
self::assertStringContainsString('.menu-button-mabcdef0123 .button-icon{color:#ffd700}', $html);
|
||||
self::assertStringNotContainsString('.menu-button-m0123456789{', $html, 'Ohne eigene Farben keine Regel.');
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||
self::assertStringContainsString('.menu-collapse-tablet>.menu-actions{display:flex}', $css);
|
||||
self::assertStringContainsString('.menu-collapse-tablet .menu-burger-body .menu-keep-visible{display:none}', $css);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array}> */
|
||||
public static function rejectedMenuButtons(): iterable
|
||||
{
|
||||
$entry = ['label' => 'Angebot', 'type' => 'url', 'url' => '/kontakt', 'children' => []];
|
||||
$button = ['id' => 'mabcdef0123', 'mobile' => 'menu'];
|
||||
yield 'ohne Kennung' => [[...$entry, 'button' => ['mobile' => 'menu']]];
|
||||
yield 'unbekannte Form' => [[...$entry, 'button' => [...$button, 'shape' => 'round']]];
|
||||
yield 'Farbe als Wort' => [[...$entry, 'button' => [...$button, 'fill' => 'red']]];
|
||||
yield 'CSS in der Farbe' => [[...$entry, 'button' => [...$button, 'ink' => '#fff;}body{display:none']]];
|
||||
yield 'unbekannte Anzeige auf dem Telefon' => [[...$entry, 'button' => [...$button, 'mobile' => 'oben']]];
|
||||
yield 'unbekannte Einstellung' => [[...$entry, 'button' => [...$button, 'style' => 'x']]];
|
||||
yield 'neben dem Menü-Knopf nur als Symbol, ohne Symbol' => [[...$entry, 'button' => [...$button, 'mobile' => 'bar-icon']]];
|
||||
yield 'nur Symbol ohne Symbol' => [[...$entry, 'button' => [...$button, 'iconOnly' => true]]];
|
||||
yield 'Button mit Unterpunkten' => [[...$entry, 'button' => $button, 'children' => [['label' => 'Unter', 'type' => 'url', 'url' => '/a', 'children' => []]]]];
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedMenuButtons')]
|
||||
public function testAMenuButtonRejectsUnknownOrUnsafeSettings(array $entry): void
|
||||
{
|
||||
$this->expectException(InvalidMenu::class);
|
||||
$this->menus->save('header', 'Hauptnavigation', [$entry], 0);
|
||||
}
|
||||
|
||||
public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void
|
||||
{
|
||||
$doomed = $this->publishedPage('temporaer', 'Temporaer');
|
||||
@@ -1516,6 +1731,16 @@ final class CmsTest extends TestCase
|
||||
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
|
||||
self::assertStringContainsString('U+0131', $faces[0]['range']);
|
||||
|
||||
// Ein neuer Abruf ersetzt nur die Regeln der geholten Familien; andere Familien, deren
|
||||
// Dateien noch im Ordner liegen, bleiben im Stylesheet.
|
||||
$vorher = "/* latin */\n@font-face{font-family:'Lato';font-weight:400;src:url(/assets/fonts/lato-400-latin.woff2)}\n"
|
||||
."/* latin */\n@font-face{font-family:'DM Sans';font-weight:400;src:url(/assets/fonts/alt.woff2)}\n";
|
||||
$neu = $fonts->combine($vorher, ["/* latin */\n@font-face{font-family:'DM Sans';font-weight:400;src:url(/assets/fonts/dm-sans-400-latin.woff2)}"], ['DM Sans']);
|
||||
self::assertStringContainsString('lato-400-latin.woff2', $neu);
|
||||
self::assertStringContainsString('dm-sans-400-latin.woff2', $neu);
|
||||
self::assertStringNotContainsString('alt.woff2', $neu);
|
||||
self::assertSame(2, substr_count($neu, '@font-face'));
|
||||
|
||||
// Ohne heruntergeladene Dateien bleibt es bei der Ersatzschrift - verlinkt wird Google
|
||||
// nie, denn das gäbe die IP jedes Besuchers an einen Dritten weiter.
|
||||
$theme = new Theme($this->db);
|
||||
@@ -1547,6 +1772,11 @@ final class CmsTest extends TestCase
|
||||
'/assets/fonts/lato-700-latin.woff2',
|
||||
'/assets/fonts/lato-900-latin.woff2',
|
||||
], $fonts->preload($theme->load()));
|
||||
|
||||
// Eine variable Schrift ist eine Datei für alle Schnitte: das Stylesheet nennt sie einmal
|
||||
// mit Gewichtsbereich, und vorgeladen wird nur diese eine Datei statt dreimal dieselbe.
|
||||
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato';font-weight:400 900;src:url(/assets/fonts/lato-400-latin.woff2) format('woff2')}");
|
||||
self::assertSame(['/assets/fonts/lato-400-latin.woff2'], $fonts->preload($theme->load()));
|
||||
}
|
||||
|
||||
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
|
||||
@@ -3138,6 +3368,9 @@ final class CmsTest extends TestCase
|
||||
& .card { transform: none }', '#b1 .card{border:0;transform:none}'];
|
||||
yield 'element rule after a nested one' => ['& { color: red }
|
||||
display: flex', '#b1{display:flex;color:red}'];
|
||||
// Ein Pseudo-Element braucht content: leer fuer Striche und Verlaeufe, ein Zeichen fuer ein Wasserzeichen.
|
||||
yield 'empty content' => ["&::after { content: ''; height: 2px }", "#b1::after{content:'';height:2px}"];
|
||||
yield 'short content' => ['&::before { content: "K" }', '#b1::before{content:"K"}'];
|
||||
}
|
||||
|
||||
#[DataProvider('ownCssCases')]
|
||||
@@ -3161,6 +3394,9 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
yield 'old expression syntax' => ['color: expression(alert(1))'];
|
||||
yield 'no value' => ['color'];
|
||||
yield 'nonsense property' => ['co lor: red'];
|
||||
yield 'content leaving the style tag' => ['content: "</style><script>"'];
|
||||
yield 'content with a second quote' => ["content: 'a' 'b'"];
|
||||
yield 'quotes outside content' => ["font-family: 'Evil'"];
|
||||
}
|
||||
|
||||
#[DataProvider('refusedOwnCss')]
|
||||
|
||||
@@ -405,6 +405,38 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR 110: Ein Menüeintrag wird über das Formular zum Button - mit Gestalt, Farben, Symbol,
|
||||
* Bewegung und Anzeige auf dem Telefon. Die Kennung für die Farbregel entsteht beim ersten
|
||||
* Speichern und bleibt danach gleich; leere Felder heißen Website-Standard und fehlen.
|
||||
*/
|
||||
public function testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId(): void
|
||||
{
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
|
||||
$row = ['position' => 10, 'level' => 1, 'label' => 'Angebot anfragen', 'type' => 'url', 'url' => '#kontakt',
|
||||
'icon' => 'arrow-right', 'iconPosition' => 'after', 'button' => '1', 'buttonId' => '', 'buttonShape' => 'pill', 'buttonAppearance' => '',
|
||||
'buttonSize' => '', 'buttonFill' => '#b71c22', 'buttonInk' => '', 'buttonHoverFill' => '@ink', 'buttonHoverInk' => '', 'buttonIconColor' => '',
|
||||
'iconMotion' => 'slide', 'buttonMobile' => 'bar'];
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 0, 'items' => [$row]]);
|
||||
self::assertResponseRedirects('/admin/menus');
|
||||
$button = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button'];
|
||||
self::assertMatchesRegularExpression('/^m[a-f0-9]{10}$/', $button['id']);
|
||||
self::assertSame(['shape' => 'pill', 'fill' => '#b71c22', 'hoverFill' => '@ink', 'mobile' => 'bar', 'iconMotion' => 'slide'], array_diff_key($button, ['id' => true]));
|
||||
|
||||
// Die Verwaltung zeigt die Werte wieder an, und erneutes Speichern behält die Kennung.
|
||||
$crawler = $client->request('GET', '/admin/menus');
|
||||
self::assertSame($button['id'], $crawler->filter('input[data-key="buttonId"]')->attr('value'));
|
||||
self::assertSame('pill', $crawler->filter('select[data-key="buttonShape"] option[selected]')->attr('value'));
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 1, 'items' => [[...$row, 'buttonId' => $button['id']]]]);
|
||||
self::assertSame($button['id'], json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button']['id']);
|
||||
|
||||
// Ohne Häkchen ist es wieder ein Link.
|
||||
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 2, 'items' => [array_diff_key($row, ['button' => true])]]);
|
||||
self::assertArrayNotHasKey('button', json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]);
|
||||
}
|
||||
|
||||
public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void
|
||||
{
|
||||
$menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
@@ -649,8 +681,12 @@ final class EditorSecurityTest extends WebTestCase
|
||||
preg_match('#<style>(.*?)</style>#s', $html, $style);
|
||||
self::assertStringContainsString("'sha256-".base64_encode(hash('sha256', $style[1], true))."'", (string) $client->getResponse()->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Eine Farbe, die keine ist, wird mit Meldung abgelehnt und nichts geaendert.
|
||||
// Die Übersicht listet das Formular, bearbeitet wird es auf seiner eigenen Seite.
|
||||
$crawler = $client->request('GET', '/admin/forms');
|
||||
self::assertSame(0, $crawler->filter('.form-def')->count(), 'Die Übersicht zeigt keine Editoren.');
|
||||
self::assertSame('/admin/forms/kontakt', $crawler->filter('.form-list a')->first()->attr('href'));
|
||||
// Eine Farbe, die keine ist, wird mit Meldung abgelehnt und nichts geaendert.
|
||||
$crawler = $client->request('GET', '/admin/forms/kontakt');
|
||||
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
|
||||
self::assertCount(5, $crawler->filter('.form-def')->first()->filter('.field-card:not(.field-card-new)'), 'Jedes Feld ist eine Karte.');
|
||||
self::assertSame('Anfrage senden', $crawler->filter('.form-def')->first()->filter('[data-preview-button]')->text());
|
||||
@@ -664,7 +700,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
public function testFormAdministrationNeedsAPublisherRoleAndACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('publisher');
|
||||
$crawler = $client->request('GET', '/admin/forms');
|
||||
$crawler = $client->request('GET', '/admin/forms/neu');
|
||||
self::assertResponseIsSuccessful();
|
||||
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
|
||||
$field = ['fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]];
|
||||
|
||||
@@ -22,6 +22,7 @@ use DoctrineMigrations\Version20260914000100;
|
||||
use DoctrineMigrations\Version20260915000100;
|
||||
use DoctrineMigrations\Version20260916000100;
|
||||
use DoctrineMigrations\Version20260917000100;
|
||||
use DoctrineMigrations\Version20261009000100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,7 @@ final class TestSchema
|
||||
Version20260915000100::class,
|
||||
Version20260916000100::class,
|
||||
Version20260917000100::class,
|
||||
Version20261009000100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Translations;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Mehrsprachigkeit als Übersetzungsebene (ADR 111).
|
||||
*
|
||||
* Umut: „Ich möchte nicht für die 2. Sprache die komplette Seite neu bauen.“ Eine Seite wird
|
||||
* einmal gebaut; je Sprache kommen nur Texte und eine Adresse dazu. Diese Tests halten fest, was
|
||||
* dabei garantiert ist: Übersetzungen durchlaufen dieselbe Prüfung wie die Hauptsprache, gehen
|
||||
* mit der Fassung online, haben eigene Adressen unter /en/ und verlinken einander.
|
||||
*/
|
||||
final class TranslationTest extends TestCase
|
||||
{
|
||||
private string $path;
|
||||
private Connection $db;
|
||||
private PageRepository $pages;
|
||||
private SiteSettings $settings;
|
||||
private Languages $languages;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-lang-');
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$this->settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'));
|
||||
$this->pages = new PageRepository($this->db, new DocumentValidator(), $this->settings);
|
||||
$this->languages = new Languages($this->db, $this->settings);
|
||||
$partials = new PartialRepository($this->db, new DocumentValidator());
|
||||
foreach (DemoContent::areas() as $kind => $area) {
|
||||
$partials->create($kind, 'default', $area['name'], $area['document']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
}
|
||||
|
||||
private function document(string $title = 'Leistungen', array $translations = []): array
|
||||
{
|
||||
$document = [
|
||||
'schemaVersion' => 27,
|
||||
'title' => $title,
|
||||
'description' => 'Was wir für Sie tun',
|
||||
'blocks' => [[
|
||||
'id' => 's1', 'type' => 'section', 'children' => [
|
||||
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => $title, 'level' => 1]],
|
||||
['id' => 't1', 'type' => 'text', 'props' => ['text' => '<p>Schreiben Sie uns über <a href="/kontakt">Kontakt</a>.</p>']],
|
||||
['id' => 'b1', 'type' => 'button', 'props' => ['text' => 'Zum Kontakt', 'href' => '/kontakt']],
|
||||
['id' => 'i1', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true, 'width' => 10, 'height' => 10]],
|
||||
['id' => 'l1', 'type' => 'language', 'props' => ['display' => 'code']],
|
||||
],
|
||||
]],
|
||||
];
|
||||
if ([] !== $translations) {
|
||||
$document['translations'] = $translations;
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/** Legt eine Seite an, speichert einen Entwurf und veröffentlicht ihn. */
|
||||
private function publishedPage(string $slug, array $document, ?string $parent = null): string
|
||||
{
|
||||
$id = $this->pages->createPage($slug, $parent, 'page', $this->document());
|
||||
$revision = $this->pages->saveDraft($id, $document, 1);
|
||||
$this->pages->publish($id, $revision, 2);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function republish(string $id, array $document): void
|
||||
{
|
||||
$version = (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]);
|
||||
$revision = $this->pages->saveDraft($id, $document, $version);
|
||||
$this->pages->publish($id, $revision, $version + 1);
|
||||
}
|
||||
|
||||
public function testApplyLaysTranslatedTextsOverTheSamePageAndKeepsEverythingElse(): void
|
||||
{
|
||||
$document = $this->document('Leistungen', ['en' => [
|
||||
'title' => 'Services',
|
||||
'blocks' => ['h1' => ['text' => 'Services'], 'b1' => ['text' => ' '], 'i1' => ['alt' => 'A picture']],
|
||||
]]);
|
||||
|
||||
$english = Translations::apply($document, 'en');
|
||||
|
||||
self::assertArrayNotHasKey('translations', $english);
|
||||
self::assertSame('Services', $english['title']);
|
||||
self::assertSame('Was wir für Sie tun', $english['description'], 'Ohne Übersetzung bleibt der Text der Hauptsprache.');
|
||||
$children = $english['blocks'][0]['children'];
|
||||
self::assertSame('Services', $children[0]['props']['text']);
|
||||
self::assertSame(1, $children[0]['props']['level'], 'Aufbau und Einstellungen bleiben unverändert.');
|
||||
self::assertSame('Zum Kontakt', $children[2]['props']['text'], 'Ein leerer Text gilt als nicht übersetzt.');
|
||||
self::assertSame('/kontakt', $children[2]['props']['href']);
|
||||
self::assertSame('', $children[3]['props']['alt'], 'Ein dekoratives Bild bekommt auch übersetzt keinen Text.');
|
||||
self::assertSame(['total' => 5, 'done' => 2, 'stale' => 0], Translations::progress($document, 'en'));
|
||||
}
|
||||
|
||||
public function testTranslationsPassTheSameChecksAsTheMainLanguage(): void
|
||||
{
|
||||
$id = $this->pages->createPage('leistungen', null, 'page', $this->document());
|
||||
|
||||
// Formatierter Text geht durch denselben Filter, Einträge verschwundener Blöcke fallen weg.
|
||||
$this->pages->saveDraft($id, $this->document('Leistungen', ['en' => [
|
||||
'blocks' => [
|
||||
'h1' => ['text' => 'Services<script>alert(1)</script>', 'level' => 'kein Text-Feld'],
|
||||
'weg' => ['text' => 'Block gibt es nicht'],
|
||||
],
|
||||
'source' => ['h1.text' => Translations::fingerprint('Leistungen'), 'weg.text' => '00000000'],
|
||||
]]), 1);
|
||||
$stored = $this->pages->findForEditor($id)['document']['translations']['en'];
|
||||
self::assertSame(['h1' => ['text' => 'Services']], $stored['blocks']);
|
||||
self::assertSame(['h1.text' => Translations::fingerprint('Leistungen')], $stored['source']);
|
||||
|
||||
$refused = [
|
||||
'unbekannte Sprache' => ['xx' => ['title' => 'X']],
|
||||
'zu langer Titel' => ['en' => ['title' => str_repeat('a', 161)]],
|
||||
'falsche Adresse' => ['en' => ['title' => 'Services', 'slug' => 'Unsere Leistungen']],
|
||||
'unbekanntes Feld' => ['en' => ['title' => 'Services', 'menu' => 'x']],
|
||||
'zu langer Button' => ['en' => ['blocks' => ['b1' => ['text' => str_repeat('a', 301)]]]],
|
||||
];
|
||||
foreach ($refused as $case => $translations) {
|
||||
try {
|
||||
$this->pages->saveDraft($id, $this->document('Leistungen', $translations), 2);
|
||||
self::fail('Abgelehnt werden muss: '.$case);
|
||||
} catch (InvalidDocument) {
|
||||
self::assertSame(2, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$id]), $case);
|
||||
}
|
||||
}
|
||||
$older = $this->document('Leistungen', ['en' => ['title' => 'Services']]);
|
||||
$older['schemaVersion'] = 26;
|
||||
$older['blocks'][0]['children'] = array_slice($older['blocks'][0]['children'], 0, 4);
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($older);
|
||||
}
|
||||
|
||||
public function testPublishingWritesTheTranslatedAddressesAndMovesFollow(): void
|
||||
{
|
||||
$this->languages->save(['en']);
|
||||
$parent = $this->pages->createPage('leistungen', null, 'page', $this->document());
|
||||
$revision = $this->pages->saveDraft($parent, $this->document('Leistungen', ['en' => ['title' => 'Services', 'slug' => 'services']]), 1);
|
||||
self::assertSame([], $this->pages->translatedPaths($parent), 'Ein Entwurf ist noch nicht unter /en erreichbar.');
|
||||
$this->pages->publish($parent, $revision, 2);
|
||||
self::assertSame(['en' => 'services'], $this->pages->translatedPaths($parent));
|
||||
|
||||
$child = $this->publishedPage('webdesign', $this->document('Webdesign', ['en' => ['title' => 'Web design', 'slug' => 'web-design']]), $parent);
|
||||
self::assertSame(['en' => 'services/web-design'], $this->pages->translatedPaths($child));
|
||||
$english = $this->pages->findPublishedTranslation('services/web-design', 'en');
|
||||
self::assertSame('Web design', $english['document']['title']);
|
||||
self::assertSame('en', $english['locale']);
|
||||
self::assertSame('leistungen/webdesign', $english['path'], 'Navigation und Brotkrumen hängen an der Adresse der Hauptsprache.');
|
||||
self::assertArrayNotHasKey('translations', $this->pages->findPublished('leistungen/webdesign')['document']);
|
||||
|
||||
// Neue übersetzte Adresse der Elternseite: die Unterseite wandert mit, die alte Adresse leitet weiter.
|
||||
$this->republish($parent, $this->document('Leistungen', ['en' => ['title' => 'Services', 'slug' => 'offers']]));
|
||||
self::assertSame(['en' => 'offers/web-design'], $this->pages->translatedPaths($child));
|
||||
self::assertSame(['to' => 'en/offers/web-design', 'status' => 301], (new RedirectRepository($this->db))->find('en/services/web-design'));
|
||||
|
||||
// Verschieben in der Hauptsprache trägt die Übersetzungen mit.
|
||||
$other = $this->publishedPage('ueber-uns', $this->document('Über uns'));
|
||||
$version = (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', [$child]);
|
||||
$this->pages->movePage($child, 'webdesign', $other, $version, new RedirectRepository($this->db));
|
||||
self::assertSame(['en' => 'ueber-uns/web-design'], $this->pages->translatedPaths($child), 'Eine nicht übersetzte Elternseite steht mit ihrer Adresse der Hauptsprache davor.');
|
||||
|
||||
// Ohne Übersetzung veröffentlicht: die Sprache ist für diese Seite wieder weg.
|
||||
$this->republish($child, $this->document('Webdesign'));
|
||||
self::assertSame([], $this->pages->translatedPaths($child));
|
||||
self::assertNull($this->pages->findPublishedTranslation('ueber-uns/web-design', 'en'));
|
||||
}
|
||||
|
||||
public function testATranslatedAddressThatIsTakenIsRefusedWhenSaving(): void
|
||||
{
|
||||
$this->languages->save(['en']);
|
||||
$this->publishedPage('leistungen', $this->document('Leistungen', ['en' => ['title' => 'Services', 'slug' => 'services']]));
|
||||
$other = $this->pages->createPage('angebote', null, 'page', $this->document('Angebote'));
|
||||
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->expectExceptionMessage('/en/services');
|
||||
$this->pages->saveDraft($other, $this->document('Angebote', ['en' => ['title' => 'Offers', 'slug' => 'services']]), 1);
|
||||
}
|
||||
|
||||
public function testALanguageCodeCannotCollideWithARootPage(): void
|
||||
{
|
||||
$this->pages->createPage('fr', null, 'page', $this->document('Frankreich'));
|
||||
try {
|
||||
$this->languages->save(['fr']);
|
||||
self::fail('Die Seite /fr würde unerreichbar.');
|
||||
} catch (InvalidPage $error) {
|
||||
self::assertStringContainsString('/fr', $error->getMessage());
|
||||
}
|
||||
$this->languages->save(['en', 'de', 'en']);
|
||||
self::assertSame(['en'], $this->languages->extra(), 'Die Hauptsprache und Doppelte fallen weg.');
|
||||
|
||||
$this->expectException(InvalidPage::class);
|
||||
$this->pages->createPage('en', null, 'page', $this->document('Englisch'));
|
||||
}
|
||||
|
||||
public function testTheWebsiteServesTheTranslationWithHreflangAndLinksInItsLanguage(): void
|
||||
{
|
||||
$this->languages->save(['en']);
|
||||
$home = $this->publishedPage('home', $this->document('Start', ['en' => ['title' => 'Home', 'blocks' => ['h1' => ['text' => 'Welcome']]]]));
|
||||
$this->publishedPage('kontakt', $this->document('Kontakt', ['en' => ['title' => 'Contact', 'slug' => 'contact']]));
|
||||
$this->publishedPage('leistungen', $this->document('Leistungen', ['en' => [
|
||||
'title' => 'Services', 'slug' => 'services', 'seoTitle' => 'Our services | KantSites',
|
||||
'blocks' => ['h1' => ['text' => 'Services'], 't1' => ['text' => '<p>Write to us via <a href="/kontakt">contact</a>.</p>'], 'b1' => ['text' => 'Get in touch']],
|
||||
]]));
|
||||
$this->publishedPage('impressum', $this->document('Impressum'));
|
||||
|
||||
$english = $this->request('/en/services');
|
||||
$html = (string) $english->getContent();
|
||||
self::assertSame(200, $english->getStatusCode());
|
||||
self::assertStringContainsString('<html lang="en"', $html);
|
||||
self::assertStringContainsString('<title>Our services | KantSites</title>', $html);
|
||||
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/en/services">', $html);
|
||||
self::assertStringContainsString('<link rel="alternate" hreflang="de" href="https://cms.test/leistungen">', $html);
|
||||
self::assertStringContainsString('<link rel="alternate" hreflang="en" href="https://cms.test/en/services">', $html);
|
||||
self::assertStringContainsString('hreflang="x-default" href="https://cms.test/leistungen"', $html);
|
||||
self::assertStringContainsString('content="en_US"', $html);
|
||||
self::assertStringContainsString('Get in touch', $html);
|
||||
self::assertStringContainsString('href="/en/contact"', $html, 'Links auf übersetzte Seiten führen zur Übersetzung.');
|
||||
self::assertStringNotContainsString('href="/kontakt"', $html);
|
||||
self::assertMatchesRegularExpression('#<a href="/leistungen" hreflang="de" lang="de">DE#', $html, 'Die Sprachwahl zeigt bewusst auf die andere Sprache.');
|
||||
self::assertMatchesRegularExpression('#<a href="/en/services" hreflang="en" lang="en" aria-current="page">EN#', $html);
|
||||
|
||||
$german = (string) $this->request('/leistungen')->getContent();
|
||||
self::assertStringContainsString('<html lang="de"', $german);
|
||||
self::assertStringContainsString('hreflang="en" href="https://cms.test/en/services"', $german);
|
||||
self::assertStringContainsString('href="/kontakt"', $german);
|
||||
self::assertStringContainsString('Zum Kontakt', $german);
|
||||
self::assertStringNotContainsString('hreflang', (string) $this->request('/impressum')->getContent(), 'Eine Seite ohne Übersetzung hat keine Sprachalternativen.');
|
||||
|
||||
self::assertStringContainsString('Welcome', (string) $this->request('/en')->getContent(), '/en ist die Startseite der Sprache.');
|
||||
self::assertSame(301, $this->request('/en/home')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/en/impressum')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/fr/services')->getStatusCode());
|
||||
$sitemap = (string) $this->request('/sitemap.xml')->getContent();
|
||||
self::assertStringContainsString('<loc>https://cms.test/en/services</loc>', $sitemap);
|
||||
self::assertStringContainsString('<loc>https://cms.test/en</loc>', $sitemap);
|
||||
unset($home);
|
||||
|
||||
// Sprache abgewählt: ihre Seiten sind weg, die Übersetzungen bleiben in den Seiten liegen.
|
||||
$this->languages->save([]);
|
||||
self::assertSame(404, $this->request('/en/services')->getStatusCode());
|
||||
self::assertStringNotContainsString('hreflang', (string) $this->request('/leistungen')->getContent());
|
||||
self::assertStringNotContainsString('/en/', (string) $this->request('/sitemap.xml')->getContent());
|
||||
}
|
||||
|
||||
public function testHeaderMenuAndFixedTextsFollowTheLanguageOfThePage(): void
|
||||
{
|
||||
$this->languages->save(['en']);
|
||||
$partials = new PartialRepository($this->db, new DocumentValidator());
|
||||
$header = $partials->findByKind('header');
|
||||
$partials->save($header['id'], [
|
||||
'schemaVersion' => 27, 'title' => 'Kopf', 'description' => 'Kopfbereich',
|
||||
'blocks' => [['id' => 'kopf', 'type' => 'section', 'children' => [
|
||||
['id' => 'kopf-text', 'type' => 'text', 'props' => ['text' => '<p>Willkommen</p>']],
|
||||
['id' => 'kopf-menu', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal']],
|
||||
]]],
|
||||
'translations' => ['en' => ['blocks' => ['kopf-text' => ['text' => '<p>Welcome<script>x</script></p>'], 'weg' => ['text' => 'x']]]],
|
||||
], $header['version']);
|
||||
self::assertSame(['kopf-text' => ['text' => '<p>Welcome</p>']], $partials->find($header['id'])['document']['translations']['en']['blocks'], 'Ein Bereich wird beim Speichern bereinigt wie eine Seite.');
|
||||
|
||||
$this->publishedPage('home', $this->document('Start'));
|
||||
$services = $this->publishedPage('leistungen', $this->document('Leistungen', ['en' => ['title' => 'Services', 'slug' => 'services']]));
|
||||
$menus = new \App\Content\MenuRepository($this->db, $this->settings);
|
||||
$menus->save('header', 'Hauptnavigation', [
|
||||
['label' => 'Leistungen', 'type' => 'page', 'pageId' => $services, 'anchor' => '', 'labels' => ['en' => 'Our services'], 'children' => []],
|
||||
], 0);
|
||||
try {
|
||||
$menus->save('header', 'Hauptnavigation', [['label' => 'X', 'type' => 'url', 'url' => '/x', 'labels' => ['xx' => 'Y'], 'children' => []]], 1);
|
||||
self::fail('Eine unbekannte Sprache im Menü wird abgelehnt.');
|
||||
} catch (\App\Domain\InvalidMenu) {
|
||||
}
|
||||
|
||||
$english = (string) $this->request('/en/services')->getContent();
|
||||
self::assertStringContainsString('<p>Welcome</p>', $english);
|
||||
self::assertStringNotContainsString('Willkommen', $english);
|
||||
self::assertMatchesRegularExpression('#<a href="/en/services"[^>]*>.*?Our services#s', $english, 'Menübeschriftung und Ziel in der Sprache der Seite.');
|
||||
self::assertStringContainsString('>Skip to content</a>', $english);
|
||||
self::assertStringContainsString('aria-label="Main navigation"', $english);
|
||||
|
||||
$german = (string) $this->request('/leistungen')->getContent();
|
||||
self::assertStringContainsString('<p>Willkommen</p>', $german);
|
||||
self::assertMatchesRegularExpression('#<a href="/leistungen"[^>]*>.*?Leistungen#s', $german);
|
||||
self::assertStringNotContainsString('Our services', $german);
|
||||
self::assertStringContainsString('>Zum Inhalt springen</a>', $german);
|
||||
}
|
||||
|
||||
public function testFormsSpeakTheLanguageOfThePageButSendInTheMainLanguage(): void
|
||||
{
|
||||
$this->languages->save(['en']);
|
||||
$forms = new \App\Content\FormRepository($this->db);
|
||||
$translations = ['en' => ['buttonLabel' => 'Send request', 'success' => 'Thanks!', 'fields' => [
|
||||
'art' => ['label' => 'Type of enquiry', 'options' => ['Beratung' => 'Advice', 'Angebot' => 'Quote', 'Gibt es nicht' => 'x']],
|
||||
'email' => ['label' => 'Email', 'help' => ''],
|
||||
'weg' => ['label' => 'Feld gibt es nicht'],
|
||||
]]];
|
||||
$fields = [
|
||||
['name' => 'art', 'type' => 'radio', 'label' => 'Art der Anfrage', 'required' => true, 'options' => ['Beratung', 'Angebot']],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true, 'help' => 'Für die Antwort'],
|
||||
];
|
||||
try {
|
||||
$forms->save('anfrage', 'Anfrage', $fields, ['recipient' => 'info@example.org', 'translations' => ['xx' => []]], 0);
|
||||
self::fail('Eine unbekannte Sprache wird abgelehnt.');
|
||||
} catch (\App\Domain\InvalidForm) {
|
||||
}
|
||||
$forms->save('anfrage', 'Anfrage', $fields, ['recipient' => 'info@example.org', 'translations' => $translations], 0);
|
||||
$stored = $forms->findBySlug('anfrage')['settings']['translations']['en'];
|
||||
self::assertSame(['art' => ['label' => 'Type of enquiry', 'options' => ['Beratung' => 'Advice', 'Angebot' => 'Quote']], 'email' => ['label' => 'Email']], $stored['fields'], 'Nur Felder und Optionen, die es gibt; leere fallen weg.');
|
||||
|
||||
$checked = $forms->check($forms->findBySlug('anfrage'), ['art' => 'Advice', 'email' => 'kaputt'], 'en');
|
||||
self::assertSame(['art' => 'Please choose one of the options offered.', 'email' => 'Please enter a valid email address.'], $checked['errors'], 'Abgeschickt wird der Wert der Hauptsprache, nicht die Anzeige.');
|
||||
|
||||
$document = $this->document('Kontakt', ['en' => ['title' => 'Contact', 'slug' => 'contact']]);
|
||||
$document['blocks'][0]['children'][] = ['id' => 'f1', 'type' => 'form', 'props' => ['form' => 'anfrage']];
|
||||
$this->publishedPage('kontakt', $document);
|
||||
|
||||
$english = (string) $this->request('/en/contact')->getContent();
|
||||
self::assertMatchesRegularExpression('#<legend>Type of enquiry#', $english);
|
||||
self::assertStringContainsString('type="radio" value="Beratung"', $english);
|
||||
self::assertMatchesRegularExpression('#<label for="[^"]+-1">Advice</label>#', $english);
|
||||
self::assertStringContainsString('<input type="hidden" name="language" value="en">', $english);
|
||||
self::assertStringContainsString('>Send request</button>', $english);
|
||||
self::assertStringContainsString('(required)', $english);
|
||||
self::assertStringContainsString('Für die Antwort', $english, 'Ein nicht übersetzter Hilfetext bleibt in der Hauptsprache.');
|
||||
|
||||
$german = (string) $this->request('/kontakt')->getContent();
|
||||
self::assertMatchesRegularExpression('#<label for="[^"]+-1">Beratung</label>#', $german);
|
||||
self::assertStringNotContainsString('name="language"', $german);
|
||||
|
||||
$issued = time() - 10;
|
||||
$body = ['page' => 'kontakt', 'block' => 'f1', 'language' => 'en', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'art' => 'Beratung', 'email' => ''];
|
||||
$refused = $this->request('/formular/anfrage', 'POST', $body);
|
||||
self::assertSame(422, $refused->getStatusCode());
|
||||
self::assertStringContainsString('This field is required.', (string) $refused->getContent());
|
||||
self::assertStringContainsString('<html lang="en"', (string) $refused->getContent());
|
||||
$sent = $this->request('/formular/anfrage', 'POST', [...$body, 'email' => 'kunde@example.org']);
|
||||
self::assertSame('/en/contact?gesendet=anfrage#f1', $sent->headers->get('Location'));
|
||||
self::assertSame('Beratung', json_decode((string) $this->db->fetchOne('SELECT data FROM form_submission'), true)['art'], 'Gespeichert wird in der Hauptsprache.');
|
||||
self::assertStringContainsString('Thanks!', (string) $this->request('/en/contact?gesendet=anfrage')->getContent());
|
||||
}
|
||||
|
||||
public function testPostsArchivesConsentAndNotFoundFollowTheLanguage(): void
|
||||
{
|
||||
$this->languages->save(['en']);
|
||||
$this->publishedPage('home', $this->document('Start', ['en' => ['title' => 'Home']]));
|
||||
$posts = new \App\Content\PostRepository($this->db, new MediaLibrary($this->db, $this->path.'-media'), $this->settings);
|
||||
$translated = $this->pages->createPage('neues-dach', null, 'post', $this->document('Neues Dach'));
|
||||
$this->pages->publish($translated, $this->pages->saveDraft($translated, $this->document('Neues Dach', ['en' => ['title' => 'New roof', 'description' => 'A new roof for a farmhouse', 'slug' => 'new-roof']]), 1), 2);
|
||||
$posts->saveMetadata($translated, [], ['Projekte'], [], 3);
|
||||
$german = $this->pages->createPage('garten', null, 'post', $this->document('Garten'));
|
||||
$this->pages->publish($german, $this->pages->saveDraft($german, $this->document('Garten'), 1), 2);
|
||||
$this->settings->saveLanguageTexts(['en' => ['category:projekte' => 'Projects', 'tag:kaputt!' => 'x']], ['en' => ['title' => 'Your choice', 'text' => '']]);
|
||||
self::assertSame(['en' => ['category:projekte' => 'Projects']], $this->settings->load()['termNames']);
|
||||
|
||||
$list = $posts->published(language: 'en')['posts'];
|
||||
self::assertContains('New roof', array_column($list, 'title'));
|
||||
self::assertContains('Garten', array_column($list, 'title'), 'Ein nicht übersetzter Beitrag steht in der Hauptsprache in der Liste.');
|
||||
self::assertContains('A new roof for a farmhouse', array_column($list, 'excerpt'));
|
||||
|
||||
$archive = $this->request('/en/beitraege');
|
||||
$html = (string) $archive->getContent();
|
||||
self::assertSame(200, $archive->getStatusCode());
|
||||
self::assertStringContainsString('<html lang="en"', $html);
|
||||
self::assertStringContainsString('>Posts</h1>', $html);
|
||||
self::assertStringContainsString('href="/en/new-roof"', $html);
|
||||
self::assertStringContainsString('href="/garten"', $html);
|
||||
self::assertStringContainsString('>Projects</a>', $html);
|
||||
self::assertStringContainsString('href="/en/kategorie/projekte"', $html);
|
||||
$category = (string) $this->request('/en/kategorie/projekte')->getContent();
|
||||
self::assertStringContainsString('>Projects</h1>', $category);
|
||||
self::assertStringContainsString('>Beiträge</h1>', (string) $this->request('/beitraege')->getContent());
|
||||
|
||||
$post = (string) $this->request('/en/new-roof')->getContent();
|
||||
self::assertStringContainsString('<title>New roof</title>', $post);
|
||||
self::assertStringContainsString('>Projects</a>', $post);
|
||||
|
||||
$missing = $this->request('/en/gibt-es-nicht');
|
||||
self::assertSame(404, $missing->getStatusCode());
|
||||
self::assertStringContainsString('<html lang="en"', (string) $missing->getContent());
|
||||
self::assertStringContainsString('Page not found', (string) $missing->getContent());
|
||||
self::assertStringContainsString('Seite nicht gefunden', (string) $this->request('/gibt-es-nicht')->getContent());
|
||||
|
||||
$consent = new \App\Content\Consent($this->settings);
|
||||
$request = Request::create('/en');
|
||||
$request->attributes->set('kant_locale', 'en');
|
||||
self::assertSame('Your choice', $consent->state($request)['title']);
|
||||
self::assertSame('', $consent->state(Request::create('/'))['title']);
|
||||
}
|
||||
|
||||
private function request(string $url, string $method = 'GET', array $body = []): Response
|
||||
{
|
||||
$previous = [];
|
||||
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
|
||||
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
|
||||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||
}
|
||||
$kernel = new Kernel('test', false);
|
||||
try {
|
||||
$request = Request::create($url, $method, $body);
|
||||
$response = $kernel->handle($request);
|
||||
$kernel->terminate($request, $response);
|
||||
|
||||
return $response;
|
||||
} finally {
|
||||
$kernel->shutdown();
|
||||
foreach ($previous as $key => [$server, $env]) {
|
||||
if (null === $server) {
|
||||
unset($_SERVER[$key]);
|
||||
} else {
|
||||
$_SERVER[$key] = $server;
|
||||
}
|
||||
if (null === $env) {
|
||||
unset($_ENV[$key]);
|
||||
} else {
|
||||
$_ENV[$key] = $env;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user