Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28b2442188 | ||
|
|
8e628b9417 | ||
|
|
13f6759df8 | ||
|
|
48ae2443cd | ||
|
|
f00fbfef09 | ||
|
|
08998de533 | ||
|
|
6af2e2aead | ||
|
|
88737a95a9 | ||
|
|
e1753fa7e4 | ||
|
|
5bfe1f3c6e | ||
|
|
0ba82b7d33 | ||
|
|
668287f640 | ||
|
|
5e5d380fd3 | ||
|
|
6fb6d30e8d | ||
|
|
ace6c50743 | ||
|
|
82a74277cb | ||
|
|
5d55a65c22 | ||
|
|
24c1d9c871 | ||
|
|
a976c146de | ||
|
|
3ea28a03fc | ||
|
|
711af312af | ||
|
|
31fb53c1fc | ||
|
|
24f98633d0 | ||
|
|
e47f278a13 | ||
|
|
e8a9dcdf5c | ||
|
|
04927b9563 | ||
|
|
3b1dfc0948 | ||
|
|
e21e800f2c | ||
|
|
d8c8cbe7bd | ||
|
|
45d1141951 | ||
|
|
42e74af052 | ||
|
|
106f84dfd5 | ||
|
|
6632836580 | ||
|
|
d0019c80af | ||
|
|
ab9e6a7ea0 | ||
|
|
8328bf0964 | ||
|
|
245f189d57 | ||
|
|
99b3aba39a | ||
|
|
c91f996524 | ||
|
|
0ed4c83e41 | ||
|
|
9e0365f841 | ||
|
|
ff2b57db58 | ||
|
|
741eaa80f7 | ||
|
|
23fdca4edb | ||
|
|
a8368068aa | ||
|
|
f4ec0d9877 | ||
|
|
d08d6f648a | ||
|
|
4539f357c1 | ||
|
|
d885b3ddb5 | ||
|
|
59a40157a4 |
No files matched your search
+83
-3
@@ -25,9 +25,9 @@
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-1d19ca1bb9ca/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-a1c96276da5c/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=24M -d post_max_size=26M -d memory_limit=256M -S 127.0.0.1:8092 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8091
|
||||
"port": 8092
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
@@ -79,6 +79,26 @@
|
||||
],
|
||||
"port": 8094
|
||||
},
|
||||
{
|
||||
"name": "ebsolar",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8096'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8096 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8096
|
||||
},
|
||||
{
|
||||
"name": "ebsolar-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8097'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8097 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8097
|
||||
},
|
||||
{
|
||||
"name": "immobilien-deeg-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
@@ -88,6 +108,66 @@
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8095'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8095 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8095
|
||||
},
|
||||
{
|
||||
"name": "kantarci-immobilien",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8098'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8098 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8098
|
||||
},
|
||||
{
|
||||
"name": "kantarci-immobilien-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8099'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8099 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8099
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,24 @@ 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.
|
||||
|
||||
## Kaufmännisches Und und Zeilenumbrüche bleiben, wie sie sind (ab 1.20.1)
|
||||
|
||||
**Behoben.** Ein Text ohne Auszeichnung wurde beim Speichern doppelt kodiert: aus `&` wurde `&`, beim nächsten Speichern `&amp;` – irgendwann stand die Entity sichtbar auf der Seite. Ebenso wuchsen Zeilenumbrüche: jedes zweite Speichern machte aus einem `<br>` zwei. Beides ist behoben, und wiederholtes Speichern ändert einen Text jetzt nicht mehr. Was bereits falsch gespeichert ist, räumt `php bin/console cms:repair:entities` auf: ohne Option zeigt er nur, was sich ändern würde, mit `--schreiben` wendet er es an. Angefasst werden die veröffentlichte Fassung und der Entwurf jeder Seite sowie die Bereiche; ältere Fassungen bleiben Historie.
|
||||
|
||||
## Eigenes HTML und eingebettete Seiten (ab 1.20.0)
|
||||
|
||||
Der Baustein **Eigenes HTML** (Werkzeugkasten, Gruppe „Seite und Daten“) nimmt Markup auf, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. Ändern darf ihn nur ein **Administrator**, genau wie das Feld „Eigenes Skript“; für alle anderen ist er sichtbar und schreibgeschützt, und der Server weist eine Änderung ohnehin ab. Zwei Tags nimmt er nicht an: `<script>` gehört unter „Erweitert“ in „Eigenes Skript“, und ein `<iframe>` in den Baustein **Externer Inhalt** – nur der fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein (ADR 106).
|
||||
|
||||
Am eingebetteten Rahmen lässt sich alles gestalten, was dieser Website gehört: Höhe als Eigenschaft, Abstände, Breite, Rand und Ecken über Design-Tokens und freie Stile, dazu eigenes CSS am Block (`& iframe{…}`) und für den Platzhalter vor der Einwilligung (`&.embed-blocked`, `& .embed-title`, `& .consent-button`). **Was im Rahmen steht, gehört dem Anbieter** – daran kommt kein CSS dieser Website heran, das trennt der Browser zwischen zwei Herkünften. Den Einwilligungsbalken selbst gestaltet man unter **Einstellungen** (Form und vier Farben).
|
||||
|
||||
## Adresse mit oder ohne www, Rollen wie versprochen, Gesamtprüfung (ab 1.17.0)
|
||||
|
||||
**Eine Adresse.** `SITE_ORIGIN` in `.env` entscheidet, ob die Website mit oder ohne `www` heißt; die andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um (auch die Verwaltung), ein fremder Host bleibt erreichbar. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt die Einstellung, der Bildschirm „System“ zeigt sie und warnt, wenn die Verwaltung gerade unter einer anderen Adresse läuft. Der www-Block in `public/.htaccess` ist weg.
|
||||
@@ -202,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.
|
||||
@@ -270,7 +290,7 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
@@ -363,6 +383,22 @@ Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `v
|
||||
|
||||
Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Hausverwaltung und Immobilien GmbH) liegt in `var/immobilien-deeg.db` mit Medien in `var/immobilien-deeg-media`; die Startkonfiguration `immobilien-deeg` in `.claude/launch.json` nutzt Port 8094, `immobilien-deeg-prod` denselben Bestand im Produktionsmodus auf Port 8095 zum Messen. `scripts/seed-immobilien-deeg.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `bewertung` (Bewertungsanfrage mit Auswahl Wohnung, Haus, Mehrfamilienhaus, Grundstück); `sh scripts/reset-immobilien-deeg.sh [Bilderordner]` (Standard `var/immobilien-deeg-assets`: Logo und zwei Architekturfotos aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Sechste lokale Referenzwebsite: EB Solar
|
||||
|
||||
Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden) liegt in `var/ebsolar.db` mit Medien in `var/ebsolar-media`; die Startkonfiguration `ebsolar` in `.claude/launch.json` nutzt Port 8096, `ebsolar-prod` denselben Bestand im Produktionsmodus auf Port 8097 zum Messen. `scripts/seed-ebsolar.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als wiederverwendbare Sektion, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge mit Galerie und Bildbetrachter, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `solarcheck` (acht Schritte wie das Original auf ebsolar.de, die fünf Auswahlfragen als Bildkarten – gezeichnet und gerastert von `node scripts/build-ebsolar-options.mjs`); `sh scripts/reset-ebsolar.sh [Bilderordner]` (Standard `var/ebsolar-assets`: Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos und `projects.json` aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Siebte lokale Referenzwebsite: KANTARCI Immobilien (Landingpage)
|
||||
|
||||
Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien GmbH, Baden-Baden) liegt in `var/kantarci-immobilien.db` mit Medien in `var/kantarci-immobilien-media`; die Startkonfiguration `kantarci-immobilien` in `.claude/launch.json` nutzt Port 8098, `kantarci-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8099 zum Messen. `sh scripts/reset-kantarci-immobilien.sh [Bilderordner]` (Standard `var/kantarci-immobilien-assets` mit `kantarci-banner.jpeg` aus dem gelieferten Paket) schneidet Logo, Hausmotiv und Seitensymbol aus dem Banner (`scripts/build-kantarci-assets.php`), zeichnet die Bildkarten der Mietanfrage (`node scripts/build-kantarci-options.mjs`), wirft die Installation weg und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` (drei Schritte mit Bildkarten und Zusammenfassung) auf. Schriften sind Arial und Georgia, es gibt nichts zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### 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`.
|
||||
@@ -38,6 +38,9 @@ services:
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Content\DocumentLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
@@ -79,6 +82,11 @@ services:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
# Eigene kurze Zeitgrenzen statt des Download-Dienstes mit denen fuer Releasearchive.
|
||||
App\Ops\OriginCheck:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$download: null
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
+74
-2
@@ -2,6 +2,76 @@
|
||||
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## 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)
|
||||
|
||||
Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte.
|
||||
|
||||
**Den Rahmen gab es schon.** Der Baustein `embed` („Externer Inhalt“, Schema v14) bettet eine fremde Seite ein: Adresse, Titel, Anbieter, Höhe – und davor die Einwilligungsfrage. Vor der Einwilligung wird nichts geladen, kein Bild und keine Anfrage; erst danach kommt der Host in `frame-src` der Sicherheitsrichtlinie. Ein `<iframe>`, das jemand als rohes Markup einfügt, hätte beides nicht: keine Frage, und der Browser lädt es gar nicht erst, weil `frame-src` den Host nicht kennt. Deshalb ist `<iframe>` im HTML-Baustein eine Fehlermeldung mit dem Weg zum richtigen Baustein – nicht etwas, das man einfügen kann und das dann schweigend nichts tut. Dasselbe gilt für `<script>`: das Feld „Eigenes Skript“ unter „Erweitert“ liefert es als eigene Datei aus, inline würde die Richtlinie es ohnehin blockieren.
|
||||
|
||||
**Der Baustein (Schema v24).** `html` mit einer einzigen Eigenschaft: das Markup, bis 20.000 Zeichen, unverändert ausgegeben. Es gibt keine Filterliste – wer ihn setzen darf, ist die eigentliche Antwort, und die ist dieselbe wie beim eigenen Skript: **nur ein Administrator**. Geprüft wird wie dort gegen den gespeicherten Stand (`CustomCode::assertHtmlUnchanged()`), in beiden Controllern und auch beim Zurückholen einer alten Fassung – ein Redakteur kann eine Seite mit HTML also weiter bearbeiten, ohne das HTML anfassen zu können. Im Editor ist das Feld für ihn sichtbar und schreibgeschützt, wie das Skriptfeld.
|
||||
|
||||
**Warum keine Filterung wie bei Rich Text.** Genau die Fälle, für die man den Baustein nimmt – ein fremdes Widget, eine eigene Tabelle, eine Auszeichnung, die es als Baustein nicht gibt – fallen durch jede Whitelist. Ein gefilterter HTML-Baustein hätte den Wunsch nicht erfüllt, sondern nur verschoben.
|
||||
|
||||
**Folgen.** `README.md` nennt den Baustein und seine Rolle. Nicht gebaut: eine Vorschau, die fremde Skripte ausführt (die Editorvorschau führt keine aus, das bleibt so), und eine Ausnahme von der Administratorregel für einzelne Seiten.
|
||||
|
||||
## ADR 105 – Designpakete in Plugins zurückgenommen: das Design gehört der Website (16.09.2026)
|
||||
|
||||
Umut hatte auf der DEEG-Installation „Nachtblau“ eingeschaltet und wieder ausgeschaltet – und die Farben blieben. Die Ursache war nicht der Schalter (`activate()`/`deactivate()` fassen das Design nie an), sondern der zweite Knopf daneben: „Designpaket übernehmen“ schrieb die Palette des Plugins über `Theme::save()` endgültig in die Website. Technisch war das gewollt (ADR 103: ein Import, damit sich das Ergebnis unter Design weiter anpassen lässt und beim Ausschalten nichts verlorengeht) – in der Bedienung war es eine Falle: eine Aktion, die die gesamte Palette ersetzt, ohne Rückfrage und ohne Weg zurück, auf einem Bildschirm, wo daneben ein Schalter sitzt, den man gefahrlos umlegen kann.
|
||||
@@ -116,7 +186,7 @@ Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so e
|
||||
|
||||
**Anlass:** Umut: „URL sollte aus env kommen, www oder ohne, die Seite sollte dann darauf reagieren.“ Bis dahin stand die Umleitung auskommentiert in `public/.htaccess` und musste von Hand zur Einstellung `SITE_ORIGIN` passen (LIVEGANG, Punkt 3). Zwei Stellen für eine Entscheidung laufen auseinander – und dann nennt jede Seite in Canonical, Sitemap und Feed eine Adresse, die weiterleitet.
|
||||
|
||||
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft.
|
||||
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft. Seit 1.21.8 lässt sie sich dort auch ändern, wie die „WordPress-Adresse“: Das Feld schreibt über `Environment::write` in `.env`, aber erst nachdem `OriginCheck` die Installation unter der neuen Adresse erreicht hat (`GET /.well-known/kantsites-origin` liefert einen HMAC aus `APP_SECRET`). Eine Umstellung auf www ohne DNS oder Zertifikat würde sonst jede Seite samt Verwaltung ins Leere leiten. „Trotzdem übernehmen“ erscheint erst nach einer Ablehnung. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld.
|
||||
|
||||
**Bewusst nicht:** ein fremder Host wird nicht umgeleitet. Die Vorschauadresse des Hosters, eine IP-Adresse, `localhost`, eine Subdomain – alles bleibt erreichbar, auch wenn `SITE_ORIGIN` etwas anderes nennt. Sonst sperrte eine falsch eingetragene Adresse jeden aus, samt der Verwaltung, die man bräuchte, um es zu bemerken. Nur lesende Anfragen (GET, HEAD) werden umgeleitet: ein 301 macht aus einem POST ein GET und verwirft die Eingabe. Und HTTPS erzwingt die Anwendung nicht: hinter einem Proxy sieht PHP oft nur `http`, ohne konfigurierte vertrauenswürdige Proxys wäre das eine Schleife. Die drei Zeilen dafür bleiben in `.htaccess`, wo der Webserver die Verbindung wirklich kennt.
|
||||
|
||||
@@ -699,6 +769,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.
|
||||
@@ -1042,7 +1114,7 @@ Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon is
|
||||
|
||||
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
|
||||
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein quadratischer Ausschnitt in 32 px (Reiter), 180 px (Startbildschirm) sowie 48 und 192 px (Google nimmt nur Vielfache von 48), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png`, `/favicon-192.png`, `/favicon.ico` (48 px) und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt. Auch `/favicon.svg` beantwortet die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen, mit eigenem ein 302 auf das 192er – als Datei in `public/` hätte der Webserver immer das mitgelieferte Zeichen ausgeliefert, und Suchmaschinen hätten es behalten.
|
||||
|
||||
Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
|
||||
|
||||
|
||||
+309
-1
@@ -1,5 +1,313 @@
|
||||
# Projektstatus
|
||||
|
||||
## 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.
|
||||
|
||||
- **Sprungziel im Menü:** Eine Auswahl aus den Ankern der verknüpften Seite, beschriftet mit der ersten Überschrift des Bausteins, etwa „So erreichen Sie uns (#kontakt)“. Die Liste liefert `GET /admin/api/sprungziele/{id}` (Rolle wie die Menüs). Wechselt die Seite, lädt die Liste neu. Ein gespeicherter Anker, den es nicht mehr gibt, bleibt als „(auf der Seite nicht gefunden)“ sichtbar. Ohne Skript bleibt das Textfeld.
|
||||
- **Linkfelder in der Plugin-Verwaltung und die Adresse eines eigenen Menülinks:** Das Textfeld bleibt, weil eine fremde Adresse getippt wird. Daneben stehen „Seite wählen“ und „PDF wählen“ (`link-field.js`, Markup `data-link-field`/`data-link-input`).
|
||||
- **Kategorien und Schlagwörter eines Beitrags:** Die vorhandenen Namen stehen als Knöpfe unter dem Feld. Ein Klick nimmt einen Namen auf oder heraus (`term-chips.js`), neue Namen tippt man weiter ein. So entstehen nicht „Projekt“ und „Projekte“ nebeneinander.
|
||||
|
||||
**Bekannt, nicht neu:** axe meldet im Menü-Editor `nested-interactive`, weil der Ziehknopf in der Zusammenfassung jedes Eintrags steckt. Die Editor-Prüfung prüft dort nur die neuen Felder.
|
||||
|
||||
**Geprüft:** neue Tests `EditorSecurityTest::testMenuAnchorsAndPostTermsAreOfferedForChoosing` und `…testPluginSettingsForLinksAndImagesAreChosenNotTyped`. Die Editor-Prüfung wählt im Menü eine Seite als eigenen Link, prüft die Sprungziel-Auswahl eines neuen Seitenlinks, legt einen Beitrag mit Kategorie an und nimmt die Kategorie per Klick auf und wieder heraus (jeweils mit axe).
|
||||
|
||||
## Fassung 1.22.2: Auswahl statt abgetippter Kennungen und Adressen (22.09.2026)
|
||||
|
||||
**Anlass:** Umut zu den Beitragseinstellungen: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild“, dann: „Andere bereiche prüfen ob es sowas noch gibt!“
|
||||
|
||||
Die ganze Verwaltung und der Editor wurden nach Feldern durchsucht, in die man eine Kennung, eine Adresse oder einen technischen Namen tippen musste, der auf etwas anderes zeigt. Umgebaut:
|
||||
|
||||
- **Vorschaubild beim Teilen und Beitragsbild** (Seiten und Beiträge → Einstellungen): Bildfeld mit Vorschau, „Bild wählen oder hochladen“ im Mediathek-Dialog, „Entfernen“ (`admin/media-field.html.twig`, `media-field.js`, Twig-Funktion `kant_media`). Die Kennung steht nur noch im versteckten Feld.
|
||||
- **Plugin-Einstellungen der Art Bild:** dasselbe Bildfeld statt „Kennung aus der Mediathek“.
|
||||
- **Editor, Formular-Baustein, Sektions-Baustein, Beitragsliste (Kategorie, Schlagwort):** Auswahllisten statt Adressen. Die Listen kommen mit `/api/design` (`choices`). Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt als „(nicht mehr vorhanden)“ sichtbar.
|
||||
- **Linkziele im Editor** (Button, Bild, Symbolkarte, CTA, Symbol, Link-Felder von Plugins) und **Links im Textbaustein:** „Seite wählen“ mit Seitensuche (`openPageDialog` in `page-picker.js`) neben „PDF aus Mediathek wählen“. Freie Adressen bleiben möglich. Der Schalter „In neuem Tab öffnen“ erscheint bei Plugin-Feldern nicht mehr, deren Schema kennt ihn nicht.
|
||||
- **Weiterleitungen, neue Adresse:** Seitensuche. Ein anderer Pfad (etwa `kategorie/projekte`) lässt sich weiter eintippen (`data-free` im Seitenwähler).
|
||||
- **Formular-Editor:** „Antwortadresse aus Feld“ wählt aus den E-Mail-Feldern, „Nur zeigen, wenn“ aus den Auswahlfeldern weiter oben, mit Kästchen für die Antworten. „Bild je Option“ öffnet den Mediathek-Dialog mit Vorschau statt einer Aufklappliste. Ohne Skript bleiben die Textfelder, die gespeichert werden.
|
||||
|
||||
**Bewusst getippt bleibt:** was an der Stelle erst entsteht (Adresse einer neuen Seite, eines Formulars, einer Sektion, Farb- und Schriftschlüssel, technischer Feldname, Anker), die alte Adresse einer Weiterleitung, Einbettungsadressen, Schlüssel für Turnstile. Noch offen: Link-Einstellungen von Plugins in der Plugin-Verwaltung (Textfeld), Sprungziel im Menü, Vorschläge vorhandener Kategorien und Schlagwörter in den Beitragseinstellungen.
|
||||
|
||||
**Geprüft:** neue Tests `EditorSecurityTest::testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId` und `…testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses`. Die Editor-Prüfung (`npm run test:editor`) wählt jetzt im Browser ein Vorschaubild und speichert es. Sie wählt eine Seite als Linkziel (mit axe) und prüft, dass Formular, Kategorie und Schlagwort Auswahlfelder sind. Im Formular-Editor wählt sie Bedingung, Antwortadresse und ein Optionsbild, legt das Formular an und liest es wieder (mit axe).
|
||||
|
||||
## Fassung 1.22.1: vergrößerbare Galerie mit einem Haken (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bei Klick vergrößern bei Galerie muss man da alles einzeln machen? kann man bei Galerie das mit einem Klick für alle elemente machen?“
|
||||
|
||||
- Der Haken „Bilder vergrößerbar“ an der Galerie gilt jetzt für jedes Bild darin, das unter „Beim Klick“ nichts selbst gewählt hat. Das gilt auch für später hinzugefügte Bilder. Bisher schaltete er nur das Blättern im Popup ein, und jedes Bild musste einzeln auf „Bild vergrößern“ gestellt werden.
|
||||
- Eine eigene Wahl geht vor: „Nichts tun“ bleibt still, ein Link bleibt ein Link. „Nie eingestellt“ und „Nichts tun“ sind im Dokument unterscheidbar, weil der Editor `click` erst beim Ändern speichert. Bestehende Galerien ändern sich deshalb nur dort, wo der Haken gesetzt ist und ein Bild noch nie eingestellt wurde.
|
||||
- Der Editor zeigt bei einem solchen Bild „Bild vergrößern“ als aktuelle Wahl, nach derselben Regel wie die Vorlage (`galleryZoom` in `blocks/block.html.twig`).
|
||||
|
||||
**Geprüft:** neuer Test `CmsTest::testAZoomableGalleryMakesEveryImageWithoutItsOwnChoiceZoomable` (ohne Wahl, „Nichts tun“, Link, Galerie ohne Haken), volle Kette vor dem Release.
|
||||
|
||||
## PDFs in der Mediathek, als Linkziel und im Betrachter (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „kann man aktuell PDFs hochladen und als Button verlinken? … und ein Inline PDF Viewer wie Bild-Popup“, danach: „ja setz um, einfacher Browser-Betrachter“.
|
||||
|
||||
- **Hochladen:** Das Upload-Feld der Mediathek nimmt jetzt neben JPEG, PNG und WebP auch PDFs bis 20 MB an. Ob eine Datei ein PDF ist, entscheidet ihr Inhalt (`%PDF-` in den ersten 1024 Bytes), nicht die Endung. Die PDFs stehen in einer eigenen Tabelle `document` (Migration `20260917000100`) und nicht in `media`. Jede Bildauswahl erwartet dort Varianten mit Breite und Höhe. Die Datei liegt unverändert als `<id>.pdf` im Medienordner und geht deshalb mit jeder Sicherung mit. Die Mediathek zeigt die PDFs unter den Bildern, mit Titel und Archivieren.
|
||||
- **Ausliefern:** `/media/<id>.pdf` kommt mit `Content-Type: application/pdf`, `nosniff`, `inline` und dem Titel als Dateinamen, `X-Robots-Tag: noindex` und `frame-ancestors 'self'`: Einbetten darf es nur die eigene Website. Archivierte PDFs bleiben erreichbar, wie Bilder.
|
||||
- **Verlinken:** Jedes Linkziel (Button, Bild, Symbolkarte, CTA, Symbol) hat den Knopf „PDF aus Mediathek wählen“ mit einer Liste, Suche und Hochladen direkt im Dialog. Ins Feld kommt die Adresse `/media/<id>.pdf`, ein gewöhnlicher lokaler Pfad, also ohne neue Schemaversion.
|
||||
- **Betrachter:** `public/assets/lightbox.js` (dieselbe Datei wie der Bildbetrachter, damit eine Seite kein Skript mehr lädt) zeigt ein verlinktes PDF in einem `<dialog>` mit `<iframe>` und nutzt dafür den PDF-Betrachter des Browsers, dazu Herunterladen, „Im neuen Tab“ und Schließen. Wo der Browser keinen eingebauten Betrachter hat (`navigator.pdfViewerEnabled`) oder das Gerät ein Touchgerät ist, öffnet sich das PDF im neuen Tab: Android-Chrome hat keinen Betrachter, iOS-Safari zeigt im Rahmen nur die erste Seite. Ist „In neuem Tab öffnen“ gesetzt oder das Skript nicht geladen, bleibt es ein normaler Link. Skript und `frame-src 'self'` kommen nur auf Seiten, deren Dokumente (auch Kopf, Fuß, Sektionen und Fließtext) auf ein PDF der Mediathek zeigen.
|
||||
- **Grenzen:** Hochgeladene PDFs sind über ihre Adresse öffentlich. Kein PDF.js, also sieht der Betrachter je nach Browser anders aus. Chrome zeigt im Kopf des Betrachters die Kennung aus der Adresse, nicht den Titel. PDFs werden nicht gelöscht, nur archiviert.
|
||||
|
||||
**Geprüft:** zwei neue Tests (`CmsTest::testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer`, `EditorSecurityTest::testPdfsGoThroughTheMediaUploadAndTheEditorPicker`: Ablehnung von Nicht-PDFs, Kopfzeilen, 404, Skript und Richtlinie nur mit PDF-Link, Formular-Upload, API mit Token, Archiv). Im Browser mit der Pilotkopie: Klick auf den Button öffnet das zweiseitige PDF im Popup, Schließen gibt den Fokus an den Button zurück, bei 375 px mit Touch öffnet es einen neuen Tab. Der Auswahldialog im Editor ist nur per Syntaxprüfung geprüft, nicht im Browser. Dafür wäre eine Anmeldung nötig.
|
||||
|
||||
## Siebte Installation: Landingpage „KANTARCI Immobilien – Mietanfrage“ (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „neues Projekt wir brauchen eine einfache landingpage nach gleichem prinzip. einfache landingpage impressum und datenschutz notwendig? baus ein“ – dazu das Paket `KANTARCI-Immobilien-Landingpage.zip` (Next.js/Vinext für Cloudflare mit D1-Datenbank: Kopf, dunkler Hero, Werteleiste, Mietanfrage, Fuß, ein Banner als einziges Bild). Später: „Formular gefällt mir noch nicht kannst du ein multipage formular machen ggfls mit icon checkbox“ und die Anbieterdaten (Geschäftsführer Ridvan Kantarci, Ooser Hauptstr. 51, 76532 Baden-Baden, info@kantarci-immo.de).
|
||||
|
||||
**Neu.** Datenbank `var/kantarci-immobilien.db`, Medien `var/kantarci-immobilien-media`, Bildquellen `var/kantarci-immobilien-assets` (das Banner, daraus zugeschnitten Logo, Hausmotiv und Seitensymbol, dazu `options/` mit 14 Bildkarten), Startkonfigurationen `kantarci-immobilien` (Port 8098, dev) und `kantarci-immobilien-prod` (Port 8099). `sh scripts/reset-kantarci-immobilien.sh` schneidet die Bilder zu (`scripts/build-kantarci-assets.php`, GD), zeichnet die Bildkarten (`node scripts/build-kantarci-options.mjs`, Chromium), migriert und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf, Fuß, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` auf. Schriften Arial und Georgia (System).
|
||||
|
||||
- **Impressum und Datenschutz sind Pflicht:** geschäftlicher Auftritt (§ 5 DDG) und ein Formular mit personenbezogenen Daten (Art. 13 DSGVO). Offen und im Text als „NOCH ZU PRÜFEN“ markiert: Handelsregister, Telefonnummer, USt-IdNr., Erlaubnis nach § 34c GewO (nur bei Vermittlung/Verwaltung für Dritte), Urheber des Hausfotos, Hosting- und Mail-Auftragsverarbeiter, Löschfrist.
|
||||
- **Mietanfrage in drei Schritten** mit Zusammenfassung: Haushalt (Bildkarten Allein, Zu zweit, Familie mit Kindern, Wohngemeinschaft; Personenzahl nur bei den beiden letzten), Wohnwünsche (Zimmer als Bildkarten, Ausstattung als Mehrfachauswahl mit sechs Bildkarten, Einzug, Warmmiete, Objekt), Kontaktdaten. Der Einzug ist eine Auswahl statt eines Datums – der Formularbaukasten kennt kein Datumsfeld.
|
||||
- **E-Mail:** Die Vorlage nennt `immo@kantarci-immobilien.de`, die Domain existiert aber nicht (kein DNS, 18.09.2026). Überall steht jetzt `info@kantarci-immo.de` (MX vorhanden), auch als Empfänger der Anfrage.
|
||||
- **CMS-Lücken beim Aufbau:** eigenes CSS kennt kein `@media` – Raster, die am Telefon einspaltig werden sollen, laufen über `repeat(auto-fill,minmax(…))`. Verschachtelte Selektoren erlauben keine Umlaute, also auch kein `[data-label="Gewünschter Einzug"]` (Feld heißt deshalb „Einzugstermin“). Die Zusammenfassung ist selbst ein `.form-step`; wer die Schritte zweispaltig setzt, muss `.form-summary-body` über beide Spalten legen. `<small>` in einem Textbaustein wird entfernt. Die Fehlermeldung des Formulars duzt („Bitte prüfe …“), die Seite siezt.
|
||||
|
||||
**Geprüft:** Aufbau per Reset-Skript fehlerfrei; Startseite, Impressum, Datenschutz 200, unbekannte Adresse 404; im Browser (1280 px und 375 px, kein seitliches Scrollen) alle drei Schritte, Bedingung Personenzahl, Zusammenfassung und Absenden – die Anfrage liegt vollständig in `form_submission`. Mailversand ist lokal nicht eingerichtet. Lighthouse und axe noch nicht gelaufen.
|
||||
|
||||
## Adresse der Website in der Verwaltung (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: „ja bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird und übernommen wird“ – mit oder ohne www wie die „WordPress-Adresse“, statt nur über `cms:site:origin` oder die `.env` von Hand.
|
||||
|
||||
- **System → Diese Installation → Adresse** hat ein Feld „Adresse der Website“ und einen Link, der direkt auf die andere Schreibweise (mit/ohne www) umstellt. Gespeichert wird `SITE_ORIGIN` in `.env` über `Environment::write`. Das gilt ab der nächsten Anfrage, weil `%env()%` zur Laufzeit gelesen wird. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld, und ein POST wird abgewiesen.
|
||||
- **Schutz vor dem Aussperren:** Vor dem Speichern fragt die Installation sich selbst unter der neuen Adresse an (`OriginCheck`, `GET /.well-known/kantsites-origin`). Zurückkommen muss ein HMAC aus `APP_SECRET`, den nur diese Installation kennt. Ohne DNS-Eintrag oder Zertifikat für www wird nichts geändert – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen. Erst nach einer Ablehnung erscheint „Trotzdem übernehmen“ (etwa wenn der Server sich selbst nicht erreicht, oder bei http im lokalen Betrieb). Die Prüfadresse ist auch im Wartungszustand erreichbar.
|
||||
- Wechselt die Verwaltung dadurch den Host, ist dort eine neue Anmeldung nötig. Das Sitzungscookie gilt nur für die Schreibweise, unter der es entstand. Die Meldung sagt das.
|
||||
|
||||
**Geprüft:** zwei neue Tests in `SystemScreenTest` (Server-Umgebung ohne Feld, Ablehnung einer nicht erreichbaren Adresse ohne Schreiben in `.env`, falsche Eingaben, Prüfadresse; `OriginCheck` mit fremder und eigener Kennung). Im Browser mit der Pilotkopie bei 1280 und 375 px (kein seitliches Scrollen) Feld, Ablehnung und „Trotzdem übernehmen“ angesehen.
|
||||
|
||||
## Favicon auch für die Google-Suche (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: Google zeigt bei Elektro Bernhard weiter das KantSites-K, obwohl ein eigenes Symbol gesetzt ist – „mach das im Kern sobald man Favicon setzt alles ersetzen“.
|
||||
|
||||
- **Ursache:** Das eigene Symbol gab es nur in 32 und 180 px, Google nimmt nur Vielfache von 48 px. Und `/favicon.svg` lag als Datei in `public/`, der Webserver lieferte also immer das K aus – dort hatte Google es beim ersten Besuch gefunden.
|
||||
- **Neu:** `FAVICON_SIZES` sind 32, 48, 180 und 192. Der Kopf verweist zusätzlich auf `/favicon-192.png` (`sizes="192x192"`). `/favicon.ico` liefert das 48er-PNG. `/favicon.svg` beantwortet jetzt die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen (liegt jetzt unter `public/assets/favicon.svg`), mit eigenem ein 302 auf `/favicon-192.png`. Fehlen bei älteren Installationen die neuen Größen, entstehen sie beim ersten Abruf aus der gespeicherten Einstellung. Wird das Symbol entfernt, gelten die liegengebliebenen PNGs nicht mehr.
|
||||
- **Update:** Das Archiv-Update kopiert `public/` nur hinzu; `ENTFALLEN_IN_PUBLIC` löscht deshalb die alte `public/favicon.svg`, sonst bliebe sie auf dem Server und überdeckte die Route.
|
||||
- **Danach bei Google:** In der Search Console die Startseite unter „URL prüfen“ neu zur Indexierung einreichen. Google holt Favicons selten neu ab, das kann trotzdem Tage bis Wochen dauern.
|
||||
|
||||
**Geprüft:** neuer Test `testGoogleGetsTheOwnSiteIconUnderEveryAddress` und der bisherige Favicon-Test grün, Update-Tests unverändert (eine vorbestehende Warnung), php-cs-fixer sauber; lokal (ohne eigenes Symbol) `/favicon.svg` 200, `/favicon.ico` → `/favicon.svg`.
|
||||
|
||||
## Favicon mit Zuschnitt, Pflichtseiten per Suche, neueste zuerst (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Was ein Dropdown? nicht dein ernst? Sofort fixen über Mediathek auswählbar machen zurecht schneidbar machen und auch uploadbar dort machen!“ – dazu „Genauso Adresse Datenschutz und Impressum über search felder auswählbar machen“ und „Beiträge und Seiten sortieren absteigend also neueste zuerst“.
|
||||
|
||||
- **Symbol der Website** hat einen eigenen Abschnitt in den Einstellungen statt einer Auswahlliste am Ende der Unternehmensdaten. Der Knopf öffnet die Mediathek (dort auch Upload), danach folgt ein Zuschnitt: Quadrat ziehen, Größe an der Ecke, per Regler oder mit +/−, Pfeiltasten zum Verschieben, Vorschau in 180 und 32 px. Gespeichert wird `faviconCrop` als `links,oben,kante` in Anteilen (links/oben von Breite/Höhe, Kante von der kürzeren Seite), `renderFavicon()` schneidet daraus die PNGs. `faviconVersion` hängt am Verweis; ohne Zuschnitt bleibt es bei den ersten acht Zeichen der Bildkennung, bestehende Verweise ändern sich also nicht.
|
||||
- `openMedia()` kennt `needsAlt: false`: Für das Symbol wird kein Alternativtext verlangt. Die Stile des Mediathek-Dialogs stehen jetzt in `media-dialog.css` statt in `editor.css`, damit der Dialog auch außerhalb des Editors funktioniert.
|
||||
- **Datenschutz und Impressum** werden über die Inhaltssuche gewählt wie die 404-Seite. Die Suche kann jetzt statt der Kennung den Pfad speichern (`valueKey: 'path'` bzw. `data-value="path"`), gespeichert wird also weiter die Adresse.
|
||||
- **Seiten- und Beitragsliste** der Verwaltung sortieren nach Anlagedatum absteigend. Die Einrückung der Unterseiten ist dort entfallen – ohne Baumreihenfolge hätte sie eine falsche Elternseite angedeutet.
|
||||
|
||||
## Sechste Installation: der Redesign-Entwurf „EB Solar“ (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „bitte redesign aufsetzen ebsolar.de achte auf die richtlinien wie immer machs wie die anderen tagespflege, elektro-baernhard co“ – dazu das Paket `ebsolar-redesign.zip` (statische Website: Startseite, Projektübersicht, acht Projektseiten, eigene SVG-Symbole, Kontaktformular per `mailto`).
|
||||
|
||||
**Neu.** Neben Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Maler Drews und DEEG steht jetzt **EB Solar** (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden): Datenbank `var/ebsolar.db`, Medien `var/ebsolar-media`, Bildquellen `var/ebsolar-assets` (Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos, `projects.json`), Startkonfigurationen `ebsolar` (Port 8096, dev) und `ebsolar-prod` (Port 8097, `APP_ENV=prod` zum Messen). `scripts/seed-ebsolar.php` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als **wiederverwendbare Sektion**, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge, Menü und zwei Formulare; `sh scripts/reset-ebsolar.sh` wirft weg, migriert und baut neu. Schrift ist Arial (System) wie in der Vorlage, es gibt nichts zu holen.
|
||||
|
||||
**Aus der Vorlage übernommen:** Farben (`#b52c2c` Rot, `#252b30` Tinte, `#f5f6f6` helle Fläche, `#e6e8e8` Linie), Schriftgrößen, Abstände, Wortlaut und die Reihenfolge der Abschnitte – Hero mit Bildunterschrift über dem Foto, die vier Zusicherungen zwischen zwei Linien, drei Schritte, „Gutes Handwerk. Neue Energie.“ auf heller Fläche, Projektraster, Herstellerleiste, „Was kann Ihr Dach?“.
|
||||
|
||||
**Bewusste Abweichungen.**
|
||||
- Das Kontaktformular der Vorlage öffnet nur das E-Mail-Programm des Besuchers. Hier ist es das serverseitige Formular `kontakt`; die Anfrage liegt in der Verwaltung und geht an pv@elektro-bernhard.de.
|
||||
- Der **Solarcheck** ist im Paket nur ein Verweis auf ebsolar.de. Hier ist er eine eigene Seite mit dem Formular `solarcheck`: dieselben acht Schritte und dieselben Fragen wie im Original (abgerufen 16.09.2026), nur der Schieberegler für den Jahresverbrauch ist ein Zahlenfeld – ein Schieberegler ist im Formularbaustein nicht vorgesehen.
|
||||
- Die acht Projektseiten sind **Beiträge** der Kategorie „Projekte“ mit Galerie und Bildbetrachter; `/projekte` ist eine Beitragsliste. Die Projektart („Photovoltaik“, „Terrassenmontage · SolarEdge“) steht als **Schlagwort** am Beitrag und nicht als Kategorie: „vorheriges/nächstes Projekt“ folgt der ersten Kategorie, und die muss bei allen acht dieselbe sein, sonst führt die Navigation an Rheinstetten vorbei. Die Beitragsliste zeigt nur Kategorien, deshalb tragen die Karten anders als in der Vorlage keine Artzeile.
|
||||
- Impressum und Datenschutz sind eigene Seiten (Angaben vom Impressum der laufenden Website); was nur Elektro Bernhard beantworten kann, steht als „NOCH ZU PRÜFEN“ im Text.
|
||||
- Kein Cookie-Banner: es wird nichts von Dritten geladen.
|
||||
|
||||
**Bildkarten im Solarcheck (17.09.2026).** Umut: „du kannst so schöne svg's erstellen kannst du bitte noch schöne icons für den solarcheck machen statt checkboxe schöne bilder?“ Die fünf Auswahlfragen (Personen, Dachform, Dachfläche, Eindeckung, Speicher) zeigen jetzt Bildkarten statt Optionsfelder – 20 Symbole in Tinte und Rot auf heller Fläche, im Stil der übrigen Symbole der Website. `scripts/build-ebsolar-options.mjs` zeichnet sie als SVG und rastert sie mit Chromium auf 800×600 (4:3 wie die Karte), weil die Mediathek kein SVG nimmt; SVG und PNG liegen in `var/ebsolar-assets/options/`, das Aufbauskript hängt die PNGs per `images` an die Felder. Lighthouse `/solarcheck` danach: Leistung 98/98, Barrierefreiheit, Best Practices, SEO 100.
|
||||
|
||||
**Im Kern behoben: Pfeile waren Schriftzeichen.** Der Button haengte ein `↗` an, die Beitragsnavigation ein `←`/`→` – Buchstaben also, die je nach Schrift anders aussehen und mit der Schriftstaerke nicht mitwachsen. Der Symbolsatz (`templates/blocks/icon.html.twig`, `DocumentValidator::ICONS`) kennt jetzt drei Pfeile: `arrow-right`, `arrow-left`, `arrow-up-right`, gezeichnet wie die uebrigen 17 Symbole. Button und Beitragsnavigation setzen sie als eingebettetes SVG in denselben `<span aria-hidden>` wie bisher – `& span{display:none}` nimmt den Pfeil also weiterhin weg, wo eine Vorlage keinen will (DEEG). Die Groesse haengt an der Schrift des Knopfs (`.button-arrow>svg{width:1.15em}`), nicht an einer festen Zahl. Auf EB Solar tragen auch die Textlinks ihren Pfeil als Symbolbaustein neben dem Text; er zeigt auf dieselbe Adresse und ist als Schmuck von Vorlesesoftware und Tastatur ausgenommen.
|
||||
|
||||
**Im Kern behoben: zweimal derselbe Selektor in eigenem CSS loeschte die erste Regel.** `CustomCode::parse()` legte die Deklarationen je Selektor in einem Array ab – `$rules[$suffix] = …`. Standen `& .post-card{border:0}` und `& .post-card{transform:none}` untereinander, gewann die zweite, und `border:0` verschwand ohne Meldung; im Projektraster von EB Solar kam dadurch der 1-px-Rahmen des Kerns zurueck. In CSS ist zweimal derselbe Selektor eine Kaskade, kein Widerruf: die Deklarationen werden jetzt in der Reihenfolge zusammengefuegt, in der sie standen. Dasselbe galt fuer `&{…}` neben den Eigenschaften des Elements selbst – auch das ging vorher verloren.
|
||||
|
||||
**Im Kern behoben: das vorgezogene Bild wurde nicht vorab geholt.** Für ein Hintergrundbild des ersten Abschnitts schreibt `page.html.twig` längst ein `<link rel="preload" as="image">` (`heroBackground`). Für ein gewöhnliches `<img>` über der Falz fehlte das Gegenstück – der Browser fand es erst, wenn das Stylesheet geparst war, und diese Rundreise ging auf die Startseite von EB Solar (deren Startbild ein Bild und kein Hintergrund ist) direkt in die LCP-Zeit. Neu liefert `PageController::heroImage()` Quelle, Variantensatz und Größenangabe genau so, wie `blocks/block.html.twig` sie an das `img` schreibt (sonst lädt der Browser zweimal), und die Vorlage schreibt daraus die Vorabladung – aber nur, wenn nicht ohnehin schon ein Hintergrundbild vorgeholt wird.
|
||||
|
||||
**Und eine Regel fürs Aufbauen:** eine Einblendung auf dem größten Element über der Falz kostet fast eine Sekunde LCP (gemessen: 916 ms „element render delay“, mobil 92 statt 99). Das Startbild blendet deshalb nicht ein; Kleinzeile, Überschrift, Text und Knöpfe daneben schon.
|
||||
|
||||
**Geprüft:** `npm run test:links` gegen Port 8096: 17 Adressen in der Sitemap, 48 Seiten, 127 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur und ohne JavaScript bestanden, **keine redaktionellen Befunde**; Ladebudget der Startseite 14 Anfragen, 17 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8097 für `/`, `/projekte`, `/solarcheck`, `/michelbach`, `/rheinstetten`, `/impressum`, `/datenschutz`: **Barrierefreiheit, Best Practices und SEO überall 100**, Leistung 99–100, agentisches Browsing überall 100 außer `/solarcheck` mobil (98). Startseite LCP 2,1 s mobil / 0,8 s Desktop, CLS 0, TBT 0 ms; Berichte in `reports/lighthouse-ebsolar/`. Bildbetrachter der Galerie geöffnet und geblättert („1 von 4“), Beitragsnavigation stimmt mit der Reihenfolge der Vorlage überein (Michelbach ← Rheinstetten, → Au am Rhein), 404 und Solarcheck im Browser angesehen, Desktop und 375 px. PHP-Tests **581 grün** (PHP 8.4), php-cs-fixer und Twig-Lint ohne Befund.
|
||||
|
||||
**Stolperstein beim Messen:** Im Produktionsmodus liegen die uebersetzten Twig-Vorlagen in `var/cache/prod`. Nach einer Aenderung an `blocks/icon.html.twig` warf jede Seite mit Pfeil dort **500** („Key ‚arrow-right‘ … does not exist“), waehrend der Entwicklungsserver auf 8096 laengst richtig lieferte. `APP_ENV=prod … bin/console cache:clear` raeumt das auf; wer im Produktionsmodus misst, muss das nach jeder Vorlagenaenderung tun.
|
||||
|
||||
**Bekannt:** `/solarcheck` mobil hat CLS 0,08 (agentisches Browsing 98). Das Formularskript blendet nach dem Laden alle Schritte bis auf den ersten aus – ohne Skript stehen sie untereinander, mit Skript springt die Seite einmal. Der Wert liegt unter dem Ziel von 0,1, aber die Bewegung ist sichtbar; sie gehört in den Kern (Schritte serverseitig zusammenfalten), nicht ins Aufbauskript.
|
||||
|
||||
|
||||
## Startseite nach der Installation: eine Baustellenseite statt einer Anleitung (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du bitte für die Erstseite bzw. nach der Installation eine schöne Erstseite erstellen – Under Construction oder so, ohne Details mit ‚melde dich an‘ usw. Am besten auch noch Logo von KantSites: ‚Hier entsteht eine neue Webpräsenz mit KantSites‘.“
|
||||
|
||||
**Was los war.** `DemoContent::starterPage()` lieferte zwei Abschnitte mit Arbeitsanweisungen an die Betreiberin oder den Betreiber: „Melde dich an, öffne sie im Editor …“ und „Die nächsten Schritte“ mit Design, Menüs und Einstellungen. Das stand auf der einzigen Seite, die bis zum ersten Bearbeiten **öffentlich** erreichbar ist – gelesen hat es also der Besucher, nicht der Betreiber. Für den stehen dieselben Schritte längst auf dem letzten Installerbildschirm (`templates/install/done.html.twig`) und in der Verwaltung.
|
||||
|
||||
**Neu.** Eine Baustellenseite, die genau das sagt und sonst nichts: das KantSites-Zeichen (`public/assets/kantsites-logo.svg`, dieselbe Form wie das Favicon), die Kleinzeile „In Arbeit“ in der Akzentfarbe, als einzige H1 der Name der Website, darunter „Hier entsteht eine neue Webpräsenz.“, ein kurzer Trennstrich und die Zeile „Erstellt mit KantSites“. Der Abschnitt ist ein Flex-Container mit `min-height: 70vh` und einem Verlauf von `@paper` nach `@mint`, stellt den Inhalt also mittig auf den Bildschirm; die Farben sind Palettenverweise und folgen jedem Designwechsel. Alles sind gewöhnliche Bausteine mit gewöhnlichen Stilwerten – im Editor also vollständig änder- und löschbar. Die Seitenbeschreibung ist jetzt ein echter Satz statt „NOCH AUSZUFÜLLEN“, damit eine frisch installierte Website in Suchergebnissen nicht mit einem Platzhalter steht.
|
||||
|
||||
**Unverändert:** Kopf- und Fußbereich, die beiden Rechtsseiten als Gerüst mit markierten Pflichtangaben, Menüs.
|
||||
|
||||
**Geprüft:** `tests/InstallFlowTest.php` (Installation über die sechs Bildschirme, danach liefert `/` die neue Seite), `php vendor/bin/phpunit` 579 von 579. Im Browser angesehen: eine frisch migrierte Datenbank mit dem Inhalt einer Neuinstallation, Desktop und 375 px.
|
||||
|
||||
## Fassung 1.21.0: Bildecken gehören dem Designbereich (16.09.2026)
|
||||
|
||||
**Anlass:** Umut zum Hero-Bild von DEEG: „Das sollte abgerundet sein, aber wurde von irgendwo gemacht, und die Ecken sehen komisch aus. Es gibt auch im Editor gar nicht Bildecken abrunden, oder?“
|
||||
|
||||
**Was los war.** `.block-image{border-radius:12px}` stand fest in `enhancements.css` – jedes Bild jeder Installation war gerundet, ohne dass ein Feld das gesagt hätte. Auf DEEG (eine durchgehend eckige Vorlage) fiel es auf, weil über dem gerundeten Bild eckige Elemente liegen: der dunkle Verlauf, das Schild, die blaue Aufruf-Fläche. An den Ecken schaute deshalb die eckige Fläche aus der Rundung heraus.
|
||||
|
||||
**Behoben.** Die Ecken sind jetzt eine Einstellung unter **Design → Bildecken** (`theme.base.imageRadius`, geprüft wie die Inhaltsbreite, ausgegeben als `--k-image-radius`). Vorgabe bleibt `12px`, damit bestehende Websites gleich aussehen; `0px` macht alle Bilder eckig. Für ein einzelnes Bild gab es die Einstellung schon immer – im Editor unter **Design → Bild → Bildecken** (`imageRadius` im Stilschema, für Bild, Galerie, Logoleiste und Beitragsliste); sie überschreibt den Wert der Website.
|
||||
|
||||
**DEEG:** `imageRadius` auf `0px`, im Aufbauskript und in der Installation. Das Hero-Bild ist damit eckig wie die Vorlage, und die Ecken stimmen wieder.
|
||||
|
||||
**Geprüft:** neuer Test in `tests/CmsTest.php`: Vorgabe ist `12px`, eine Seite mit Bild trägt `--k-image-radius:12px`, nach dem Speichern von `0px` trägt sie `--k-image-radius:0px`, und „rund“ wird abgewiesen wie jede andere Nicht-Größenangabe.
|
||||
|
||||
## Fassung 1.20.1: das kaufmännische Und, das bei jedem Speichern wuchs (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Beim Startseitenbild wird der Text & umgewandelt, aber in der Ansicht nach dem Speichern wird & daraus.“
|
||||
|
||||
**Ursache.** `RichText::sanitise()` hat Text ohne ein einziges Tag als reinen Text behandelt und nur escaped. Was der Editor schickt, ist aber HTML – auch ohne Tag. Aus `Freudenstadt & Umgebung` wurde so bei **jedem** Speichern eine Stufe mehr: `&amp;`, `&amp;amp;` … bis die Entity sichtbar auf der Seite stand. Auf kant-it.de war der Entwurf der Startseite schon bei drei Stufen. Derselbe Zweig hatte einen zweiten Fehler gleicher Art: `nl2br()` ließ den Zeilenumbruch hinter dem `<br>` stehen, und der nächste Durchlauf machte über `breakLines()` ein zweites `<br>` daraus – aus einem Absatz wurden zwei.
|
||||
|
||||
**Behoben.** Der Zweig dekodiert jetzt zuerst (`html_entity_decode`) und escaped danach, und er ersetzt den Umbruch, statt ihn zu ergänzen. Damit endet er bei derselben Darstellung wie der DOM-Zweig, und beide sind unveränderlich gegenüber wiederholtem Speichern.
|
||||
|
||||
**Bestand.** Der Konsolenbefehl `bin/console cms:repair:entities` räumt auf, was schon gespeichert ist: veröffentlichte Fassung und Entwurf jeder Seite sowie die Bereiche, mit Trockenlauf und `--schreiben`, jede Änderung gegen den Validator geprüft (Seiten wie beim Speichern, Bereiche ohne Überschriftspflicht). Als Befehl und nicht als Skript unter `scripts/`, weil dieser Ordner nicht ins Releasearchiv geht – eine Installation ohne Git hätte das Skript sonst nie gesehen. Ältere Fassungen bleiben Historie. Auf DEEG angewandt (eine Stelle im Entwurf der Startseite). **Offen: kant-it.de** – dort stehen zwei dreifach kodierte Stellen im Entwurf der Startseite; das Skript gehört nach dem Update dort einmal ausgeführt, sonst steht der Murks beim nächsten Veröffentlichen live.
|
||||
|
||||
**Geprüft:** `tests/RepairEntitiesCommandTest.php` (Trockenlauf ändert nichts; Schreiben entschachtelt drei Stufen und lässt ein richtiges `&` stehen; ein zweiter Lauf findet nichts mehr) und ein neuer Test in `tests/CmsTest.php`: sechs Eingaben (Entity, nacktes Und, `<`, Zeilenumbruch, Markup, wörtliche Tags) dreimal hintereinander durch `sanitise()` – jedes Mal dasselbe Ergebnis; dazu eine Seite, die dreimal gespeichert und veröffentlicht wird und danach immer noch ein einziges `&` trägt.
|
||||
|
||||
**Nebenbei (DEEG):** Das Favicon zeigte den mittigen Ausschnitt des Logos, also „EE“ aus dem Schriftzug. Neu ist ein eigenes quadratisches Zeichen: der Dachgiebel aus dem Logo, weiß auf Logo-Blau, gerendert aus `logo-deeg.png` von `scripts/set-immobilien-deeg-favicon.php`. Vier Entwürfe standen zur Wahl (Doppeldach, Giebel, Dach blau auf weiß, Buchstabe D), Umut hat den Giebel gewählt.
|
||||
|
||||
## Baustein „Eigenes HTML“, DEEG-Angebotsseite (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann … z. B. eine neue Angebotsseite bei DEEG, da soll ein iframe rein, im gleichen Stil wie das Impressum.“
|
||||
|
||||
**Gebaut.** Neuer Kernbaustein `html` („Eigenes HTML“, Dokumentschema **v24**): eine Eigenschaft, das Markup bis 20.000 Zeichen, unverändert ausgegeben. `CustomCode::checkHtml()` weist `<script>` und `<iframe>` mit dem Weg zum richtigen Ort ab; `CustomCode::assertHtmlUnchanged()` hält Änderungen auf die Administratorrolle fest – in `EditorController` (Speichern und Zurückholen einer Fassung) und in `PartialController`, wie beim eigenen Skript. Im Editor: Eintrag in der Gruppe „Seite und Daten“, mehrzeiliges Feld, schreibgeschützt ohne Administratorrolle. In `site.css` nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt.
|
||||
|
||||
**Nicht gebaut, weil vorhanden:** der iframe. Der Baustein „Externer Inhalt“ (v14) macht das seit Langem – mit Einwilligung davor und dem Host in `frame-src`. Begründung in ADR 106.
|
||||
|
||||
**DEEG.** Die Seite `/angebote` gab es schon (im Editor angelegt, Starterabschnitt plus Rahmen mit Platzhaltertexten „test“). Sie ist jetzt im Stil der Rechtsseiten aufgebaut: Kleinzeile, große leichte Überschrift „Unsere Angebote“, Vorspann auf heller Fläche, darunter die IS24-Ergebnisliste (Portal 16200281, Höhe 1000) und eine Hinweiszeile zum Portal und zum Kontakt. „Immobilien“ in der Navigation und der Knopf im Angebote-Band der Startseite zeigen auf `/angebote` statt auf die alte Website. Aufbau per Skript: `scripts/add-immobilien-deeg-angebote.php`.
|
||||
|
||||
**Einwilligung und Gestaltung (DEEG).** Die Gruppe „Externe Inhalte“ ist eingeschaltet (`consentBanner`, `consentGroups`), der Balken trägt die Farben der Website (eckig, Logo-Blau als Akzent, Papier als Fläche). Vor der Zustimmung steht der Platzhalter, und der trägt über eigenes CSS am Block denselben Zuschnitt wie die Vorlage: eckiger Rahmen auf heller Fläche, linksbündig, blauer Knopf. **Gestaltbar ist der Rahmen, nicht sein Inhalt** – Abstände, Breite, Rand, Ecken und Höhe gehören dieser Website, die Darstellung der Liste gehört ImmobilienScout24; an fremdes Dokument im iframe kommt kein CSS heran, das trennt der Browser. Im Browser geprüft: Platzhalter, Zustimmung, geladene Liste.
|
||||
|
||||
**Nicht mehr offen:** Die Einwilligungsfrage war auf dieser Installation aus (`consentBanner` false, keine Gruppen). Das ist erledigt: Umut hat die Einwilligung eingeschaltet, ein fremder Rahmen lädt jetzt erst nach der Zustimmung.
|
||||
|
||||
**Geprüft:** `phpunit` 574 Tests grün, `npm test` 35 (neu: der Baustein startet leer, steht im Werkzeugkasten, hebt das Dokument auf v24). `npm run test:editor` um den ganzen Weg erweitert und bestanden: Baustein aus dem Werkzeugkasten einfügen, Markup eintippen, in der Vorschau sehen, veröffentlichen, auf der Website Zeichen für Zeichen wiederfinden – und `<script>` wie `<iframe>` werden mit ihrer Meldung abgewiesen (422, Meldung in der Leiste über dem Editor), ohne die veröffentlichte Seite anzufassen. Zwei neue PHP-Tests: Markup erscheint unverändert auf der veröffentlichten Seite und `<script>`, `<iframe>` sowie Schema v23 werden abgewiesen; HTML-Änderungen werden gegen den gespeicherten Stand geprüft.
|
||||
|
||||
## Fassung 1.19.0 veröffentlicht (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Ja committe das bitte und push mit neuem Release.“ Die Rücknahme der Designpakete geht als 1.19.0 heraus.
|
||||
@@ -942,7 +1250,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Mediathek derzeit für statische Bilder und PDFs (seit 22.09.2026): keine Videos/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
|
||||
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260917000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Dokumente: PDFs in der Mediathek, zum Verlinken und fuer den Betrachter.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Eine eigene Tabelle statt einer Art-Spalte in media: jede Stelle, die ein Bild sucht,
|
||||
// erwartet Varianten mit Breite und Hoehe. Ein PDF dort waere ein Bild ohne Bild - an
|
||||
// einem Dutzend Stellen. Getrennt kann keine Bildauswahl je ein Dokument finden.
|
||||
$document = $schema->createTable('document');
|
||||
$document->addColumn('id', 'string', ['length' => 32]);
|
||||
$document->addColumn('title', 'string', ['length' => 160]);
|
||||
$document->addColumn('bytes', 'integer');
|
||||
$document->addColumn('archived', 'boolean', ['default' => false]);
|
||||
$document->addColumn('version', 'integer', ['default' => 1]);
|
||||
$document->addColumn('created_at', 'datetime_immutable');
|
||||
$document->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('document');
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+60
-5
@@ -70,7 +70,9 @@ 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 }
|
||||
/* 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 }
|
||||
@@ -684,6 +686,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts dt { color: var(--muted) }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||
/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */
|
||||
.origin-form { margin-top: 4px; max-width: 560px }
|
||||
.origin-form label:first-of-type { margin-top: 10px }
|
||||
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
|
||||
.card-body .origin-row .secondary { margin: 0 }
|
||||
.origin-form .check-label { margin-top: 10px }
|
||||
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
|
||||
@@ -751,9 +761,25 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
|
||||
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
|
||||
|
||||
.favicon-field{display:flex;align-items:center;gap:12px}
|
||||
.favicon-field select{flex:1}
|
||||
.favicon-field{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
|
||||
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
|
||||
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
|
||||
.favicon-editor[hidden]{display:none}
|
||||
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
|
||||
.favicon-stage img{display:block;max-width:100%;max-height:360px;width:auto;height:auto}
|
||||
.favicon-frame{position:absolute;box-sizing:border-box;border:2px solid #fff;outline:1px solid rgba(0,0,0,.6);box-shadow:0 0 0 9999px rgba(20,30,25,.55);cursor:move}
|
||||
.favicon-stage{overflow:hidden}
|
||||
.favicon-frame:focus-visible{outline:3px solid var(--accent);outline-offset:0}
|
||||
.favicon-handle{position:absolute;right:-7px;bottom:-7px;width:14px;height:14px;background:#fff;border:1px solid rgba(0,0,0,.6);border-radius:3px;cursor:nwse-resize}
|
||||
.favicon-side input[type=range]{width:100%}
|
||||
.favicon-samples{display:flex;align-items:flex-end;gap:16px;margin:12px 0}
|
||||
.favicon-samples figure{margin:0;text-align:center;font-size:11px;color:var(--muted)}
|
||||
.favicon-samples canvas{display:block;margin:0 auto 6px;border-radius:8px;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/12px 12px;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.favicon-sample-large{width:90px;height:90px}
|
||||
.favicon-sample-small{width:32px;height:32px;border-radius:4px!important}
|
||||
@media(max-width:700px){.favicon-editor{grid-template-columns:1fr}}
|
||||
|
||||
.color-field { display: flex; gap: 8px; align-items: center }
|
||||
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
|
||||
@@ -874,8 +900,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-card-condition .field-help { grid-column: 1 / -1 }
|
||||
.field-card-images { grid-column: 1 / -1 }
|
||||
.field-image-pickers { display: grid; gap: 6px; margin-top: 6px }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker select { margin: 0 }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker-thumb { width: 64px; height: 48px; object-fit: cover; border-radius: 6px; background: #eef1eb }
|
||||
.field-image-picker-thumb[hidden] { display: block; visibility: hidden }
|
||||
.field-image-picker-name { color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
@media (max-width: 767px) { .field-image-picker { grid-template-columns: 64px minmax(0, 1fr); } .field-image-picker-option { grid-column: 1 / -1; font-weight: 650 } }
|
||||
.field-condition-picker { display: grid; gap: 6px; grid-column: 1 / -1 }
|
||||
.field-condition-values { border: 0; padding: 0; margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 16px }
|
||||
.field-condition-values legend { font-size: 13px; font-weight: 650; margin-bottom: 4px; padding: 0 }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
@@ -951,3 +983,26 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
/* Ein Bild der Mediathek als Formularfeld: Vorschau, Name, Wählen und Entfernen. */
|
||||
.media-field-label { display: block; font-weight: 650; font-size: 13px; margin-bottom: 6px }
|
||||
.media-field { min-width: 0 }
|
||||
.media-field-current { display: flex; align-items: center; gap: 10px; min-height: 64px; padding: 6px; border: 1px solid var(--line); border-radius: 9px; background: #fff }
|
||||
.media-field-thumb { width: 96px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #eef1eb }
|
||||
.media-field-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
.media-field-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px }
|
||||
/* Ein Linkfeld: die Adresse zum Tippen, darunter Seite oder PDF zum Wählen. */
|
||||
.link-field-actions { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px }
|
||||
.link-field-actions .text-button { padding: 4px 0; font-size: 13px }
|
||||
/* Vorhandene Kategorien und Schlagwörter zum Anklicken. */
|
||||
.term-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px }
|
||||
.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 } }
|
||||
@@ -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' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
@@ -12,7 +12,7 @@ export const CATEGORIES = [
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
];
|
||||
|
||||
/** How many columns a row can be added with. */
|
||||
@@ -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' };
|
||||
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
|
||||
if (type === 'html') block.props = { html: '' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
||||
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
||||
@@ -150,6 +152,37 @@ 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);
|
||||
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);
|
||||
@@ -160,6 +193,7 @@ export function upgradeForType(document, type) {
|
||||
if (type === 'postlist') upgradeDocument(document, 18);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (type === 'html') upgradeDocument(document, 24);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
@@ -310,7 +344,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 23;
|
||||
export const SCHEMA_VERSION = 26;
|
||||
|
||||
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' };
|
||||
|
||||
@@ -652,6 +686,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';
|
||||
|
||||
@@ -11,31 +11,6 @@ dialog::backdrop{background:#172e2966}
|
||||
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||
|
||||
/* ---- media library dialog (unchanged) ---------------------------------------------------- */
|
||||
.media-launch{margin-top:14px;white-space:normal}
|
||||
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
||||
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
||||
.media-heading h2{margin:0}
|
||||
.media-heading .eyebrow{margin-bottom:8px}
|
||||
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
.media-tools label{margin-top:10px}
|
||||
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
|
||||
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
|
||||
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
|
||||
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
|
||||
.media-card strong{font-size:12px;overflow-wrap:anywhere}
|
||||
.media-card span{font-size:11px;color:var(--muted)}
|
||||
.media-details{padding-left:22px;border-left:1px solid var(--line)}
|
||||
.media-details button,.media-details a{margin-top:12px;display:block}
|
||||
.media-details input{font-size:13px}
|
||||
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
|
||||
.media-footer label{margin:0;font-size:12px}
|
||||
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
#media-status{font-size:12px;margin-block:15px}
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
|
||||
/* ---- shell ------------------------------------------------------------------------------- */
|
||||
html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
|
||||
+131
-22
@@ -1,5 +1,6 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -8,10 +9,10 @@ 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';
|
||||
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' };
|
||||
@@ -318,6 +319,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 });
|
||||
@@ -330,7 +332,51 @@ function renderContent(block, panel) {
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
if (key === 'href' || options.link) {
|
||||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||||
// Eine eigene Seite sucht man, statt ihren Pfad zu kennen.
|
||||
const choose = value => { input.value = value; applyValue(); history.mergeKey = null; };
|
||||
const pages = document.createElement('button'); pages.type = 'button'; pages.className = 'secondary href-document'; pages.textContent = 'Seite wählen';
|
||||
pages.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||||
const row = document.createElement('div'); row.className = 'href-pickers'; row.append(pages, pdf);
|
||||
container.append(row);
|
||||
// Den Schalter kennen nur die Linkbausteine des Kerns; ein Plugin legt seine Felder selbst fest.
|
||||
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 });
|
||||
@@ -339,6 +385,21 @@ function renderContent(block, panel) {
|
||||
}
|
||||
const plugin = pluginDefinition(block);
|
||||
if (plugin) { renderPluginFields(block, panel, plugin, field); return; }
|
||||
/*
|
||||
* Eine Auswahl statt einer abgetippten Adresse, für alles, was ein Baustein nur verknüpft.
|
||||
* Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt sichtbar statt still zu
|
||||
* verschwinden. Ohne die Liste (sie kommt mit dem Design-Schema) bleibt es ein Textfeld.
|
||||
*/
|
||||
const pick = (kind, current, empty = null) => {
|
||||
const list = designSchema?.choices?.[kind];
|
||||
if (!list) return {};
|
||||
const choices = {};
|
||||
if (empty !== null) choices[''] = empty;
|
||||
else if (!current) choices[''] = 'Bitte wählen …';
|
||||
Object.assign(choices, list);
|
||||
if (current && !(current in list)) choices[current] = `${current} (nicht mehr vorhanden)`;
|
||||
return { choices };
|
||||
};
|
||||
if (block.type === 'text') {
|
||||
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
|
||||
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
|
||||
@@ -356,6 +417,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');
|
||||
@@ -370,10 +451,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.' });
|
||||
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 %.' });
|
||||
@@ -389,7 +470,7 @@ function renderContent(block, panel) {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, 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.' });
|
||||
@@ -403,14 +484,14 @@ function renderContent(block, panel) {
|
||||
field('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' });
|
||||
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
|
||||
}
|
||||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||||
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.' });
|
||||
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.' });
|
||||
@@ -460,7 +541,30 @@ function renderContent(block, panel) {
|
||||
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
|
||||
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'html') {
|
||||
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
|
||||
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
|
||||
const label = document.createElement('label'); label.htmlFor = 'own-html'; label.textContent = 'HTML';
|
||||
const input = document.createElement('textarea');
|
||||
input.id = 'own-html'; input.rows = 10; input.spellcheck = false; input.className = 'own-code';
|
||||
input.value = block.props?.html ?? '';
|
||||
input.readOnly = !mayScript;
|
||||
input.addEventListener('change', () => {
|
||||
history.change(doc => {
|
||||
upgradeDocument(doc, 24);
|
||||
const current = locate(doc.blocks, block.id)?.block;
|
||||
if (!current) return;
|
||||
current.props = { ...current.props, html: input.value };
|
||||
});
|
||||
changed();
|
||||
});
|
||||
const note = document.createElement('p'); note.className = 'field-help';
|
||||
note.innerHTML = mayScript
|
||||
? 'Markup, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. <strong><script></strong> gehört unter „Erweitert“ in das Feld „Eigenes Skript“, ein <strong><iframe></strong> in den Baustein „Externer Inhalt“ – nur der fragt vorher um Einwilligung. Die Vorschau zeigt das HTML so, wie die Seite es zeigt.'
|
||||
: 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.';
|
||||
panel.append(label, input, note);
|
||||
}
|
||||
if (block.type === 'form') field('Formular', 'form', block.props.form, { ...pick('forms', block.props.form), help: 'Felder, Empfänger und Einwilligungstext werden unter „Formulare“ gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
|
||||
@@ -478,7 +582,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.' });
|
||||
@@ -496,24 +600,27 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'postlist') {
|
||||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { ...pick('categories', block.props.category, 'Alle Kategorien') });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { ...pick('tags', block.props.tag, 'Alle Schlagwörter'), help: 'Kategorien und Schlagwörter entstehen in den Einstellungen eines Beitrags.' });
|
||||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, 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 });
|
||||
panel.append(imagePreview.element);
|
||||
const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options';
|
||||
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
|
||||
// Ohne eigene Wahl folgt das Bild seiner Galerie - dieselbe Regel wie in der Vorlage.
|
||||
const holder = locate(history.document.blocks, block.id)?.parent;
|
||||
const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true;
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine'));
|
||||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, 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);
|
||||
@@ -528,7 +635,7 @@ function renderContent(block, panel) {
|
||||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||
}
|
||||
if (block.type === 'gallery') {
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' });
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Gilt für alle Bilder der Galerie: ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Ein Bild mit eigener Einstellung unter „Beim Klick“ behält sie.' });
|
||||
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
@@ -713,7 +820,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -741,7 +848,7 @@ function renderPluginFields(block, panel, plugin, field) {
|
||||
if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); }
|
||||
panel.append(wrap);
|
||||
}
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength });
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength, plugin: true, link: schema.kind === 'url' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1041,7 +1148,9 @@ 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);
|
||||
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
|
||||
// bekommt dafür eine eigene Gruppe.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:var(--k-image-radius,12px)}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:767px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* The site icon in the settings: an image from the media library (uploads happen right there in
|
||||
* the dialog), then a square cut-out chosen by dragging. The crop is stored as fractions -
|
||||
* left and top of the width and height, the edge of the shorter side - so it holds for every
|
||||
* variant of the image, and the server cuts the PNG files from it when the form is saved.
|
||||
*/
|
||||
const field = document.querySelector('[data-favicon-field]');
|
||||
if (field) setup(field);
|
||||
|
||||
function setup(field) {
|
||||
const idInput = field.querySelector('[name="faviconMediaId"]');
|
||||
const cropInput = field.querySelector('[name="faviconCrop"]');
|
||||
const choose = field.querySelector('[data-favicon-choose]');
|
||||
const remove = field.querySelector('[data-favicon-remove]');
|
||||
const current = field.querySelector('.favicon-current');
|
||||
const base = document.querySelector('meta[name="admin-base"]').content;
|
||||
|
||||
const editor = document.createElement('div'); editor.className = 'favicon-editor'; editor.hidden = true;
|
||||
editor.innerHTML = `<div class="favicon-stage"><img alt="" draggable="false"><div class="favicon-frame" tabindex="0" role="application" aria-label="Ausschnitt für das Symbol. Mit den Pfeiltasten verschieben, mit Umschalt in großen Schritten, mit Plus und Minus die Größe ändern."><span class="favicon-handle" aria-hidden="true"></span></div></div>
|
||||
<div class="favicon-side">
|
||||
<label for="favicon-size">Größe des Ausschnitts</label>
|
||||
<input id="favicon-size" type="range" min="2" max="100" step="1" value="100">
|
||||
<p class="field-help">Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.</p>
|
||||
<div class="favicon-samples" aria-label="Vorschau">
|
||||
<figure><canvas width="180" height="180" class="favicon-sample-large"></canvas><figcaption>Startbildschirm</figcaption></figure>
|
||||
<figure><canvas width="32" height="32" class="favicon-sample-small"></canvas><figcaption>Browser-Reiter</figcaption></figure>
|
||||
</div>
|
||||
<button type="button" class="text-button" data-favicon-center>Mittig ausrichten</button>
|
||||
</div>`;
|
||||
field.after(editor);
|
||||
const stage = editor.querySelector('.favicon-stage');
|
||||
const image = stage.querySelector('img');
|
||||
const frame = stage.querySelector('.favicon-frame');
|
||||
const size = editor.querySelector('#favicon-size');
|
||||
const large = editor.querySelector('.favicon-sample-large');
|
||||
const small = editor.querySelector('.favicon-sample-small');
|
||||
|
||||
let crop = { x: 0, y: 0, s: 1 };
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
const edge = () => crop.s * Math.min(image.naturalWidth, image.naturalHeight);
|
||||
|
||||
/** Keep the square inside the picture, then show it and write it into the form. */
|
||||
const apply = () => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight; if (!w || !h) return;
|
||||
crop.s = clamp(crop.s, 0.02, 1);
|
||||
const e = edge();
|
||||
crop.x = clamp(crop.x, 0, (w - e) / w); crop.y = clamp(crop.y, 0, (h - e) / h);
|
||||
Object.assign(frame.style, { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${e / w * 100}%`, height: `${e / h * 100}%` });
|
||||
size.value = Math.round(crop.s * 100);
|
||||
cropInput.value = [crop.x, crop.y, crop.s].map(value => String(Math.round(value * 1e6) / 1e6)).join(',');
|
||||
for (const canvas of [large, small]) {
|
||||
const context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, crop.x * w, crop.y * h, e, e, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
};
|
||||
const center = () => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight, e = edge();
|
||||
crop.x = (w - e) / 2 / w; crop.y = (h - e) / 2 / h; apply();
|
||||
};
|
||||
/** Resize around the middle of the square, so the slider and the keys do not drift it away. */
|
||||
const resize = s => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight, before = edge();
|
||||
crop.s = clamp(s, 0.02, 1); const after = edge();
|
||||
crop.x += (before - after) / 2 / w; crop.y += (before - after) / 2 / h; apply();
|
||||
};
|
||||
|
||||
const load = (src, initial) => new Promise((resolve, reject) => {
|
||||
image.onload = () => { editor.hidden = false; crop = initial || { x: 0, y: 0, s: 1 }; if (initial) apply(); else center(); resolve(); };
|
||||
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
|
||||
image.src = src;
|
||||
});
|
||||
const largest = item => item.variants.at(-1).src;
|
||||
|
||||
const showCurrent = label => {
|
||||
current.replaceChildren(); current.append(document.createTextNode(label));
|
||||
choose.textContent = idInput.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !idInput.value;
|
||||
};
|
||||
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: async items => {
|
||||
if (!items.length) return;
|
||||
idInput.value = items[0].id; cropInput.value = '';
|
||||
showCurrent(`Neues Symbol aus „${items[0].title}“ – wird beim Speichern übernommen.`);
|
||||
try { await load(largest(items[0])); frame.focus({ preventScroll: true }); editor.scrollIntoView({ block: 'nearest' }); }
|
||||
catch (error) { showCurrent(error.message); }
|
||||
} }));
|
||||
remove.addEventListener('click', () => {
|
||||
idInput.value = ''; cropInput.value = ''; editor.hidden = true; image.removeAttribute('src');
|
||||
showCurrent('Mitgeliefertes Zeichen – wird beim Speichern übernommen.'); choose.focus();
|
||||
});
|
||||
editor.querySelector('[data-favicon-center]').addEventListener('click', center);
|
||||
size.addEventListener('input', () => resize(size.value / 100));
|
||||
|
||||
// Drag to move, drag the corner to resize. Pointer events cover mouse, pen and touch alike.
|
||||
let drag = null;
|
||||
frame.addEventListener('pointerdown', event => {
|
||||
event.preventDefault(); frame.setPointerCapture(event.pointerId); frame.focus({ preventScroll: true });
|
||||
drag = { mode: event.target.classList.contains('favicon-handle') ? 'resize' : 'move', startX: event.clientX, startY: event.clientY, crop: { ...crop }, rect: stage.getBoundingClientRect() };
|
||||
});
|
||||
frame.addEventListener('pointermove', event => {
|
||||
if (!drag) return;
|
||||
const w = image.naturalWidth, h = image.naturalHeight, { rect } = drag;
|
||||
const dx = (event.clientX - drag.startX) / rect.width, dy = (event.clientY - drag.startY) / rect.height;
|
||||
if (drag.mode === 'move') { crop.x = drag.crop.x + dx; crop.y = drag.crop.y + dy; }
|
||||
else {
|
||||
// The corner follows the larger of both movements, measured in image pixels.
|
||||
const grow = Math.max(dx * w, dy * h), shorter = Math.min(w, h);
|
||||
const room = Math.min(w - drag.crop.x * w, h - drag.crop.y * h);
|
||||
crop.x = drag.crop.x; crop.y = drag.crop.y;
|
||||
crop.s = clamp(drag.crop.s * shorter + grow, 0.02 * shorter, room) / shorter;
|
||||
}
|
||||
apply();
|
||||
});
|
||||
const stop = () => { drag = null; };
|
||||
frame.addEventListener('pointerup', stop); frame.addEventListener('pointercancel', stop);
|
||||
frame.addEventListener('keydown', event => {
|
||||
const step = event.shiftKey ? 0.1 : 0.01;
|
||||
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
|
||||
if (moves[event.key]) { event.preventDefault(); crop.x += moves[event.key][0]; crop.y += moves[event.key][1]; apply(); }
|
||||
else if (['+', '='].includes(event.key)) { event.preventDefault(); resize(crop.s + step); }
|
||||
else if (event.key === '-') { event.preventDefault(); resize(crop.s - step); }
|
||||
});
|
||||
|
||||
// An icon that is already set opens with its picture and its cut-out.
|
||||
if (/^[a-f0-9]{32}$/.test(idInput.value)) {
|
||||
fetch(`${base}/api/media/${idInput.value}`, { credentials: 'same-origin' })
|
||||
.then(response => { if (!response.ok || response.redirected) throw new Error(); return response.json(); })
|
||||
.then(({ item }) => {
|
||||
const parts = cropInput.value.split(',').map(Number);
|
||||
return load(largest(item), parts.length === 3 && parts.every(Number.isFinite) ? { x: parts[0], y: parts[1], s: parts[2] } : null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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}
|
||||
+121
-15
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
const root = document.querySelector('[data-form-schema]');
|
||||
if (root) {
|
||||
@@ -115,8 +116,10 @@ if (root) {
|
||||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||||
preview.after(note);
|
||||
|
||||
// Statt Kennungen zu tippen: je Option eine Liste der Mediathek. Das Textfeld bleibt das
|
||||
// gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein.
|
||||
// Statt Kennungen zu tippen: je Option ein Bild aus der Mediathek, gewählt im Dialog mit
|
||||
// Vorschau und Hochladen. Das Textfeld bleibt das gespeicherte Original und wird aus der
|
||||
// Auswahl geschrieben; ohne Skript tippt man hinein.
|
||||
const bildVon = new Map(bilder.map(bild => [bild.id, bild]));
|
||||
const renderImagePickers = card => {
|
||||
const area = card.querySelector('[data-field-images-input]');
|
||||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||||
@@ -129,23 +132,124 @@ if (root) {
|
||||
const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim())));
|
||||
box.dataset.signature = options.join('|');
|
||||
box.replaceChildren();
|
||||
const write = () => {
|
||||
const map = {};
|
||||
for (const row of box.querySelectorAll('[data-option]')) if (row.dataset.media) map[row.dataset.option] = row.dataset.media;
|
||||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||||
renderPreview();
|
||||
};
|
||||
for (const option of options) {
|
||||
const row = el('label', 'field-image-picker');
|
||||
row.append(el('span', 'field-image-picker-option', option));
|
||||
const select = document.createElement('select');
|
||||
select.append(new Option('Kein Bild', ''));
|
||||
for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id));
|
||||
select.dataset.option = option;
|
||||
select.addEventListener('change', () => {
|
||||
const map = {};
|
||||
for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value;
|
||||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||||
renderPreview();
|
||||
});
|
||||
row.append(select); box.append(row);
|
||||
const row = el('div', 'field-image-picker'); row.dataset.option = option;
|
||||
const thumb = document.createElement('img'); thumb.className = 'field-image-picker-thumb'; thumb.alt = ''; thumb.width = 64; thumb.height = 48;
|
||||
const name = el('span', 'field-image-picker-name');
|
||||
const choose = el('button', 'secondary', ''); choose.type = 'button';
|
||||
const remove = el('button', 'text-button danger-text', 'Entfernen'); remove.type = 'button';
|
||||
remove.setAttribute('aria-label', `Bild für „${option}“ entfernen`);
|
||||
const show = id => {
|
||||
row.dataset.media = id || '';
|
||||
const bild = id ? bildVon.get(id) : null;
|
||||
thumb.hidden = !bild; if (bild) thumb.src = bild.src; else thumb.removeAttribute('src');
|
||||
name.textContent = id ? (bild?.title || 'Bild gewählt') : 'Kein Bild';
|
||||
choose.textContent = id ? 'Anderes Bild' : 'Bild wählen';
|
||||
choose.setAttribute('aria-label', `${id ? 'Anderes Bild' : 'Bild'} für „${option}“ wählen`);
|
||||
remove.hidden = !id;
|
||||
};
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => {
|
||||
if (!item) return;
|
||||
bildVon.set(item.id, { id: item.id, title: item.title, src: item.variants[0].src });
|
||||
show(item.id); write();
|
||||
} }));
|
||||
remove.addEventListener('click', () => { show(''); write(); choose.focus(); });
|
||||
show(current[option] || '');
|
||||
row.append(el('span', 'field-image-picker-option', option), thumb, name, choose, remove); box.append(row);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Auswahl statt Feldnamen: die Bedingung eines Feldes nennt ein Auswahlfeld weiter oben und
|
||||
* dessen Optionen, die Antwortadresse ein E-Mail-Feld. Die Textfelder bleiben das
|
||||
* gespeicherte Original, versteckt, und werden aus der Auswahl geschrieben.
|
||||
*/
|
||||
const fieldsBefore = card => {
|
||||
const list = [];
|
||||
for (const other of cards()) {
|
||||
if (other === card) break;
|
||||
const name = other.querySelector('[data-field-name]').value.trim();
|
||||
const type = other.querySelector('[data-field-type]').value;
|
||||
if (name && ['select', 'radio', 'checkboxes'].includes(type)) {
|
||||
list.push({ name, label: other.querySelector('[data-field-label]').value.trim() || name,
|
||||
options: other.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean) });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
};
|
||||
const renderCondition = card => {
|
||||
const fieldInput = card.querySelector('[data-field-showif]');
|
||||
const valuesInput = card.querySelector('[data-field-showvalues]');
|
||||
const candidates = fieldsBefore(card);
|
||||
const signature = JSON.stringify([candidates, fieldInput.value, valuesInput.value]);
|
||||
let box = card.querySelector('[data-condition-picker]');
|
||||
if (!box) {
|
||||
box = el('div', 'field-condition-picker'); box.dataset.conditionPicker = '';
|
||||
fieldInput.closest('div').hidden = true; valuesInput.closest('div').hidden = true;
|
||||
fieldInput.closest('[data-field-condition]').prepend(box);
|
||||
const help = box.parentElement.querySelector(':scope > .field-help');
|
||||
if (help) help.textContent = 'Zur Wahl stehen Auswahlfelder weiter oben. Bei einem Schritt gilt die Bedingung für alle seine Felder.';
|
||||
}
|
||||
if (box.dataset.signature === signature) return;
|
||||
box.dataset.signature = signature;
|
||||
const focused = box.contains(document.activeElement) ? document.activeElement.dataset.key : null;
|
||||
box.replaceChildren();
|
||||
const id = `${fieldInput.id}-select`;
|
||||
const label = el('label', '', 'Nur zeigen, wenn'); label.htmlFor = id;
|
||||
const select = document.createElement('select'); select.id = id; select.dataset.key = 'field';
|
||||
select.append(new Option('Immer zeigen', ''));
|
||||
for (const candidate of candidates) select.append(new Option(candidate.label, candidate.name));
|
||||
if (fieldInput.value && !candidates.some(c => c.name === fieldInput.value)) select.append(new Option(`${fieldInput.value} (nicht mehr weiter oben)`, fieldInput.value));
|
||||
select.value = fieldInput.value;
|
||||
select.addEventListener('change', () => { fieldInput.value = select.value; valuesInput.value = ''; refresh(); });
|
||||
box.append(label, select);
|
||||
const chosen = candidates.find(c => c.name === fieldInput.value);
|
||||
if (chosen) {
|
||||
const set = document.createElement('fieldset'); set.className = 'field-condition-values';
|
||||
set.append(el('legend', '', '… eine dieser Antworten gewählt ist'));
|
||||
const current = valuesInput.value.split(',').map(v => v.trim()).filter(Boolean);
|
||||
chosen.options.forEach((option, index) => {
|
||||
const check = el('label', 'check-label');
|
||||
const box2 = document.createElement('input'); box2.type = 'checkbox'; box2.value = option; box2.checked = current.includes(option);
|
||||
box2.dataset.key = `value-${index}`;
|
||||
box2.addEventListener('change', () => {
|
||||
valuesInput.value = [...set.querySelectorAll('input:checked')].map(input => input.value).join(', ');
|
||||
refresh();
|
||||
});
|
||||
check.append(box2, document.createTextNode(` ${option}`)); set.append(check);
|
||||
});
|
||||
box.append(set);
|
||||
}
|
||||
if (focused) box.querySelector(`[data-key="${focused}"]`)?.focus();
|
||||
};
|
||||
const reply = form.querySelector('[name="replyToField"]');
|
||||
const renderReply = () => {
|
||||
if (!reply) return;
|
||||
const emails = cards().map(card => ({ name: card.querySelector('[data-field-name]').value.trim(), label: card.querySelector('[data-field-label]').value.trim(), type: card.querySelector('[data-field-type]').value }))
|
||||
.filter(field => field.name && field.type === 'email');
|
||||
let select = form.querySelector('[data-reply-picker]');
|
||||
if (!select) {
|
||||
select = document.createElement('select'); select.dataset.replyPicker = ''; select.id = reply.id;
|
||||
reply.id += '-value'; reply.type = 'hidden'; reply.after(select);
|
||||
select.addEventListener('change', () => { reply.value = select.value; });
|
||||
}
|
||||
const signature = JSON.stringify([emails, reply.value]);
|
||||
if (select.dataset.signature === signature) return;
|
||||
select.dataset.signature = signature;
|
||||
select.replaceChildren(new Option('Keine – Antworten gehen an den Absender der E-Mail', ''));
|
||||
for (const field of emails) select.append(new Option(field.label || field.name, field.name));
|
||||
if (reply.value && !emails.some(field => field.name === reply.value)) select.append(new Option(`${reply.value} (kein E-Mail-Feld mehr)`, reply.value));
|
||||
select.value = reply.value;
|
||||
const help = reply.parentElement.querySelector('.field-help');
|
||||
if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.';
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
@@ -153,6 +257,7 @@ if (root) {
|
||||
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);
|
||||
renderCondition(card);
|
||||
card.querySelector('[data-field-number]').hidden = type !== 'number';
|
||||
const remove = card.querySelector('[data-remove-field]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
|
||||
@@ -162,6 +267,7 @@ if (root) {
|
||||
else if (type === 'step') { required.checked = false; required.disabled = true; }
|
||||
else required.disabled = false;
|
||||
});
|
||||
renderReply();
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128"><rect width="128" height="128" rx="32" fill="#182d2b"/><path d="M36 28h16v42l26-28h22L68 76l34 28H78L52 82v22H36z" fill="#e5f0b6"/></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -99,3 +99,86 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/*
|
||||
* Der PDF-Betrachter - in derselben Datei wie der Bildbetrachter, damit eine Seite mit beidem
|
||||
* nicht zwei Skripte laedt.
|
||||
*
|
||||
* Ohne dieses Skript ist ein Link auf ein PDF ein gewöhnlicher Link: der Browser öffnet die
|
||||
* Datei. Das Skript zeigt sie stattdessen in einem Rahmen über der Seite, mit dem Betrachter,
|
||||
* den der Browser ohnehin mitbringt - kein eigener Nachbau, der ein Megabyte wiegt.
|
||||
*
|
||||
* Wo der Browser keinen solchen Betrachter hat oder ihn im Rahmen nur halb zeigt, bleibt es beim
|
||||
* neuen Tab: Chrome auf Android hat keinen, und Safari auf dem iPhone zeigt im Rahmen nur die
|
||||
* erste Seite. Ein Link mit „In neuem Tab öffnen“ bleibt ohnehin, was er ist.
|
||||
*/
|
||||
(() => {
|
||||
const PDF = /^\/media\/[a-f0-9]{32}\.pdf$/;
|
||||
const links = [...document.querySelectorAll('a[href]')].filter(link => {
|
||||
const url = new URL(link.href, location.href);
|
||||
|
||||
return url.origin === location.origin && PDF.test(url.pathname) && '_blank' !== link.target;
|
||||
});
|
||||
if (0 === links.length) {
|
||||
return;
|
||||
}
|
||||
const eingebettet = false !== navigator.pdfViewerEnabled && !matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
let dialog = null;
|
||||
let rahmen = null;
|
||||
let titel = null;
|
||||
let laden = null;
|
||||
|
||||
const baue = () => {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'pdf-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'pdf-dialog-title');
|
||||
dialog.innerHTML = `
|
||||
<div class="pdf-bar">
|
||||
<p class="pdf-title" id="pdf-dialog-title"></p>
|
||||
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
||||
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
|
||||
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
||||
`;
|
||||
document.body.append(dialog);
|
||||
rahmen = dialog.querySelector('.pdf-frame');
|
||||
titel = dialog.querySelector('.pdf-title');
|
||||
laden = [dialog.querySelector('.pdf-download'), dialog.querySelector('.pdf-open')];
|
||||
dialog.querySelector('.pdf-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
// Der Rahmen wird geleert, sonst läuft ein eingebettetes Video oder Audio im PDF weiter
|
||||
// und das nächste Öffnen zeigt kurz das vorige Dokument.
|
||||
dialog.addEventListener('close', () => rahmen.removeAttribute('src'));
|
||||
};
|
||||
|
||||
for (const link of links) {
|
||||
link.addEventListener('click', event => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!eingebettet) {
|
||||
window.open(link.href, '_blank', 'noopener');
|
||||
|
||||
return;
|
||||
}
|
||||
if (null === dialog) {
|
||||
baue();
|
||||
}
|
||||
const name = (link.getAttribute('aria-label') || link.textContent || '').trim();
|
||||
titel.textContent = name || 'Dokument';
|
||||
rahmen.title = name ? `PDF: ${name}` : 'PDF-Dokument';
|
||||
for (const aktion of laden) {
|
||||
aktion.href = link.href;
|
||||
}
|
||||
rahmen.src = link.href;
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { openDocuments } from './media.js';
|
||||
|
||||
/*
|
||||
* Ein Linkfeld in der Verwaltung: die Adresse bleibt ein Textfeld - eine fremde Seite tippt man
|
||||
* ein -, daneben „Seite wählen“ und „PDF wählen“. Ohne Skript bleibt das Textfeld allein.
|
||||
*
|
||||
* Markup: <div data-link-field> mit einem <input data-link-input> darin. Felder, die später
|
||||
* erscheinen (neue Menüeinträge), bekommen ihre Knöpfe beim ersten Fokus.
|
||||
*/
|
||||
const enhance = field => {
|
||||
if (field.dataset.linkReady) return;
|
||||
const input = field.querySelector('[data-link-input]');
|
||||
if (!input) return;
|
||||
field.dataset.linkReady = '1';
|
||||
const row = document.createElement('div'); row.className = 'link-field-actions';
|
||||
const choose = value => { input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); input.focus(); };
|
||||
const page = document.createElement('button'); page.type = 'button'; page.className = 'text-button'; page.textContent = 'Seite wählen';
|
||||
page.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'text-button'; pdf.textContent = 'PDF wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||||
row.append(page, pdf); input.after(row);
|
||||
};
|
||||
const all = () => document.querySelectorAll('[data-link-field]').forEach(enhance);
|
||||
all();
|
||||
new MutationObserver(all).observe(document.body, { childList: true, subtree: true });
|
||||
@@ -0,0 +1,44 @@
|
||||
/* The media library dialog. Loaded by the editor and by every screen that picks an image from
|
||||
the library (the site icon in the settings), so it does not rely on editor.css. */
|
||||
.media-dialog{padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
|
||||
.media-dialog::backdrop{background:#172e2966}
|
||||
.media-dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.media-dialog .panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
|
||||
.media-launch{margin-top:14px;white-space:normal}
|
||||
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
||||
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
||||
.media-heading h2{margin:0}
|
||||
.media-heading .eyebrow{margin-bottom:8px}
|
||||
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
.media-tools label{margin-top:10px}
|
||||
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
|
||||
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
|
||||
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
|
||||
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
|
||||
.media-card strong{font-size:12px;overflow-wrap:anywhere}
|
||||
.media-card span{font-size:11px;color:var(--muted)}
|
||||
.media-details{padding-left:22px;border-left:1px solid var(--line)}
|
||||
.media-details button,.media-details a{margin-top:12px;display:block}
|
||||
.media-details input{font-size:13px}
|
||||
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
|
||||
.media-footer label{margin:0;font-size:12px}
|
||||
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
#media-status{font-size:12px;margin-block:15px}
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
/* PDFs: eine Liste statt eines Rasters - es gibt nichts zu sehen außer dem Titel. */
|
||||
.document-dialog{max-width:720px}
|
||||
.document-list{display:grid;gap:8px;max-height:360px;overflow:auto;padding:4px}
|
||||
.document-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;border:2px solid var(--line);border-radius:9px;padding:10px 12px;background:white;color:var(--ink);text-align:left}
|
||||
.document-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.document-card strong{font-size:13px;overflow-wrap:anywhere}
|
||||
.document-card span{font-size:11px;color:var(--muted)}
|
||||
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
|
||||
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
.href-pickers{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 4px}
|
||||
.href-document{margin:0}
|
||||
/* Seite als Linkziel wählen: schmal, die Trefferliste darf überstehen. */
|
||||
.page-dialog{max-width:560px;min-height:420px;overflow:visible}
|
||||
.page-dialog .content-picker{margin-top:16px}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dragging images onto the upload field, and saying which files are about to go up.
|
||||
* Dragging images and PDFs onto the upload field, and saying which files are about to go up.
|
||||
*
|
||||
* Everything here is an addition. Without this script the field is still a file input with a
|
||||
* label: clicking it opens the file picker and the form submits the same way. The script never
|
||||
@@ -9,19 +9,19 @@ const zone = document.querySelector('[data-dropzone]');
|
||||
const input = zone?.querySelector('input[type="file"]');
|
||||
const chosen = document.querySelector('[data-chosen]');
|
||||
if (zone && input && chosen) {
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
|
||||
const describe = (files, ignored = 0) => {
|
||||
if (!files.length) {
|
||||
chosen.textContent = ignored
|
||||
? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.`
|
||||
? `Keine passende Datei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG, WebP und PDF.`
|
||||
: 'Noch keine Datei gewählt.';
|
||||
return;
|
||||
}
|
||||
const names = [...files].map(file => file.name).join(', ');
|
||||
const size = [...files].reduce((total, file) => total + file.size, 0);
|
||||
chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}`
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : '');
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG, WebP und PDF.` : '');
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => describe(input.files));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* Bildfelder in Formularen der Verwaltung: Vorschaubild beim Teilen, Beitragsbild.
|
||||
*
|
||||
* Über den Dokumentknoten statt je Feld, weil die Suche in der Inhaltsliste die Ergebnisse
|
||||
* austauscht - ein Feld, das danach erscheint, funktioniert trotzdem.
|
||||
*/
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-media-choose], [data-media-remove]');
|
||||
const field = button?.closest('[data-media-field]');
|
||||
if (!field) return;
|
||||
const input = field.querySelector('input[type="hidden"]');
|
||||
const thumb = field.querySelector('.media-field-thumb');
|
||||
const name = field.querySelector('.media-field-name');
|
||||
const choose = field.querySelector('[data-media-choose]');
|
||||
const remove = field.querySelector('[data-media-remove]');
|
||||
const show = item => {
|
||||
input.value = item?.id ?? '';
|
||||
thumb.hidden = !item;
|
||||
if (item) thumb.src = item.variants[0].src; else thumb.removeAttribute('src');
|
||||
name.textContent = item ? item.title : 'Kein Bild gewählt';
|
||||
choose.textContent = item ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !item;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
if (button.matches('[data-media-remove]')) { show(null); choose.focus(); return; }
|
||||
// Kein Alternativtext nötig: das Bild erscheint als Vorschau beim Teilen oder in einer Karte,
|
||||
// deren Titel schon sagt, worum es geht.
|
||||
openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) show(item); } });
|
||||
});
|
||||
+70
-3
@@ -40,7 +40,8 @@ export function createMediaPreview(initial = {}) {
|
||||
return { element: wrap, update };
|
||||
}
|
||||
|
||||
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
/** needsAlt: false for images that never appear as content, such as the site icon. */
|
||||
export function openMedia({ multiple = false, onChoose, needsAlt = true } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'media-title');
|
||||
@@ -128,15 +129,81 @@ export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
}
|
||||
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
|
||||
});
|
||||
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
|
||||
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose || !needsAlt;
|
||||
$('#media-insert').addEventListener('click', () => {
|
||||
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
|
||||
const decorative = $('#media-decorative').checked;
|
||||
if (!decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
|
||||
if (needsAlt && !decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
|
||||
onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
|
||||
});
|
||||
dialog.showModal(); lock(true);
|
||||
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF aus der Mediathek wählen - oder eines hochladen und gleich wählen.
|
||||
*
|
||||
* Ein eigener, schmaler Dialog statt einer Betriebsart des Bilderdialogs: ein PDF hat keine
|
||||
* Vorschau, keinen Alternativtext und keine Mehrfachauswahl. Gewählt wird die Adresse, die dann
|
||||
* im Linkziel steht.
|
||||
*/
|
||||
export function openDocuments({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog document-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'document-title');
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">DOKUMENTE</p><h2 id="document-title">PDF wählen</h2></div><button class="secondary" id="document-close">Schließen</button></div>
|
||||
<p class="panel-hint">PDF bis 20 MB. Es wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar – nichts Vertrauliches hochladen. Ohne „In neuem Tab öffnen“ zeigt die Website es in einem Betrachter über der Seite.</p>
|
||||
<div class="media-tools"><div><label for="document-search">PDFs suchen</label><input id="document-search" type="search" placeholder="Titel"></div><div><label for="document-upload">PDF hochladen</label><input id="document-upload" type="file" accept="application/pdf,.pdf" disabled></div></div>
|
||||
<p id="document-status" role="status" aria-live="polite">Wird geladen …</p><p id="document-error" class="media-error" role="alert" hidden></p>
|
||||
<div id="document-list" class="document-list" role="group" aria-label="PDF auswählen"></div>
|
||||
<div class="media-footer"><span></span><button id="document-insert" class="primary" disabled>PDF übernehmen</button></div>`;
|
||||
document.body.append(dialog);
|
||||
const $ = selector => dialog.querySelector(selector);
|
||||
let items = [], chosen = null, token;
|
||||
const error = message => { $('#document-error').textContent = message; $('#document-error').hidden = !message; };
|
||||
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
|
||||
const base = document.querySelector('meta[name="admin-base"]').content + '/api/dokumente';
|
||||
function render() {
|
||||
const query = $('#document-search').value.toLocaleLowerCase('de');
|
||||
const visible = items.filter(item => !item.archived && item.title.toLocaleLowerCase('de').includes(query));
|
||||
$('#document-list').replaceChildren();
|
||||
for (const item of visible) {
|
||||
const card = element('button', '', 'document-card'); card.type = 'button';
|
||||
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
|
||||
card.append(element('span', 'PDF', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
|
||||
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
|
||||
card.addEventListener('dblclick', () => { chosen = item; $('#document-insert').click(); });
|
||||
$('#document-list').append(card);
|
||||
}
|
||||
if (!visible.length) $('#document-list').append(element('p', items.length ? 'Kein PDF mit diesem Titel.' : 'Noch keine PDFs. Lade oben eines hoch.', 'muted'));
|
||||
$('#document-status').textContent = `${items.filter(item => !item.archived).length} PDFs${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
|
||||
$('#document-insert').disabled = !chosen;
|
||||
}
|
||||
$('#document-search').addEventListener('input', render);
|
||||
$('#document-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
$('#document-upload').addEventListener('change', async () => {
|
||||
const file = $('#document-upload').files[0]; if (!file) return;
|
||||
error('');
|
||||
if (file.size > 20 * 1024 * 1024) { error(`${file.name}: größer als 20 MB`); $('#document-upload').value = ''; return; }
|
||||
$('#document-status').textContent = `Wird hochgeladen: ${file.name}`;
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
|
||||
items.unshift(data.item); chosen = data.item;
|
||||
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||
$('#document-upload').value = ''; render();
|
||||
});
|
||||
$('#document-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
|
||||
dialog.showModal();
|
||||
fetch(base, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('PDFs konnten nicht geladen werden. Bitte neu anmelden.');
|
||||
// Hochladen erst mit Token - sonst weist der Server eine zu früh gewählte Datei ab.
|
||||
const data = await response.json(); items = data.items; token = data.csrfToken; $('#document-upload').disabled = false; render();
|
||||
}).catch(err => { $('#document-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||
@@ -1,4 +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');
|
||||
@@ -7,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 = '';
|
||||
@@ -39,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]; });
|
||||
@@ -74,8 +81,9 @@ 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;
|
||||
if (data.type === 'page') {
|
||||
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
|
||||
@@ -86,14 +94,18 @@ function createRow(data) {
|
||||
picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = '';
|
||||
body.append(picker); setupPicker(picker);
|
||||
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
||||
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);
|
||||
@@ -101,6 +113,157 @@ function createRow(data) {
|
||||
row.append(actions); return row;
|
||||
}
|
||||
|
||||
/*
|
||||
* Das Sprungziel wird aus den Ankern der verknüpften Seite gewählt. Das Textfeld bleibt, was
|
||||
* gespeichert wird - versteckt, und ohne Skript sichtbar zum Eintippen.
|
||||
*/
|
||||
const adminBase = document.querySelector('meta[name="admin-base"]').content;
|
||||
function anchorChoice(row) {
|
||||
const input = row.querySelector('input[data-key="anchor"]');
|
||||
const pageValue = row.querySelector('.picker-value');
|
||||
if (!input || !pageValue || input.dataset.enhanced) return;
|
||||
input.dataset.enhanced = '1';
|
||||
const label = row.querySelector(`label[for="${input.id}"]`);
|
||||
const select = el('select'); select.id = input.id; input.id += '-value'; input.type = 'hidden';
|
||||
if (label) { label.htmlFor = select.id; label.textContent = 'Sprungziel'; }
|
||||
input.after(select);
|
||||
const help = el('p', 'field-help', ''); select.after(help);
|
||||
let request = 0;
|
||||
const load = async () => {
|
||||
const mine = ++request, current = input.value;
|
||||
select.replaceChildren(new Option('Seitenanfang', ''));
|
||||
if (current) select.append(new Option(`#${current}`, current));
|
||||
select.value = current;
|
||||
help.textContent = '';
|
||||
if (!pageValue.value) { select.disabled = true; return; }
|
||||
select.disabled = false;
|
||||
try {
|
||||
const response = await fetch(`${adminBase}/api/sprungziele/${encodeURIComponent(pageValue.value)}`, { credentials: 'same-origin' });
|
||||
if (!response.ok || response.redirected) throw new Error();
|
||||
const { items } = await response.json();
|
||||
if (mine !== request) return;
|
||||
select.replaceChildren(new Option('Seitenanfang', ''));
|
||||
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 lässt sich einer Sektion, Zeile oder Spalte ein Sprungziel geben.';
|
||||
} catch {
|
||||
if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.';
|
||||
}
|
||||
};
|
||||
select.addEventListener('change', () => { input.value = select.value; input.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
pageValue.addEventListener('change', () => { input.value = ''; load(); });
|
||||
load();
|
||||
}
|
||||
list.querySelectorAll('.menu-entry').forEach(anchorChoice);
|
||||
function linkButtons(input) {
|
||||
// Die Adresse eines eigenen Links: Seite oder PDF wählen, eine fremde Adresse tippen.
|
||||
if (input.closest('[data-link-field]')) return;
|
||||
input.dataset.linkInput = '';
|
||||
const box = el('div', 'link-field'); box.dataset.linkField = '';
|
||||
input.before(box); box.append(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();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
input.removeAttribute('aria-activedescendant'); current = -1;
|
||||
};
|
||||
const select = item => {
|
||||
value.value = item?.id ?? '';
|
||||
// Most fields store the page id; a setting that keeps an address asks for the path instead.
|
||||
value.value = item?.[picker.dataset.value || 'id'] ?? '';
|
||||
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
|
||||
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
|
||||
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
|
||||
@@ -51,7 +52,11 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
};
|
||||
input.addEventListener('focus', () => search(''));
|
||||
input.addEventListener('input', () => {
|
||||
value.value = ''; input.setCustomValidity(input.value && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
// Ein freies Feld (etwa das Ziel einer Weiterleitung) nimmt auch einen Pfad, der keine Seite
|
||||
// ist; gewählt wird trotzdem am bequemsten aus der Suche.
|
||||
const free = picker.dataset.free === '1';
|
||||
value.value = free ? input.value.trim().replace(/^\/+/, '') : '';
|
||||
input.setCustomValidity(input.value && !free && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
value.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
|
||||
});
|
||||
@@ -65,3 +70,30 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
}
|
||||
|
||||
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
|
||||
/**
|
||||
* Eine Seite oder einen Beitrag für ein Linkziel wählen, in einem kleinen Dialog mit derselben
|
||||
* Suche wie in den Menüs. onChoose bekommt den Treffer mit Titel und Pfad.
|
||||
*/
|
||||
export function openPageDialog({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'media-dialog page-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'page-dialog-title');
|
||||
const source = document.querySelector('meta[name="admin-base"]').content + '/api/content-choices';
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">LINKZIEL</p><h2 id="page-dialog-title">Seite wählen</h2></div><button type="button" class="secondary" data-close>Schließen</button></div>
|
||||
<div class="content-picker">
|
||||
<label for="page-dialog-search">Seite oder Beitrag suchen</label>
|
||||
<div class="picker-input"><input id="page-dialog-search" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="page-dialog-results" placeholder="Titel oder Adresse …"><button type="button" class="subtle picker-clear" aria-label="Suche leeren">✕</button></div>
|
||||
<input type="hidden" class="picker-value">
|
||||
<div id="page-dialog-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
|
||||
<p class="picker-status field-help" role="status"></p>
|
||||
</div>`;
|
||||
const picker = dialog.querySelector('.content-picker');
|
||||
picker.dataset.source = source; picker.classList.add('menu-add');
|
||||
document.body.append(dialog);
|
||||
setupPicker(picker, item => { if (item) { onChoose?.(item); dialog.close(); } });
|
||||
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
dialog.showModal();
|
||||
dialog.querySelector('#page-dialog-search').focus();
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
* the alternative would be a selection engine of our own. The formatting it produces is normalised
|
||||
* on the server anyway, so a browser quirk cannot store something unexpected.
|
||||
*/
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { openDocuments } from './media.js';
|
||||
|
||||
/** Mirrors RichText::ALLOWED. Only used for the hint text and for cleaning up on paste. */
|
||||
export const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'mark', 'sup', 'sub', 'ul', 'ol', 'li', 'a'];
|
||||
@@ -116,7 +118,12 @@ export function createRichText(value, apply, { label = 'Text', plain = false } =
|
||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
||||
});
|
||||
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
// Eine eigene Seite oder ein PDF wählt man, statt die Adresse zu kennen.
|
||||
const fill = value => { linkInput.value = value; linkInput.focus(); };
|
||||
linkRow.append(linkInput,
|
||||
button('Seite …', 'Seite oder Beitrag als Linkziel wählen', () => openPageDialog({ onChoose: item => fill(item.path === 'home' ? '/' : `/${item.path}`) }), 'text-button'),
|
||||
button('PDF …', 'PDF aus der Mediathek als Linkziel wählen', () => openDocuments({ onChoose: item => fill(item.src) }), 'text-button'),
|
||||
newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
+73
-4
@@ -44,6 +44,11 @@ p{hyphens:auto}
|
||||
|
||||
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
|
||||
.block-icon{flex-shrink:0;display:block}
|
||||
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
|
||||
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
|
||||
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
|
||||
.button-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
|
||||
@@ -234,6 +239,11 @@ body.consent-open{padding-bottom:320px}
|
||||
.embed-title{margin:0 0 8px;font-weight:600}
|
||||
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:var(--muted)}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
|
||||
/* Eigenes HTML: nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt. Alles
|
||||
Weitere bestimmt der Code selbst - das ist der Zweck des Bausteins. */
|
||||
.block-html{max-width:100%}
|
||||
.block-html img,.block-html video,.block-html table{max-width:100%}
|
||||
.block-html table{border-collapse:collapse}
|
||||
|
||||
/* Header and footer hold block documents of their own (ADR 026). They are therefore ordinary
|
||||
block containers - as flex containers they shrank the section inside them until a single word
|
||||
@@ -257,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}
|
||||
@@ -433,9 +446,14 @@ body.consent-open{padding-bottom:320px}
|
||||
.kant .width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:767px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
|
||||
/* A section that is a flex container centres its content box; the inner container must then be
|
||||
allowed to grow, or a full height section would show a strip in the middle. */
|
||||
.block-section>.section-inner{flex:1 1 auto}
|
||||
/* Ein Abschnitt, der ein Flex-Kasten ist, reiht seinen Inhaltskasten als Flex-Element ein.
|
||||
Wachsen darf der dabei nicht: die Laufrichtung ist voreingestellt die Zeile, und dort frisst
|
||||
das Wachsen die Randluft des Containers. Auf dem Telefon stand der Text eines Hero-Abschnitts
|
||||
damit hart an der Kante, waehrend Kopf und alle uebrigen Abschnitte ihre 18 px hatten - am
|
||||
Schreibtisch fiel es nicht auf, weil dort die Hoechstbreite des Designs vorher bremst.
|
||||
Die Hoehe braucht das Wachsen nicht: quer zur Laufrichtung dehnt sich ein Flex-Element
|
||||
ohnehin ueber die volle Hoehe, und steht "Ausrichtung: Mitte", ist die Mitte gewollt. */
|
||||
.block-section>.section-inner{flex:0 1 auto}
|
||||
|
||||
/* Menu kinds. Horizontal is the default; vertical stacks; burger is a details element, so it
|
||||
opens without a script - a menu that needs one would not open on a page that runs none. */
|
||||
@@ -467,17 +485,55 @@ body.consent-open{padding-bottom:320px}
|
||||
weder faerben noch abschalten. Linie und Schatten kommen dann aus Rahmen und Wirkung. */
|
||||
.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}
|
||||
.site-header .menu-burger[open]>summary{border-radius:8px}
|
||||
/* Die Flaeche malt ihren eigenen Untergrund, also setzt sie auch ihre eigene Schriftfarbe.
|
||||
Ohne das erbte sie die Farbe des Kopfbereichs: bei einem Kopf, der ueber einem dunklen Bild
|
||||
schwebt und deshalb weiss schreibt, stand das aufgeklappte Menue weiss auf weiss. */
|
||||
.site-header .menu-burger-body{position:absolute;left:0;right:0;top:100%;z-index:30;
|
||||
padding:15px max(16px,calc((100% - var(--k-container,1180px)) / 2 + 16px)) 25px;
|
||||
background:var(--paper);box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
|
||||
background:var(--paper);color:var(--ink);
|
||||
box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
|
||||
.site-header .menu-burger-body .menu-list{gap:0}
|
||||
.site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px}
|
||||
/* 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}
|
||||
@media (min-width:1025px){.visibility-tablet,.visibility-mobile,.visibility-hide-desktop{display:none!important}}
|
||||
@media (min-width:768px) and (max-width:1024px){.visibility-desktop,.visibility-mobile,.visibility-hide-tablet{display:none!important}}
|
||||
@media (max-width:767px){.visibility-desktop,.visibility-tablet,.visibility-hide-mobile{display:none!important}}
|
||||
/* Das Sprungziel des Knopfs „nach oben“, ein leerer <span> am Anfang von <body>.
|
||||
WebKit springt nicht zu einem Ziel, dessen Box inline ist und keine Flaeche hat - der Tipp
|
||||
kam an, #seitenanfang stand in der Adresse, und die Seite blieb stehen. Auf dem iPhone,
|
||||
und ebenso in Safari auf dem Mac; Chromium ist toleranter, deshalb fiel es am Schreibtisch
|
||||
nie auf. Ein leerer Block ohne Hoehe reicht WebKit, es ist die Inline-Box, die stoert.
|
||||
Nachgestellt in Playwright-WebKit mit iPhone-Profil: inline bleibt bei 1500, block springt. */
|
||||
#seitenanfang{display:block}
|
||||
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
|
||||
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
|
||||
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
|
||||
@@ -531,6 +587,19 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
|
||||
.zoom-prev{left:8px}.zoom-next{right:8px}
|
||||
}
|
||||
/* Der PDF-Betrachter: fast bildschirmfüllend, mit einer schmalen Leiste darüber. Den Rest macht
|
||||
der Betrachter des Browsers - Blättern, Zoom, Drucken. */
|
||||
.pdf-dialog{border:0;padding:0;width:min(96vw,1200px);height:94vh;max-width:none;max-height:none;background:#1b212c;color:#fff;border-radius:12px;overflow:hidden}
|
||||
.pdf-dialog[open]{display:flex;flex-direction:column}
|
||||
.pdf-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
.pdf-bar{display:flex;align-items:center;gap:10px;padding:10px 10px 10px 18px}
|
||||
.pdf-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#fff}
|
||||
.pdf-action{color:#fff;font-size:13px;text-decoration:none;padding:9px 14px;border-radius:999px;background:rgba(255,255,255,.12);white-space:nowrap}
|
||||
.pdf-action:hover{background:rgba(255,255,255,.24)}
|
||||
.pdf-close{display:grid;place-items:center;width:40px;height:40px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:24px;line-height:1;cursor:pointer}
|
||||
.pdf-close:hover{background:rgba(255,255,255,.26)}
|
||||
.pdf-action:focus-visible,.pdf-close:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||
.pdf-frame{flex:1;width:100%;border:0;background:#525659}
|
||||
|
||||
.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Kategorien und Schlagwörter eines Beitrags: das Textfeld mit der Liste durch Komma bleibt, was
|
||||
* gespeichert wird. Darunter stehen die vorhandenen Namen zum Anklicken - ein Klick nimmt einen
|
||||
* Namen auf oder heraus. Neue Namen tippt man weiter ein.
|
||||
*
|
||||
* Über den Dokumentknoten, weil die Suche in der Inhaltsliste die Ergebnisse austauscht.
|
||||
*/
|
||||
const split = value => value.split(',').map(name => name.trim()).filter(Boolean);
|
||||
const same = (a, b) => a.localeCompare(b, 'de', { sensitivity: 'base' }) === 0;
|
||||
|
||||
const enhance = input => {
|
||||
if (input.dataset.termReady) return;
|
||||
let names = [];
|
||||
try { names = JSON.parse(input.dataset.termInput || '[]'); } catch { names = []; }
|
||||
input.dataset.termReady = '1';
|
||||
if (!names.length) return;
|
||||
const box = document.createElement('div'); box.className = 'term-chips'; box.setAttribute('role', 'group');
|
||||
const label = document.querySelector(`label[for="${input.id}"]`)?.textContent || 'Vorhandene Namen';
|
||||
box.setAttribute('aria-label', `${label}: vorhandene zum Übernehmen`);
|
||||
const sync = () => {
|
||||
const chosen = split(input.value);
|
||||
for (const chip of box.children) chip.setAttribute('aria-pressed', String(chosen.some(name => same(name, chip.dataset.name))));
|
||||
};
|
||||
for (const name of names) {
|
||||
const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'term-chip'; chip.dataset.name = name; chip.textContent = name;
|
||||
chip.addEventListener('click', () => {
|
||||
const chosen = split(input.value);
|
||||
const next = chosen.some(entry => same(entry, name)) ? chosen.filter(entry => !same(entry, name)) : [...chosen, name];
|
||||
input.value = next.join(', ');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
sync();
|
||||
});
|
||||
box.append(chip);
|
||||
}
|
||||
input.addEventListener('input', sync);
|
||||
const help = input.parentElement.querySelector('.field-help');
|
||||
(help ?? input).after(box);
|
||||
sync();
|
||||
};
|
||||
const all = () => document.querySelectorAll('[data-term-input]').forEach(enhance);
|
||||
all();
|
||||
new MutationObserver(all).observe(document.body, { childList: true, subtree: true });
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Legt auf der bestehenden DEEG-Installation die Seite „Angebote“ an und verweist von der
|
||||
* Navigation und vom Angebote-Band der Startseite dorthin.
|
||||
*
|
||||
* Die Seite zeigt die Ergebnisliste des DEEG-Portals bei ImmobilienScout24 als eingebetteten
|
||||
* Rahmen – Baustein „Externer Inhalt“, nicht eigenes HTML: nur er fragt vorher um Einwilligung
|
||||
* und trägt den Anbieter in die Sicherheitsrichtlinie der Seite ein. Kopfabschnitt, Schriftgrößen
|
||||
* und Farben sind dieselben wie bei Impressum und Datenschutz (siehe seed-immobilien-deeg.php).
|
||||
*
|
||||
* Läuft nur gegen die DEEG-Datenbank und nur, solange es die Seite noch nicht gibt.
|
||||
*/
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
if (!str_contains($url, 'immobilien-deeg')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$db = ConnectionFactory::create($url);
|
||||
$media = new MediaLibrary($db, str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']));
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
|
||||
// Die Seite war im Editor schon angelegt (Starterabschnitt plus Rahmen mit Platzhaltertexten).
|
||||
// Sie wird überschrieben, nicht verdoppelt; der bisherige Stand bleibt als Fassung erhalten und
|
||||
// lässt sich im Editor unter „Fassungen“ zurückholen.
|
||||
$vorhanden = $db->fetchOne('SELECT id FROM page WHERE path = ?', ['angebote']);
|
||||
|
||||
$SCHEMA = 24;
|
||||
$portal = 'https://portal.immobilienscout24.de/ergebnisliste/16200281';
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
|
||||
// Der Kopf wie auf Impressum und Datenschutz: helle Fläche, Kleinzeile, große leichte Überschrift.
|
||||
$kopf = ['id' => 'angebote-kopf', 'type' => 'section', 'children' => [
|
||||
['id' => 'angebote-label', 'type' => 'text', 'props' => ['text' => 'AKTUELLE OBJEKTE'], 'style' => [
|
||||
'desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'margin' => ['bottom' => '25px']],
|
||||
'mobile' => ['fontSize' => '11px', 'margin' => ['bottom' => '18px']],
|
||||
], 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
['id' => 'angebote-h1', 'type' => 'heading', 'props' => ['text' => 'Unsere Angebote', 'level' => 1], 'style' => [
|
||||
'desktop' => ['fontWeight' => '400', 'fontSize' => '56px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.05em', 'margin' => ['bottom' => '22px']],
|
||||
'tablet' => ['fontSize' => '44px'],
|
||||
'mobile' => ['fontSize' => '38px', 'margin' => ['bottom' => '16px']],
|
||||
], 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
|
||||
['id' => 'angebote-vorspann', 'type' => 'text', 'props' => ['text' => 'Wohnungen, Häuser und Grundstücke aus unserem Bestand in Freudenstadt und Umgebung. Die Liste wird laufend aktualisiert – sehen Sie sich um und sprechen Sie uns an.'], 'style' => [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
], 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'medium']]],
|
||||
], 'style' => [
|
||||
'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => ['top' => '84px', 'bottom' => '72px']],
|
||||
'tablet' => ['padding' => ['top' => '64px', 'bottom' => '56px']],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '40px']],
|
||||
]];
|
||||
|
||||
// Die Liste selbst. Der Rahmen bleibt hoch genug, dass mehrere Objekte ohne Scrollen im Rahmen
|
||||
// sichtbar sind; darunter der Weg zum Portal für alle, die lieber dort suchen.
|
||||
$hinweisText = 'Nichts Passendes dabei? <a href="'.$portal.'" target="_blank" rel="noopener">Alle Objekte direkt bei ImmobilienScout24 ansehen ↗</a> oder <a href="/#kontakt">sprechen Sie uns an</a> – wir kennen den Markt vor Ort und wissen oft von Objekten, bevor sie inseriert sind.';
|
||||
$liste = ['id' => 'angebote-liste', 'type' => 'section', 'children' => [
|
||||
['id' => 'angebote-rahmen', 'type' => 'embed', 'props' => [
|
||||
'group' => 'external',
|
||||
'title' => 'Aktuelle Immobilienangebote der Deeg Hausverwaltung und Immobilien GmbH bei ImmobilienScout24',
|
||||
'provider' => 'ImmobilienScout24',
|
||||
'url' => $portal,
|
||||
'hint' => 'Die Angebotsliste wird von ImmobilienScout24 geladen. Dabei erfährt der Anbieter Ihre IP-Adresse und welche Seite Sie ansehen.',
|
||||
'height' => 1000,
|
||||
], 'style' => ['desktop' => ['margin' => ['bottom' => '26px']]], 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']],
|
||||
// Der Rahmen selbst und der Platzhalter davor tragen den Zuschnitt der Vorlage: eckig,
|
||||
// helle Fläche, Logo-Blau auf dem Knopf. Was IM Rahmen steht, gehört ImmobilienScout24 -
|
||||
// daran kommt kein CSS dieser Website heran, und das ist keine Einstellung, sondern die
|
||||
// Trennung zwischen zwei Herkünften im Browser.
|
||||
'css' => "& iframe{border:1px solid #dbe1e3;border-radius:0;background:#f5fafd}
|
||||
&.embed-blocked{border:1px solid #dbe1e3;border-radius:0;background:#f5fafd;padding:56px 24px;text-align:left}
|
||||
& .embed-title{font-size:20px;font-weight:700;color:#343a40;margin-bottom:12px}
|
||||
& .embed-hint{max-width:640px;font-size:15px;line-height:1.7;color:#58666d}
|
||||
& .embed-provider{font-size:13px;color:#66747b}
|
||||
& .consent-button{border-radius:0;background:#0076b6;border-color:#0076b6;color:#fff;flex:0 0 auto;padding:16px 24px}
|
||||
& .consent-button:hover{background:#006aa3;border-color:#006aa3}",
|
||||
],
|
||||
['id' => 'angebote-hinweis', 'type' => 'text', 'props' => ['text' => $hinweisText], 'style' => [
|
||||
'desktop' => ['maxWidth' => '760px', 'fontSize' => '16px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
], 'css' => "& a{color:#006aa3;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#343a40}",
|
||||
'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
|
||||
], 'style' => [
|
||||
'desktop' => ['padding' => ['top' => '72px', 'bottom' => '108px']],
|
||||
'tablet' => ['padding' => ['top' => '56px', 'bottom' => '80px']],
|
||||
'mobile' => ['padding' => ['top' => '40px', 'bottom' => '65px']],
|
||||
]];
|
||||
|
||||
$dokumentAngebote = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Angebote',
|
||||
'description' => 'Aktuelle Wohnungen, Häuser und Grundstücke der Deeg Hausverwaltung und Immobilien GmbH in Freudenstadt und Umgebung.',
|
||||
'blocks' => [$kopf, $liste],
|
||||
];
|
||||
if (false !== $vorhanden) {
|
||||
$id = (string) $vorhanden;
|
||||
$revision = $pages->saveDraft($id, $dokumentAngebote, (int) $pages->findForEditor($id)['version']);
|
||||
$pages->publish($id, $revision, (int) $pages->findForEditor($id)['version']);
|
||||
echo "Seite überarbeitet und veröffentlicht: /angebote\n";
|
||||
} else {
|
||||
$id = $pages->createPage('angebote', null, 'page', $dokumentAngebote);
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
echo "Seite angelegt und veröffentlicht: /angebote\n";
|
||||
}
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', ['Aktuelle Angebote | Deeg Hausverwaltung und Immobilien GmbH', $id]);
|
||||
|
||||
// „Immobilien“ in der Navigation zeigte auf den Sprungpunkt der Startseite; jetzt gibt es die
|
||||
// Seite, auf die dieser Abschnitt ohnehin nur verwiesen hat.
|
||||
$header = $menus->all()['header'];
|
||||
$eintraege = [];
|
||||
foreach ($header['items'] as $eintrag) {
|
||||
if ('Immobilien' === $eintrag['label']) {
|
||||
$eintrag['pageId'] = $id;
|
||||
$eintrag['anchor'] = '';
|
||||
}
|
||||
$eintraege[] = $eintrag;
|
||||
}
|
||||
$menus->save('header', $header['name'], $eintraege, (int) $header['version']);
|
||||
echo "Navigation: „Immobilien“ zeigt auf /angebote\n";
|
||||
|
||||
// Und der Knopf im Angebote-Band der Startseite, der bisher auf die alte Website führte.
|
||||
$home = $db->fetchAssociative('SELECT id FROM page WHERE path = ?', ['home']);
|
||||
$dokument = $pages->findForEditor($home['id'])['document'];
|
||||
$getroffen = 0;
|
||||
$walk = function (array &$blocks) use (&$walk, &$getroffen): void {
|
||||
foreach ($blocks as &$block) {
|
||||
// Der Knopf im Angebote-Band ist ein Textbaustein mit eigenem CSS, kein Button-Baustein -
|
||||
// deshalb wird die Adresse im Text ersetzt, nicht in den Eigenschaften.
|
||||
foreach (['href', 'text'] as $feld) {
|
||||
$wert = (string) ($block['props'][$feld] ?? '');
|
||||
if ('' !== $wert && str_contains($wert, 'immobiliendeeg.de/immobilien')) {
|
||||
$block['props'][$feld] = str_replace(['https://www.immobiliendeeg.de/immobilien/', 'https://www.immobiliendeeg.de/immobilien'], '/angebote', $wert);
|
||||
unset($block['props']['newTab']);
|
||||
++$getroffen;
|
||||
}
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$walk($block['children']);
|
||||
}
|
||||
}
|
||||
};
|
||||
$walk($dokument['blocks']);
|
||||
if ($getroffen > 0) {
|
||||
$stand = $pages->findForEditor($home['id']);
|
||||
$revision = $pages->saveDraft($home['id'], $dokument, $stand['version']);
|
||||
$pages->publish($home['id'], $revision, $pages->findForEditor($home['id'])['version']);
|
||||
}
|
||||
echo "Startseite: $getroffen Knopf/Knöpfe zeigen jetzt auf /angebote\n";
|
||||
|
||||
// Ein fremder Rahmen ohne Einwilligung lädt beim Seitenaufruf mit und gibt dabei IP-Adresse und
|
||||
// Seite an den Anbieter weiter. Mit der Gruppe „Externe Inhalte“ steht davor der Platzhalter, und
|
||||
// erst der Knopf lädt die Liste. Der Balken trägt die Farben der Website.
|
||||
$aktuell = $settings->load();
|
||||
$settings->save([
|
||||
...$aktuell,
|
||||
// Gespeichert als Liste, erwartet als Textfeld - wie es der Einstellungsbildschirm schickt.
|
||||
'sameAs' => implode("\n", $aktuell['sameAs']),
|
||||
'consentBanner' => true,
|
||||
'consentGroups' => ['external'],
|
||||
'consentShape' => 'eckig',
|
||||
'consentAccent' => '@blau-tief',
|
||||
'consentAccentInk' => '@papier',
|
||||
'consentColor' => '@papier',
|
||||
'consentInk' => '@anthrazit',
|
||||
]);
|
||||
echo "Einwilligung eingeschaltet: Gruppe „Externe Inhalte“, Balken eckig in Logo-Blau\n";
|
||||
@@ -76,9 +76,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 +149,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,7 +161,7 @@ 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');
|
||||
@@ -172,7 +178,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 +206,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 +219,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,114 @@
|
||||
/*
|
||||
* Zeichnet die Bildkarten des Solarchecks von EB Solar und rastert sie zu PNG.
|
||||
*
|
||||
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
|
||||
* Mediathek hat - und die Mediathek nimmt JPEG, PNG und WebP, kein SVG. Deshalb entstehen die
|
||||
* Symbole hier als SVG (leicht zu ändern, alles in einer Datei) und werden mit Chromium auf
|
||||
* 800 × 600 gerastert, dem Seitenverhältnis 4:3 der Karte. Stil wie die übrigen Symbole der
|
||||
* Website: dünne Linie in Tinte, runde Enden, ein roter Akzent, helle Fläche dahinter.
|
||||
*
|
||||
* node scripts/build-ebsolar-options.mjs → var/ebsolar-assets/options/*.png und *.svg
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ziel = process.argv[2] || 'var/ebsolar-assets/options';
|
||||
const TINTE = '#252b30', ROT = '#b52c2c', HELL = '#f5f6f6', PAPIER = '#ffffff', GRAU = '#c9ced0';
|
||||
|
||||
/** Ein Bild: 800 × 600, helle Fläche, Inhalt in der Mitte. */
|
||||
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
|
||||
<rect width="800" height="600" fill="${HELL}"/>
|
||||
<g fill="none" stroke="${TINTE}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
|
||||
</svg>`;
|
||||
|
||||
/** Eine Person: Kopf und Schultern. */
|
||||
const person = (x, y, s = 1, farbe = TINTE) => `
|
||||
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
|
||||
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
|
||||
|
||||
/** Ein Haus: Wände plus Dach nach Angabe; das Dach ist der Akzent. */
|
||||
const haus = (dach) => `
|
||||
<path d="M190 520V300M610 520V300M150 520h500"/>
|
||||
<rect x="360" y="410" width="80" height="110" rx="6"/>
|
||||
${dach}`;
|
||||
|
||||
/** Ein Dach als schräge Fläche mit Modulen im Raster. */
|
||||
const module = (spalten, zeilen) => {
|
||||
const x0 = 160, y0 = 150, b = 480, h = 300, scher = 90;
|
||||
const zellen = [];
|
||||
for (let z = 0; z < zeilen; z++) {
|
||||
for (let s = 0; s < spalten; s++) {
|
||||
const bx = b / spalten, bh = h / zeilen;
|
||||
const x = x0 + s * bx + (z * bh) * (scher / h), y = y0 + z * bh;
|
||||
zellen.push(`<path d="M${x + 8} ${y + 8}h${bx - 16}l${(bh - 16) * scher / h} ${bh - 16}h-${bx - 16}z" fill="${ROT}" fill-opacity=".14" stroke="${ROT}" stroke-width="8"/>`);
|
||||
}
|
||||
}
|
||||
return `<path d="M${x0} ${y0}h${b}l${scher} ${h}h-${b}z"/>${zellen.join('')}`;
|
||||
};
|
||||
|
||||
const fragezeichen = (x, y, s = 1) => `<path d="M${x - 40 * s} ${y - 50 * s}a${40 * s} ${40 * s} 0 1 1 ${58 * s} ${36 * s}c-${14 * s} ${9 * s}-${18 * s} ${18 * s}-${18 * s} ${34 * s}" stroke="${ROT}"/><circle cx="${x}" cy="${y + 65 * s}" r="9" fill="${ROT}" stroke="none"/>`;
|
||||
|
||||
const batterie = (inhalt, farbe = TINTE) => `
|
||||
<rect x="190" y="200" width="380" height="200" rx="26" stroke="${farbe}"/>
|
||||
<path d="M570 260h30a20 20 0 0 1 20 20v40a20 20 0 0 1-20 20h-30" stroke="${farbe}"/>
|
||||
${inhalt}`;
|
||||
|
||||
const bilder = {
|
||||
// Wie viele Personen leben im Haushalt?
|
||||
'personen-1': person(400, 320, 1.3),
|
||||
'personen-2': person(300, 320, 1.15) + person(500, 320, 1.15),
|
||||
'personen-3-4': person(210, 330) + person(400, 330) + person(590, 330),
|
||||
'personen-4-plus': person(160, 330, .85) + person(320, 330, .85) + person(480, 330, .85) + person(640, 330, .85)
|
||||
+ `<path d="M715 140v70M680 175h70" stroke="${ROT}"/>`,
|
||||
|
||||
// Welche Form hat Ihr Dach?
|
||||
'dach-flach': haus(`<path d="M150 300h500" stroke="${ROT}"/><path d="M200 300v-40h400v40" stroke="${ROT}"/>`),
|
||||
'dach-sattel': haus(`<path d="M140 320 400 120l260 200" stroke="${ROT}"/>`),
|
||||
'dach-pult': haus(`<path d="M150 330 650 190" stroke="${ROT}"/><path d="M610 300v-95" />`),
|
||||
'dach-versetzt': `
|
||||
<path d="M120 520h560M160 520V370M440 520V270M660 520V330"/>
|
||||
<rect x="260" y="420" width="70" height="100" rx="6"/>
|
||||
<path d="M120 380 290 250l150 120" stroke="${ROT}"/><path d="M440 290 560 180l130 140" stroke="${ROT}"/>`,
|
||||
'dach-sonstiges': haus(`<path d="M140 320 400 120l260 200" stroke="${GRAU}"/>`) + fragezeichen(400, 250, 1.1),
|
||||
|
||||
// Wie groß ist Ihre Dachfläche?
|
||||
'flaeche-50': module(2, 1),
|
||||
'flaeche-100': module(3, 2),
|
||||
'flaeche-200': module(5, 3),
|
||||
'flaeche-200-plus': module(7, 4),
|
||||
'flaeche-unsicher': `<path d="M160 150h480l90 300H160z" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
|
||||
|
||||
// Aus welchem Material besteht die Dacheindeckung?
|
||||
'eindeckung-ziegel': [0, 1, 2, 3].map(z => [0, 1, 2, 3, 4].map(s => {
|
||||
if (z % 2 === 1 && s === 4) return '';
|
||||
const x = 150 + s * 100 + (z % 2) * 50, y = 170 + z * 80;
|
||||
return `<path d="M${x} ${y}a50 50 0 0 0 100 0" stroke="${z === 2 && s === 2 ? ROT : TINTE}"/>`;
|
||||
}).join('')).join(''),
|
||||
'eindeckung-bitumen': `
|
||||
<path d="M130 440h540v50H130z"/>
|
||||
<circle cx="250" cy="360" r="70"/><circle cx="250" cy="360" r="26"/>
|
||||
<path d="M320 400h340" stroke="${ROT}" stroke-width="22"/>
|
||||
<path d="M250 430h410" stroke="${ROT}" stroke-width="22" stroke-opacity=".35"/>`,
|
||||
'eindeckung-blech': `
|
||||
<path d="M120 340h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30"/>
|
||||
<path d="M120 420h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30" stroke="${GRAU}"/>
|
||||
<path d="M120 340v80M680 340v80" stroke="${GRAU}"/>
|
||||
<path d="M350 340l30-70h60l30 70" stroke="${ROT}"/>`,
|
||||
'eindeckung-anderes': `<rect x="200" y="150" width="400" height="300" rx="20" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
|
||||
|
||||
// Wünschen Sie einen Stromspeicher?
|
||||
'speicher-ja': batterie(`<path d="M420 230 350 310h60l-30 70 70-80h-60z" fill="${ROT}" stroke="${ROT}" stroke-width="10"/>`),
|
||||
'speicher-nein': batterie('', GRAU) + `<path d="M190 470 610 130" stroke="${ROT}"/>`,
|
||||
};
|
||||
|
||||
await mkdir(ziel, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
|
||||
for (const [name, inhalt] of Object.entries(bilder)) {
|
||||
const svg = bild(inhalt);
|
||||
await writeFile(`${ziel}/${name}.svg`, svg);
|
||||
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
|
||||
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
|
||||
console.log('gezeichnet:', name);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,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,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Schneidet aus dem gelieferten Banner (kantarci-banner.jpeg, 901 × 458) die Bilder der
|
||||
* KANTARCI-Landingpage zu: das Logo, das Hausmotiv und ein quadratisches Seitensymbol.
|
||||
* Die Mediathek nimmt kein SVG, deshalb entsteht das Symbol als PNG.
|
||||
*
|
||||
* Aufruf: php scripts/build-kantarci-assets.php <banner.jpeg> <zielordner>
|
||||
*/
|
||||
|
||||
[$_, $banner, $ziel] = $argv + [null, null, null];
|
||||
if (!is_file((string) $banner) || null === $ziel) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-kantarci-assets.php <banner.jpeg> <zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel)) {
|
||||
mkdir($ziel, 0777, true);
|
||||
}
|
||||
$quelle = imagecreatefromjpeg($banner);
|
||||
|
||||
$zuschnitt = static function (int $x, int $y, int $b, int $h, int $faktor = 1) use ($quelle) {
|
||||
$bild = imagecreatetruecolor($b * $faktor, $h * $faktor);
|
||||
imagecopyresampled($bild, $quelle, 0, 0, $x, $y, $b * $faktor, $h * $faktor, $b, $h);
|
||||
|
||||
return $bild;
|
||||
};
|
||||
|
||||
// Logo: Bildmarke und Schriftzug oben links, auf Weiß.
|
||||
imagepng($zuschnitt(22, 8, 396, 104), $ziel.'/logo-kantarci.png', 9);
|
||||
|
||||
// Hausmotiv: der Fotoanteil rechts, oberhalb des goldenen Bands.
|
||||
imagejpeg($zuschnitt(500, 0, 401, 334), $ziel.'/haus.jpg', 90);
|
||||
|
||||
// Seitensymbol: die Bildmarke mittig auf Weiß, 256 × 256.
|
||||
$symbol = imagecreatetruecolor(256, 256);
|
||||
imagefill($symbol, 0, 0, imagecolorallocate($symbol, 255, 255, 255));
|
||||
imagecopyresampled($symbol, $quelle, 28, 28, 26, 12, 200, 200, 80, 80);
|
||||
imagepng($symbol, $ziel.'/symbol-kantarci.png', 9);
|
||||
|
||||
echo "Bilder geschrieben nach $ziel\n";
|
||||
@@ -0,0 +1,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,83 @@
|
||||
/*
|
||||
* Zeichnet die Bildkarten der KANTARCI-Mietanfrage und rastert sie zu PNG.
|
||||
*
|
||||
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
|
||||
* Mediathek hat - und die Mediathek nimmt kein SVG. Deshalb entstehen die Symbole hier als SVG
|
||||
* und werden mit Chromium auf 800 × 600 (4:3 wie die Karte) gerastert. Stil der Landingpage:
|
||||
* dünne Linie in Navy, runde Enden, ein goldener Akzent, warme helle Fläche dahinter.
|
||||
*
|
||||
* node scripts/build-kantarci-options.mjs → var/kantarci-immobilien-assets/options/*.png und *.svg
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ziel = process.argv[2] || 'var/kantarci-immobilien-assets/options';
|
||||
const NAVY = '#142535', GOLD = '#b49a66', HELL = '#f5f3ee', PAPIER = '#ffffff';
|
||||
|
||||
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
|
||||
<rect width="800" height="600" fill="${HELL}"/>
|
||||
<g fill="none" stroke="${NAVY}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
|
||||
</svg>`;
|
||||
|
||||
/** Eine Person: Kopf und Schultern. */
|
||||
const person = (x, y, s = 1, farbe = NAVY) => `
|
||||
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
|
||||
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
|
||||
|
||||
/** Ein Grundriss: Außenwand, dazu Innenwände; der jeweils letzte Raum ist golden hinterlegt. */
|
||||
const grundriss = (waende, akzent) => `
|
||||
${akzent ? `<path d="${akzent}" fill="${GOLD}" fill-opacity=".22" stroke="none"/>` : ''}
|
||||
<rect x="170" y="130" width="460" height="340" rx="8"/>
|
||||
${waende}`;
|
||||
|
||||
const bilder = {
|
||||
// Wer zieht ein?
|
||||
'haushalt-allein': person(400, 330, 1.3),
|
||||
'haushalt-zu-zweit': person(275, 330, 1.1) + person(525, 330, 1.1, GOLD),
|
||||
'haushalt-familie': person(250, 320, 1.1) + person(550, 320, 1.1) + person(400, 400, .7, GOLD),
|
||||
'haushalt-wg': person(200, 340, .9) + person(400, 340, .9, GOLD) + person(600, 340, .9),
|
||||
|
||||
// Wie viele Zimmer?
|
||||
'zimmer-1': grundriss('<path d="M340 470v-50" />', 'M177 137h446v326H177z'),
|
||||
'zimmer-2': grundriss('<path d="M400 130v230M400 420v50"/>', 'M407 137h216v326H407z'),
|
||||
'zimmer-3': grundriss('<path d="M360 130v230M360 420v50M360 300h270"/>', 'M367 307h256v156H367z'),
|
||||
'zimmer-4': grundriss('<path d="M400 130v340M170 300h170M460 300h170"/>', 'M407 307h216v156H407z')
|
||||
+ `<path d="M690 110v80M650 150h80" stroke="${GOLD}"/>`,
|
||||
|
||||
// Was ist Ihnen wichtig?
|
||||
'ausstattung-balkon': `
|
||||
<path d="M250 110h300v230H250z"/><path d="M400 110v230"/>
|
||||
<path d="M170 340h460M170 340v150M630 340v150M170 490h460" stroke="${GOLD}"/>
|
||||
<path d="M245 340v150M320 340v150M400 340v150M480 340v150M555 340v150" stroke="${GOLD}" stroke-width="10"/>`,
|
||||
'ausstattung-aufzug': `
|
||||
<rect x="230" y="100" width="340" height="400" rx="12"/><path d="M400 100v400"/>
|
||||
<path d="M620 210l40-50 40 50z" fill="${GOLD}" stroke="${GOLD}"/><path d="M620 300l40 50 40-50z" fill="${GOLD}" stroke="${GOLD}"/>`,
|
||||
'ausstattung-stellplatz': `
|
||||
<rect x="230" y="110" width="340" height="380" rx="40"/>
|
||||
<path d="M340 420V180h80a60 60 0 0 1 0 120h-80" stroke="${GOLD}" stroke-width="22"/>`,
|
||||
'ausstattung-kueche': `
|
||||
<rect x="180" y="200" width="440" height="290" rx="10"/><path d="M180 270h440"/>
|
||||
<circle cx="300" cy="235" r="14" fill="${NAVY}" stroke="none"/><circle cx="360" cy="235" r="14" fill="${NAVY}" stroke="none"/>
|
||||
<rect x="250" y="320" width="300" height="120" rx="8" stroke="${GOLD}"/>
|
||||
<path d="M300 140c0-30 30-30 30-60M400 140c0-30 30-30 30-60M500 140c0-30 30-30 30-60" stroke="${GOLD}" stroke-width="10"/>`,
|
||||
'ausstattung-barrierefrei': `
|
||||
<circle cx="380" cy="130" r="36" stroke="${GOLD}"/>
|
||||
<path d="M380 180v130h120l50 110" stroke="${GOLD}"/><path d="M380 240h100"/>
|
||||
<path d="M330 260a120 120 0 1 0 170 150"/>`,
|
||||
'ausstattung-haustier': `
|
||||
<path d="M400 330c-70 0-130 70-130 120 0 40 40 50 70 40s50-10 60-10 30 0 60 10 70 0 70-40c0-50-60-120-130-120z" fill="${GOLD}" fill-opacity=".25" stroke="${GOLD}"/>
|
||||
<ellipse cx="260" cy="270" rx="42" ry="55"/><ellipse cx="350" cy="190" rx="42" ry="58"/>
|
||||
<ellipse cx="450" cy="190" rx="42" ry="58"/><ellipse cx="540" cy="270" rx="42" ry="55"/>`,
|
||||
};
|
||||
|
||||
await mkdir(ziel, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
|
||||
for (const [name, inhalt] of Object.entries(bilder)) {
|
||||
const svg = bild(inhalt);
|
||||
await writeFile(`${ziel}/${name}.svg`, svg);
|
||||
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
|
||||
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
|
||||
console.log('gezeichnet:', name);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -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();
|
||||
}
|
||||
+193
-1
@@ -360,8 +360,43 @@ try {
|
||||
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
|
||||
return Boolean(previous?.classList.contains('button'));
|
||||
}, headingId);
|
||||
// Ein PDF als Linkziel: im Dialog hochladen, übernehmen, die Adresse steht im Feld.
|
||||
await page.getByRole('button', { name: 'PDF aus Mediathek wählen', exact: true }).click();
|
||||
const documentDialog = page.locator('dialog.document-dialog');
|
||||
await documentDialog.waitFor();
|
||||
await documentDialog.locator('#document-upload:enabled').waitFor({ state: 'attached' });
|
||||
await documentDialog.locator('#document-upload').setInputFiles({ name: 'Preisliste.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF\n') });
|
||||
await documentDialog.locator('.document-card[aria-pressed="true"]').waitFor();
|
||||
assert.equal(await documentDialog.locator('#document-error').isHidden(), true, 'the PDF upload reports no error');
|
||||
const pdfAxe = await new AxeBuilder({ page }).include('dialog.document-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(pdfAxe.violations.map(v => v.id), [], 'the PDF dialog has no accessibility violations');
|
||||
await documentDialog.getByRole('button', { name: 'PDF übernehmen', exact: true }).click();
|
||||
await documentDialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/);
|
||||
await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? ''));
|
||||
checks.push({ name: 'a PDF from the library as a link target', passed: true });
|
||||
// Eine eigene Seite als Linkziel: gesucht und gewählt, nicht als Pfad abgetippt.
|
||||
await page.getByRole('button', { name: 'Seite wählen', exact: true }).click();
|
||||
const pageDialog = page.locator('dialog.page-dialog');
|
||||
await pageDialog.locator('[role="option"]').first().waitFor();
|
||||
const pageAxe = await new AxeBuilder({ page }).include('dialog.page-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(pageAxe.violations.map(v => v.id), [], 'the page dialog has no accessibility violations');
|
||||
await pageDialog.locator('[role="option"]').first().click();
|
||||
await pageDialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/[a-z0-9\-/]*$/);
|
||||
checks.push({ name: 'a page as a link target, picked by search', passed: true });
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
// Verknüpfte Dinge kommen aus Listen: Formular, Kategorie und Schlagwort sind Auswahlfelder.
|
||||
for (const [type, id] of [['form', 'property-form'], ['postlist', 'property-category'], ['postlist', 'property-tag']]) {
|
||||
await addFromLibrary(`button[data-add="${type}"]`);
|
||||
assert.equal(await page.locator(`#${id}`).evaluate(node => node.tagName), 'SELECT', `${id} is a choice, not a typed address`);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
}
|
||||
checks.push({ name: 'forms, categories and tags are picked from lists', passed: true });
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
|
||||
// --- Der Werkzeugkasten kennt den Zusammenhang ---------------------------------------------
|
||||
@@ -940,6 +975,48 @@ try {
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
checks.push({ name: 'plugin switched on, block inserted from the toolbox, schema panel, assets on the public page, switched off keeps the block', passed: true });
|
||||
|
||||
// --- Der Baustein „Eigenes HTML“ (ADR 106) -------------------------------------------------
|
||||
// Markup des Betreibers, unveraendert bis auf die oeffentliche Seite. Geprueft wird der Weg,
|
||||
// den ein Administrator geht: einfuegen, eintippen, Vorschau, veroeffentlichen - und dass
|
||||
// <script> und <iframe> mit einer Meldung abgewiesen werden statt still zu verschwinden.
|
||||
await page.goto(origin + '/admin/pages/test-home');
|
||||
await treeSelect('headline');
|
||||
await addFromLibrary('[data-add="html"]');
|
||||
await page.locator('#properties-title').filter({ hasText: 'Eigenes HTML' }).waitFor();
|
||||
await page.getByLabel('HTML', { exact: true }).fill('<table class="preise"><tbody><tr><td>Miete</td><td>ab 8 €/m²</td></tr></tbody></table>');
|
||||
// Erst der Fokuswechsel schickt den Wert ins Dokument; die Vorschau folgt kurz darauf.
|
||||
await page.locator('#preview-status').click();
|
||||
await page.waitForFunction(() => Boolean(document.querySelector('#preview').contentDocument.querySelector('.block-html table.preise')), null, { timeout: 20000 });
|
||||
await page.locator('#save').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
{
|
||||
const html = await (await page.request.get(origin + '/')).text();
|
||||
assert.match(html, /class="block-html"><table class="preise">/, 'Eigenes HTML unverändert auf der Website');
|
||||
// Der Baustein selbst bringt kein Skript auf die Seite - die Tabelle steht da, wie sie
|
||||
// eingegeben wurde, und sonst nichts. (Dass eine Seite ohne Skript weiterhin
|
||||
// script-src 'none' sendet, prüft CmsTest; hier trägt das Plugin von vorhin schon eines bei.)
|
||||
assert.equal(html.includes('<table class="preise"><tbody><tr><td>Miete</td>'), true, 'Markup Zeichen für Zeichen');
|
||||
}
|
||||
for (const [markup, meldung] of [
|
||||
['<script>alert(1)</script>', /Eigenes HTML führt keine Skripte aus/],
|
||||
['<iframe src="https://example.com"></iframe>', /Externer Inhalt/],
|
||||
]) {
|
||||
await page.getByLabel('HTML', { exact: true }).fill(markup);
|
||||
await page.locator('#preview-status').click();
|
||||
await page.locator('#save').click();
|
||||
// Abgewiesen wird schon die Vorschau, und das Speichern meldet es in der Leiste über dem
|
||||
// Editor - die Seite bleibt, wie sie veröffentlicht ist.
|
||||
await page.locator('#message').filter({ hasText: meldung }).waitFor();
|
||||
}
|
||||
await page.getByLabel('HTML', { exact: true }).fill('<p class="notiz">Wieder gut.</p>');
|
||||
await page.locator('#preview-status').click();
|
||||
await page.locator('#save').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/own-html.png', fullPage: true });
|
||||
checks.push({ name: 'own HTML block: inserted, previewed, published unchanged, script and iframe refused', passed: true });
|
||||
|
||||
// Every management screen, not just the page list: they carry tables, disclosures and now the
|
||||
// media grid, and none of that was ever checked automatically before.
|
||||
for (const [name, path] of [
|
||||
@@ -972,7 +1049,10 @@ try {
|
||||
// looked at from a visitor's browser: the banner has to appear, and the embed must not load.
|
||||
await page.goto(origin + '/admin/settings');
|
||||
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check();
|
||||
await page.getByLabel('Adresse der Datenschutzseite', { exact: true }).fill('grundlagen');
|
||||
// The privacy page is chosen by search, the way the 404 page is: type, then take the hit.
|
||||
await page.getByLabel('Datenschutzseite', { exact: true }).fill('grundlagen');
|
||||
await page.locator('#privacyPath-results [role=option]').filter({ hasText: '/grundlagen' }).first().click();
|
||||
assert.equal(await page.locator('.content-picker:has(#privacyPath) .picker-value').inputValue(), 'grundlagen');
|
||||
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
|
||||
@@ -1047,6 +1127,118 @@ try {
|
||||
await axe('mobile media library');
|
||||
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
|
||||
// Das Vorschaubild einer Seite: aus der Mediathek gewählt, nicht als Kennung abgetippt.
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(origin + '/admin');
|
||||
const sheet = page.locator('details.sheet').first();
|
||||
await sheet.locator('summary').click();
|
||||
const ogField = sheet.locator('[data-media-field]').filter({ has: page.locator('input[name="ogMediaId"]') });
|
||||
await ogField.getByRole('button', { name: 'Bild wählen oder hochladen', exact: true }).click();
|
||||
await page.locator('#media-dialog [data-media-id]').first().click();
|
||||
await page.locator('#media-insert').click();
|
||||
await page.locator('#media-dialog').waitFor({ state: 'detached' });
|
||||
const chosenOg = await ogField.locator('input[name="ogMediaId"]').inputValue();
|
||||
assert.match(chosenOg, /^[a-f0-9]{32}$/);
|
||||
assert.equal(await ogField.locator('.media-field-thumb').isVisible(), true, 'the chosen image is shown');
|
||||
await axe('content settings with an image field');
|
||||
await ogField.locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await page.waitForLoadState('load');
|
||||
const savedOg = page.locator('input[name="ogMediaId"]').first();
|
||||
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');
|
||||
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');
|
||||
const karte = index => neu.locator('[data-field-list] [data-field]').nth(index);
|
||||
await karte(0).locator('[data-field-label]').fill('E-Mail');
|
||||
await karte(0).locator('[data-field-type]').selectOption('email');
|
||||
await neu.locator('[data-add-field]').click();
|
||||
await karte(1).locator('[data-field-label]').fill('Art');
|
||||
await karte(1).locator('[data-field-type]').selectOption('radio');
|
||||
await karte(1).locator('[data-field-options-input]').fill('Wohnung, Haus');
|
||||
await neu.locator('[data-add-field]').click();
|
||||
await karte(2).locator('[data-field-label]').fill('Etage');
|
||||
await karte(2).getByLabel('Nur zeigen, wenn', { exact: true }).selectOption({ label: 'Art' });
|
||||
await karte(2).getByLabel('Wohnung', { exact: true }).check();
|
||||
assert.equal(await karte(2).locator('[data-field-showif]').inputValue(), 'art');
|
||||
assert.equal(await karte(2).locator('[data-field-showvalues]').inputValue(), 'Wohnung');
|
||||
await neu.getByLabel('Antwortadresse aus Feld', { exact: true }).selectOption({ label: 'E-Mail' });
|
||||
assert.equal(await neu.locator('input[name="replyToField"]').inputValue(), 'e_mail');
|
||||
await karte(1).getByRole('button', { name: 'Bild für „Wohnung“ wählen', exact: true }).click();
|
||||
await page.locator('#media-dialog [data-media-id]').first().click();
|
||||
await page.locator('#media-insert').click();
|
||||
await page.locator('#media-dialog').waitFor({ state: 'detached' });
|
||||
assert.match(await karte(1).locator('[data-field-images-input]').inputValue(), /^Wohnung = [a-f0-9]{32}$/);
|
||||
assert.equal(await karte(1).locator('.field-image-picker-thumb').first().isVisible(), true);
|
||||
await axe('form editor with pickers');
|
||||
await neu.getByRole('button', { name: 'Formular anlegen', exact: true }).click();
|
||||
await page.waitForLoadState('load');
|
||||
const gespeichert = page.locator('form[action$="/forms/anfrage-pruefung"]');
|
||||
await gespeichert.waitFor({ state: 'attached' });
|
||||
assert.equal(await gespeichert.locator('input[name="replyToField"]').inputValue(), 'e_mail');
|
||||
assert.equal(await gespeichert.locator('[data-field-showif]').nth(2).inputValue(), 'art');
|
||||
checks.push({ name: 'form editor: condition, reply address and option images are picked', passed: true });
|
||||
// Menü: der eigene Link bekommt eine Seite per Suche, ein Seitenlink sein Sprungziel per Auswahl.
|
||||
await page.goto(origin + '/admin/menus');
|
||||
await page.locator('.menu-add details summary').click();
|
||||
await page.locator('#link-url').locator('xpath=..').getByRole('button', { name: 'Seite wählen', exact: true }).click();
|
||||
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();
|
||||
const neuerEintrag = page.locator('#menu-items .menu-entry').last();
|
||||
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');
|
||||
// Die ganze Seite: seit der Ziehknopf neben der Zusammenfassung steht, gibt es keinen Knopf
|
||||
// mehr in einem Knopf.
|
||||
await neuerEintrag.locator('summary').click();
|
||||
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');
|
||||
await page.locator('summary').filter({ hasText: 'Neuen Beitrag anlegen' }).click();
|
||||
await page.locator('#new-title').fill('Prüfbeitrag');
|
||||
await page.locator('form.create-form button[type="submit"]').click();
|
||||
await page.waitForLoadState('load');
|
||||
await page.goto(origin + '/admin/posts');
|
||||
let beitrag = page.locator('details.sheet').first();
|
||||
await beitrag.locator('summary').click();
|
||||
await beitrag.locator('input[name="categories"]').fill('Projekte');
|
||||
await beitrag.locator('input[name="categories"]').locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await page.waitForLoadState('load');
|
||||
await page.goto(origin + '/admin/posts');
|
||||
beitrag = page.locator('details.sheet').first();
|
||||
await beitrag.locator('summary').click();
|
||||
const kategorie = beitrag.locator('input[name="categories"]');
|
||||
await kategorie.fill('');
|
||||
const vorschlag = beitrag.locator('.term-chip', { hasText: 'Projekte' }).first();
|
||||
await vorschlag.click();
|
||||
assert.equal(await kategorie.inputValue(), 'Projekte');
|
||||
assert.equal(await vorschlag.getAttribute('aria-pressed'), 'true');
|
||||
await vorschlag.click();
|
||||
assert.equal(await kategorie.inputValue(), '');
|
||||
await axe('post settings with term suggestions');
|
||||
checks.push({ name: 'post settings suggest existing categories', passed: true });
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile('reports/editor/result.json', JSON.stringify({ checks, publicDataUntouched: true }, null, 2));
|
||||
console.log('Editor end-to-end checks passed; isolated database, production data unchanged.');
|
||||
|
||||
@@ -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,13 @@
|
||||
#!/bin/sh
|
||||
# Wirft die EB-Solar-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/ebsolar.db"
|
||||
export MEDIA_DIRECTORY="$root/var/ebsolar-media"
|
||||
rm -f "$root/var/ebsolar.db"
|
||||
rm -rf "$root/var/ebsolar-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/ebsolar-assets}"
|
||||
# Die Vorlage nutzt Arial; es gibt keine Schrift zu holen.
|
||||
"$php" -d memory_limit=512M scripts/seed-ebsolar.php "$assets"
|
||||
@@ -0,0 +1,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,17 @@
|
||||
#!/bin/sh
|
||||
# Wirft die KANTARCI-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/kantarci-immobilien.db"
|
||||
export MEDIA_DIRECTORY="$root/var/kantarci-immobilien-media"
|
||||
rm -f "$root/var/kantarci-immobilien.db"
|
||||
rm -rf "$root/var/kantarci-immobilien-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/kantarci-immobilien-assets}"
|
||||
# Logo, Hausmotiv und Symbol sind Zuschnitte aus dem gelieferten Banner, die Bildkarten
|
||||
# der Mietanfrage zeichnet Chromium (Playwright).
|
||||
"$php" scripts/build-kantarci-assets.php "$assets/kantarci-banner.jpeg" "$assets"
|
||||
node scripts/build-kantarci-options.mjs "$assets/options"
|
||||
# Die Vorlage nutzt Arial und Georgia; es gibt keine Schrift zu holen.
|
||||
"$php" -d memory_limit=512M scripts/seed-kantarci-immobilien.php "$assets"
|
||||
@@ -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
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -283,7 +283,7 @@ $theme->save(
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 700], 'fallback' => 'sans-serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'backgroundColor' => '@papier', 'linkColor' => '@anthrazit', 'containerWidth' => '1320px', 'headingScale' => 1.25, 'linkUnderline' => false],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'backgroundColor' => '@papier', 'linkColor' => '@anthrazit', 'containerWidth' => '1320px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
|
||||
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 14, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau-tief', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut die Landingpage „KANTARCI Immobilien – Mietanfrage“ als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist das gelieferte Paket „KANTARCI-Immobilien-Landingpage.zip“ (18.09.2026): eine
|
||||
* Next.js-Seite (app/page.tsx, app/globals.css) mit Kopf, dunklem Hero, Werteleiste, Mietanfrage
|
||||
* und Fuß. Wortlaut, Farben (Navy #102333, Gold #b49a66), Georgia für Überschriften und Arial
|
||||
* für den Text sind daraus übernommen. Gebaut ist alles aus gewöhnlichen Bausteinen – kein
|
||||
* eigener Baustein, kein Plugin, keine externe Schrift.
|
||||
*
|
||||
* Bewusste Abweichungen:
|
||||
* - Die Mietanfrage ist das serverseitige Formular „mietanfrage“ dieses CMS statt der
|
||||
* Cloudflare-D1-Anbindung: Eingänge landen in der Verwaltung und per E-Mail. Auf Umuts Wunsch
|
||||
* in drei Schritten mit Bildkarten (Haushalt, Zimmer, Ausstattung) und Zusammenfassung statt
|
||||
* einer langen Seite. Der Einzug ist eine Auswahl statt eines Datumsfelds (der Baukasten
|
||||
* kennt kein Datum).
|
||||
* - E-Mail überall info@kantarci-immo.de: die Domain der Vorlage gibt es nicht.
|
||||
* - Neu sind Impressum und Datenschutz, beide Pflicht: Die Seite ist ein geschäftlicher
|
||||
* Auftritt (§ 5 DDG) und das Formular verarbeitet personenbezogene Daten (Art. 13 DSGVO).
|
||||
* Anschrift, Geschäftsführer und E-Mail hat Umut am 18.09.2026 genannt; was offen ist
|
||||
* (Register, Telefon, § 34c), steht als „NOCH ZU PRÜFEN“ im Text.
|
||||
* - Logo und Hausmotiv sind Zuschnitte aus dem Banner (scripts/build-kantarci-assets.php).
|
||||
* - Die Symbole der Werteleiste kommen aus dem Symbolsatz des Kerns (kein Blatt, kein Herz).
|
||||
*
|
||||
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
|
||||
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-kantarci-immobilien.sh).
|
||||
* Erstes Argument: der Ordner mit logo-kantarci.png, haus.jpg und symbol-kantarci.png.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$assets = $argv[1] ?? null;
|
||||
if (null === $assets || !is_dir($assets)) {
|
||||
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
|
||||
exit(1);
|
||||
}
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
|
||||
if (!str_contains($url, 'kantarci-immobilien')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die KANTARCI-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
|
||||
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$media = new MediaLibrary($db, $mediaDirectory);
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$partials = new PartialRepository($db, new DocumentValidator());
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
$forms = new FormRepository($db);
|
||||
$theme = new Theme($db);
|
||||
|
||||
$firma = 'KANTARCI Immobilien GmbH';
|
||||
// Die Vorlage nennt immo@kantarci-immobilien.de; die Domain gibt es nicht (18.09.2026, kein
|
||||
// DNS-Eintrag). Umut hat info@kantarci-immo.de für Impressum und Datenschutz genannt, die Domain
|
||||
// hat Mailserver - deshalb steht sie überall, auch als Empfänger der Mietanfrage.
|
||||
$mail = 'info@kantarci-immo.de';
|
||||
$gf = 'Ridvan Kantarci';
|
||||
$strasse = 'Ooser Hauptstraße 51';
|
||||
$ort = '76532 Baden-Baden';
|
||||
$SCHEMA = 24;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
|
||||
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null): array {
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$row = static function (string $id, array $children, array $design = [], array $style = []): array {
|
||||
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
|
||||
if ([] !== $design) {
|
||||
$block['layout'] = ['design' => $design];
|
||||
}
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Eine Zeile, die nebeneinander stellt statt zu rastern – Kleinzeile, Werteleiste, Kopf, Fuß. */
|
||||
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = []) use ($row): array {
|
||||
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, 'margin' => ['top' => '0px'], ...$extra]];
|
||||
if ([] !== $tablet) {
|
||||
$style['tablet'] = $tablet;
|
||||
}
|
||||
if ([] !== $mobil) {
|
||||
$style['mobile'] = $mobil;
|
||||
}
|
||||
|
||||
return $row($id, $children, [], $style);
|
||||
};
|
||||
|
||||
$column = static function (string $id, array $children, array $style = [], string $sichtbarkeit = 'all', string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('all' !== $sichtbarkeit) {
|
||||
$block['layout'] = ['visibility' => $sichtbarkeit];
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Eine reine Fläche: der kurze Goldstrich vor der Kleinzeile, der Verlauf über dem Hausbild. */
|
||||
$flaeche = static function (string $id, array $style, string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'spacer', 'props' => ['size' => 'small'], 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Ein Button ohne den Pfeil des Bausteins: die Vorlage kommt ohne Pfeile aus. */
|
||||
$button = static function (string $id, string $text, string $href, array $style = []): array {
|
||||
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style, 'css' => '& span{display:none}'];
|
||||
};
|
||||
|
||||
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array {
|
||||
$gross = end($eintrag['variants']);
|
||||
$props = [
|
||||
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
|
||||
'mediaId' => $eintrag['id'],
|
||||
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
|
||||
];
|
||||
if (null !== $href) {
|
||||
$props['href'] = $href;
|
||||
$props['click'] = 'link';
|
||||
}
|
||||
|
||||
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$symbol = static function (string $id, string $name, int $groesse, array $style = []): array {
|
||||
return ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt die Einblendungen nach einer Liste, statt sie über das ganze Dokument zu streuen.
|
||||
*
|
||||
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
|
||||
*/
|
||||
$einblenden = static function (array $dokument, array $plan): array {
|
||||
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
|
||||
foreach ($bloecke as $i => $block) {
|
||||
if (isset($plan[$block['id'] ?? ''])) {
|
||||
[$art, $verzug] = [...$plan[$block['id']], 'none'];
|
||||
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$bloecke[$i]['children'] = $gehen($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $bloecke;
|
||||
};
|
||||
$dokument['blocks'] = $gehen($dokument['blocks']);
|
||||
|
||||
return $dokument;
|
||||
};
|
||||
|
||||
// Wiederkehrende Gestaltungswerte der Vorlage.
|
||||
$kleinStil = ['desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.17em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '20px']], 'mobile' => ['fontSize' => '11px']];
|
||||
$linkCss = "& a{color:#102333;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#88703f}";
|
||||
// Die Mietanfrage in drei Schritten: volle Breite statt der 640 px des Kerns, zwei Spalten dort,
|
||||
// wo zwei kurze Felder nebeneinander passen. Eigenes CSS kennt kein @media: die Raster füllen
|
||||
// deshalb mit auto-fill, am Telefon bleibt so eine Spalte. Felder über `data-label`, weil es kein
|
||||
// :nth-child kennt; Deckkraft der Nebentexte auf 1, sonst fallen sie unter 4,5:1.
|
||||
$formularCss = '& .block-form{max-width:none}
|
||||
& .form-step{grid-template-columns:repeat(auto-fill,minmax(190px,1fr));column-gap:16px;row-gap:18px}
|
||||
& .form-step>.form-field{grid-column:1/-1}
|
||||
& .form-step>.form-step-title{grid-column:1/-1}
|
||||
& .form-step>.form-step-intro{grid-column:1/-1}
|
||||
& .form-step>[data-label="Vorname"]{grid-column:span 1}
|
||||
& .form-step>[data-label="Nachname"]{grid-column:span 1}
|
||||
& .form-step>[data-label="Einzugstermin"]{grid-column:span 1}
|
||||
& .form-step>[data-label="Max. Warmmiete pro Monat"]{grid-column:span 1}
|
||||
& .form-step-title{font-family:Georgia,serif;font-size:22px;font-weight:400;color:#142535;margin-bottom:4px}
|
||||
& .form-step-intro{font-size:14px;color:#5f6b74;opacity:1}
|
||||
& .form-progress-step{opacity:1;color:#5f6b74}
|
||||
& .form-progress-step[aria-current]{color:#142535}
|
||||
& .form-field{align-content:start}
|
||||
& .form-field label{font-size:14px;font-weight:600;color:#142535}
|
||||
& .form-field input{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535}
|
||||
& .form-field select{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535}
|
||||
& .form-field textarea{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535;min-height:110px}
|
||||
& .form-choice legend{font-size:14px;font-weight:600;color:#142535}
|
||||
& .form-choice-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
||||
& [data-label="Ausstattung"] .form-choice-grid{grid-template-columns:repeat(auto-fill,minmax(125px,1fr));gap:10px}
|
||||
& .form-step>.form-summary-body{grid-column:1/-1}
|
||||
& .form-summary-title{font-family:Arial,sans-serif;font-size:15px;font-weight:700;letter-spacing:0}
|
||||
& .form-summary-step{border-color:#dce0e2;border-radius:6px}
|
||||
& .form-choice-card label{border-color:#dce0e2;border-radius:6px;font-size:14px}
|
||||
& .field-note{font-size:13px;color:#5f6b74;opacity:1}
|
||||
& .required-mark{color:#88703f}
|
||||
& .check-row label{font-weight:400;font-size:14px;line-height:1.65;color:#576570}
|
||||
& .cms-button{justify-content:center;background:#142535;color:#fff;border-radius:3px}
|
||||
& .cms-button:hover{background:#234256;color:#fff}';
|
||||
|
||||
/** Die Kleinzeile in Versalien, wahlweise mit dem kurzen Goldstrich davor. */
|
||||
$kleinzeile = static function (string $id, string $inhalt, string $farbe = '@gold-text', bool $strich = false) use ($text, $zeile, $flaeche, $kleinStil): array {
|
||||
$zeilenText = $text($id.'-text', $inhalt, ['desktop' => [...$kleinStil['desktop'], 'color' => $farbe, 'margin' => ['bottom' => '0px']], 'mobile' => $kleinStil['mobile']]);
|
||||
if (!$strich) {
|
||||
return [...$zeilenText, 'id' => $id, 'style' => ['desktop' => [...$kleinStil['desktop'], 'color' => $farbe], 'mobile' => $kleinStil['mobile']]];
|
||||
}
|
||||
|
||||
return $zeile($id, [
|
||||
$flaeche($id.'-strich', ['desktop' => ['width' => '27px', 'height' => '1px', 'backgroundColor' => '@gold', 'flexShrink' => 0]], 'min-height:0'),
|
||||
$zeilenText,
|
||||
], '12px', ['margin' => ['top' => '0px', 'bottom' => '20px']]);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien, Formular
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'KANTARCI Immobilien', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'navy', 'name' => 'Navy', 'value' => '#102333'],
|
||||
['key' => 'tinte', 'name' => 'Schrift', 'value' => '#142535'],
|
||||
['key' => 'gold', 'name' => 'Gold (Linien, Flächen)', 'value' => '#b49a66'],
|
||||
['key' => 'gold-knopf', 'name' => 'Gold als Knopf', 'value' => '#bda16a'],
|
||||
['key' => 'gold-hell', 'name' => 'Gold auf Navy', 'value' => '#cbb17b'],
|
||||
['key' => 'gold-text', 'name' => 'Gold als Schrift auf Weiß', 'value' => '#88703f'],
|
||||
['key' => 'text', 'name' => 'Fließtext', 'value' => '#5f6b74'],
|
||||
['key' => 'text-navy', 'name' => 'Fließtext auf Navy', 'value' => '#d1d8de'],
|
||||
['key' => 'hell', 'name' => 'Helle Fläche', 'value' => '#f1f3f3'],
|
||||
['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#e1e4e5'],
|
||||
['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
// Die Vorlage lädt keine Webschrift: Arial für Text, Georgia für Überschriften.
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Georgia', 'source' => 'system', 'weights' => [400], 'fallback' => 'serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@tinte', 'containerWidth' => '1140px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
|
||||
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@gold-knopf', 'color' => '@navy', 'hoverBackgroundColor' => '#d0b582', 'hoverColor' => '@navy'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo-kantarci.png', 'KANTARCI Immobilien GmbH'],
|
||||
'haus' => ['haus.jpg', 'Modernes Wohnhaus mit begrünten Balkonen'],
|
||||
'symbol' => ['symbol-kantarci.png', 'Bildmarke von KANTARCI Immobilien'],
|
||||
// Die Bildkarten der Mietanfrage (scripts/build-kantarci-options.mjs).
|
||||
'haushalt-allein' => ['options/haushalt-allein.png', 'Symbol: eine Person'],
|
||||
'haushalt-zu-zweit' => ['options/haushalt-zu-zweit.png', 'Symbol: zwei Personen'],
|
||||
'haushalt-familie' => ['options/haushalt-familie.png', 'Symbol: zwei Erwachsene und ein Kind'],
|
||||
'haushalt-wg' => ['options/haushalt-wg.png', 'Symbol: drei Personen'],
|
||||
'zimmer-1' => ['options/zimmer-1.png', 'Grundriss mit einem Zimmer'],
|
||||
'zimmer-2' => ['options/zimmer-2.png', 'Grundriss mit zwei Zimmern'],
|
||||
'zimmer-3' => ['options/zimmer-3.png', 'Grundriss mit drei Zimmern'],
|
||||
'zimmer-4' => ['options/zimmer-4.png', 'Grundriss mit vier Zimmern'],
|
||||
'ausstattung-balkon' => ['options/ausstattung-balkon.png', 'Symbol: Balkon'],
|
||||
'ausstattung-aufzug' => ['options/ausstattung-aufzug.png', 'Symbol: Aufzug'],
|
||||
'ausstattung-stellplatz' => ['options/ausstattung-stellplatz.png', 'Symbol: Parkplatz'],
|
||||
'ausstattung-kueche' => ['options/ausstattung-kueche.png', 'Symbol: Küche'],
|
||||
'ausstattung-barrierefrei' => ['options/ausstattung-barrierefrei.png', 'Symbol: Rollstuhl'],
|
||||
'ausstattung-haustier' => ['options/ausstattung-haustier.png', 'Symbol: Pfote'],
|
||||
] as $schluessel => [$datei, $alt]) {
|
||||
$pfad = rtrim($assets, '/\\').'/'.$datei;
|
||||
if (!is_file($pfad)) {
|
||||
fwrite(STDERR, "Bild fehlt: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$eintrag = $media->upload(new UploadedFile($pfad, basename($datei), null, null, true));
|
||||
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
|
||||
$medien[$schluessel] = $media->find($eintrag['id']);
|
||||
echo "Bild übernommen: $datei\n";
|
||||
}
|
||||
|
||||
// Drei Schritte: wer einzieht, wie gewohnt werden soll, wie wir Sie erreichen. Haushalt, Zimmer
|
||||
// und Ausstattung sind Bildkarten; die Personenzahl fragt das Formular nur, wo sie offen ist.
|
||||
$karten = static fn (array $zuordnung): array => array_map(static fn (string $schluessel): string => $medien[$schluessel]['id'], $zuordnung);
|
||||
$forms->save('mietanfrage', 'Mietanfrage', [
|
||||
['name' => 'schritt_haushalt', 'type' => 'step', 'label' => 'Ihr Haushalt', 'help' => 'Wer zieht in die neue Wohnung ein?'],
|
||||
['name' => 'haushalt', 'type' => 'radio', 'label' => 'Wer zieht ein?', 'required' => true, 'options' => ['Allein', 'Zu zweit', 'Familie mit Kindern', 'Wohngemeinschaft'],
|
||||
'images' => $karten(['Allein' => 'haushalt-allein', 'Zu zweit' => 'haushalt-zu-zweit', 'Familie mit Kindern' => 'haushalt-familie', 'Wohngemeinschaft' => 'haushalt-wg'])],
|
||||
['name' => 'personen', 'type' => 'number', 'label' => 'Personen im Haushalt', 'required' => true, 'min' => 2, 'max' => 30,
|
||||
'showIf' => ['field' => 'haushalt', 'values' => ['Familie mit Kindern', 'Wohngemeinschaft']]],
|
||||
|
||||
['name' => 'schritt_wohnen', 'type' => 'step', 'label' => 'Ihre Wohnwünsche', 'help' => 'Alles hier ist freiwillig – je mehr wir wissen, desto besser passt unser Vorschlag.'],
|
||||
['name' => 'zimmer', 'type' => 'radio', 'label' => 'Wie viele Zimmer?', 'required' => false, 'options' => ['1 Zimmer', '2 Zimmer', '3 Zimmer', '4 und mehr'],
|
||||
'images' => $karten(['1 Zimmer' => 'zimmer-1', '2 Zimmer' => 'zimmer-2', '3 Zimmer' => 'zimmer-3', '4 und mehr' => 'zimmer-4'])],
|
||||
['name' => 'ausstattung', 'type' => 'checkboxes', 'label' => 'Ausstattung', 'required' => false, 'help' => 'Was ist Ihnen wichtig? Mehrfachauswahl möglich.',
|
||||
'options' => ['Balkon oder Terrasse', 'Aufzug', 'Stellplatz', 'Einbauküche', 'Barrierefrei', 'Haustier erlaubt'],
|
||||
'images' => $karten(['Balkon oder Terrasse' => 'ausstattung-balkon', 'Aufzug' => 'ausstattung-aufzug', 'Stellplatz' => 'ausstattung-stellplatz', 'Einbauküche' => 'ausstattung-kueche', 'Barrierefrei' => 'ausstattung-barrierefrei', 'Haustier erlaubt' => 'ausstattung-haustier'])],
|
||||
['name' => 'einzug', 'type' => 'select', 'label' => 'Einzugstermin', 'required' => false, 'options' => ['Ab sofort', 'In 1 bis 3 Monaten', 'In 3 bis 6 Monaten', 'Später oder flexibel']],
|
||||
['name' => 'warmmiete', 'type' => 'number', 'label' => 'Max. Warmmiete pro Monat', 'required' => false, 'min' => 1, 'max' => 100000, 'unit' => '€'],
|
||||
['name' => 'objekt', 'type' => 'text', 'label' => 'Gewünschte Wohnung / Objektadresse', 'required' => false, 'help' => 'Noch kein bestimmtes Objekt? Lassen Sie das Feld einfach frei.'],
|
||||
|
||||
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Damit wir uns persönlich bei Ihnen melden können.'],
|
||||
['name' => 'vorname', 'type' => 'text', 'label' => 'Vorname', 'required' => true],
|
||||
['name' => 'nachname', 'type' => 'text', 'label' => 'Nachname', 'required' => true],
|
||||
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true],
|
||||
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefonnummer', 'required' => false, 'help' => 'Für eine persönliche Rückmeldung'],
|
||||
['name' => 'anschrift', 'type' => 'text', 'label' => 'Aktuelle Anschrift', 'required' => false, 'help' => 'Straße, Hausnummer, PLZ und Ort'],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => false, 'help' => 'Was ist Ihnen beim Wohnen sonst noch wichtig?'],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich bin einverstanden, dass KANTARCI meine Angaben zur Bearbeitung meiner Mietanfrage speichert und mich kontaktiert.'],
|
||||
], ['recipient' => $mail, 'subject' => 'Neue Mietanfrage über die Landingpage', 'success' => 'Vielen Dank für Ihr Interesse. Ihre Mietanfrage ist angekommen – wir melden uns persönlich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Mietanfrage absenden', 'summary' => true], 0);
|
||||
echo "Formular angelegt: mietanfrage\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopf- und Fußbereich
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf', [
|
||||
$zeile('kopf-zeile', [
|
||||
$column('kopf-marke', [
|
||||
$bild('kopf-logo', $medien['logo'], 'KANTARCI Immobilien GmbH – zur Startseite', [
|
||||
'desktop' => ['maxWidth' => '300px', 'margin' => $seiten('0px')],
|
||||
'mobile' => ['maxWidth' => '220px'],
|
||||
], '/'),
|
||||
], ['desktop' => ['flexShrink' => 1, 'minWidth' => '0']]),
|
||||
$column('kopf-kontakt', [
|
||||
$zeile('kopf-kontakt-zeile', [
|
||||
$symbol('kopf-kontakt-symbol', 'mail', 20, ['desktop' => ['color' => '@navy', 'flexShrink' => 0]]),
|
||||
$text('kopf-kontakt-link', '<a href="mailto:'.$mail.'">Kontakt aufnehmen</a>', ['desktop' => ['fontSize' => '14px', 'fontWeight' => '600', 'margin' => ['bottom' => '0px']]], "& a{color:#102333}\n& a:hover{color:#88703f}"),
|
||||
], '10px'),
|
||||
], ['desktop' => ['flexShrink' => 0]], 'hide-mobile'),
|
||||
], '24px', ['justifyContent' => 'space-between']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'padding' => ['top' => '22px', 'bottom' => '22px']],
|
||||
'mobile' => ['padding' => ['top' => '16px', 'bottom' => '16px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$zeile('fuss-oben', [
|
||||
$column('fuss-marke', [
|
||||
$text('fuss-name', 'KANTARCI', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'letterSpacing' => '0.14em', 'lineHeight' => 1.3, 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('fuss-zusatz', 'IMMOBILIEN GMBH', ['desktop' => ['fontSize' => '10px', 'letterSpacing' => '0.22em', 'color' => '@gold-hell', 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
$column('fuss-spruch', [
|
||||
$text('fuss-spruch-text', 'Lebensraum schaffen. Zukunft bauen.', ['desktop' => ['fontSize' => '13px', 'color' => '#bac4cd', 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
$column('fuss-mail', [
|
||||
$text('fuss-mail-text', '<a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '13px', 'margin' => ['bottom' => '0px']]], "& a{color:#bac4cd;overflow-wrap:anywhere}\n& a:hover{color:#fff;text-decoration:underline}"),
|
||||
]),
|
||||
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'padding' => ['top' => '34px', 'bottom' => '26px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#243a4c'],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']),
|
||||
$zeile('fuss-unten', [
|
||||
$text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '12px', 'color' => '#bac4cd', 'margin' => ['bottom' => '0px']]]),
|
||||
$zeile('fuss-links', [
|
||||
// Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü.
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
|
||||
'style' => ['desktop' => ['fontSize' => '12px', 'gap' => '4px', 'color' => '#bac4cd']],
|
||||
'css' => "& a{color:#bac4cd}\n& a:hover{color:#fff}"],
|
||||
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '12px', 'margin' => ['bottom' => '0px']]], "& a{color:#bac4cd}\n& a:hover{color:#fff}"),
|
||||
], '22px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
|
||||
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'padding' => ['top' => '20px', 'bottom' => '26px']],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@navy', 'color' => '#bac4cd', 'padding' => ['top' => '0px', 'bottom' => '0px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Navy, dann Absätze mit Zwischenüberschriften. */
|
||||
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array {
|
||||
$kopf = $section($praefix.'-kopf', [
|
||||
$kleinzeile($praefix.'-label', $label, '@gold-hell', true),
|
||||
$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => ['fontSize' => '52px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '20px']],
|
||||
'mobile' => ['fontSize' => '38px'],
|
||||
]),
|
||||
$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text-navy', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '72px', 'bottom' => '64px']],
|
||||
'mobile' => ['padding' => ['top' => '44px', 'bottom' => '40px']],
|
||||
]);
|
||||
|
||||
$punkte = [];
|
||||
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
|
||||
$punkte[] = $row($praefix.'-punkt-'.$i, [
|
||||
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
|
||||
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
|
||||
'desktop' => ['fontFamily' => 'body', 'fontSize' => '16px', 'fontWeight' => '700', 'lineHeight' => 1.5, 'letterSpacing' => '0', 'color' => '@tinte', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['flexBasis' => '260px', 'flexShrink' => 0],
|
||||
'tablet' => ['flexBasis' => '200px'],
|
||||
'mobile' => ['flexBasis' => 'auto'],
|
||||
]),
|
||||
$column($praefix.'-punkt-'.$i.'-text', [
|
||||
$text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexBasis' => 'auto']]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '44px', 'margin' => ['top' => '0px'],
|
||||
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px', 'bottom' => '26px']],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '20px', 'bottom' => '20px']],
|
||||
]);
|
||||
}
|
||||
|
||||
$inhalt = $section($praefix, [
|
||||
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '920px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '48px', 'bottom' => '90px']], 'mobile' => ['padding' => ['top' => '24px', 'bottom' => '56px']]]);
|
||||
|
||||
return [$kopf, $inhalt];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$wertStil = ['desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '12px', 'textAlign' => 'center', 'lineHeight' => 1.5]];
|
||||
$wert = static fn (string $id, string $icon, string $label): array => $zeile('wert-'.$id, [
|
||||
$symbol('wert-'.$id.'-symbol', $icon, 24, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]),
|
||||
$text('wert-'.$id.'-text', $label, $wertStil),
|
||||
], '13px', [], ['flexDirection' => 'column', 'gap' => '9px', 'flexGrow' => 1, 'flexBasis' => '0']);
|
||||
|
||||
$schritt = static fn (string $n, string $titel, string $inhalt): array => $zeile('schritt-'.$n, [
|
||||
$text('schritt-'.$n.'-nummer', $n, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'lineHeight' => 1.2, 'color' => '@gold-text', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#c9b58e', 'padding' => ['top' => '8px'], 'margin' => ['bottom' => '0px'], 'flexShrink' => 0]]),
|
||||
$text('schritt-'.$n.'-text', '<strong>'.$titel.'</strong>'.$inhalt, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.65, 'color' => '@text', 'maxWidth' => '290px', 'margin' => ['top' => '5px', 'bottom' => '0px']]],
|
||||
'& strong{display:block;color:#102333;font-size:15px;margin-bottom:4px}'),
|
||||
], '17px', ['alignItems' => 'flex-start', 'margin' => ['top' => '22px']]);
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Mietanfrage',
|
||||
'description' => 'Ihr neues Zuhause beginnt hier. Melden Sie Ihr Interesse an einer Mietwohnung bei KANTARCI Immobilien an – persönlich und unverbindlich.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Navy, Text links, das Haus rechts mit Verlauf und Bildzeile.
|
||||
$section('hero', [
|
||||
$row('hero-zeile', [
|
||||
$column('hero-text', [
|
||||
$kleinzeile('hero-label', 'KANTARCI IMMOBILIEN', '#d1bb90', true),
|
||||
$heading('hero-titel', 'Hier beginnt Ihr<br><em>neues Zuhause.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '62px', 'lineHeight' => 1.09, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '25px']],
|
||||
'tablet' => ['fontSize' => '45px'],
|
||||
'mobile' => ['fontSize' => '42px'],
|
||||
], '& em{font-style:normal;color:#cbb17b}'),
|
||||
$text('hero-intro', 'Ein Ort zum Ankommen. Raum für Ihr Leben. Entdecken Sie neue Mietwohnungen und teilen Sie uns mit, wie Sie wohnen möchten.', [
|
||||
'desktop' => ['maxWidth' => '450px', 'fontSize' => '16px', 'lineHeight' => 1.85, 'color' => '@text-navy', 'margin' => ['bottom' => '30px']],
|
||||
'mobile' => ['fontSize' => '15px'],
|
||||
]),
|
||||
$button('hero-knopf', 'Jetzt Mietinteresse anmelden', '#mietanfrage', ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
$text('hero-notiz', 'Unverbindlich. Persönlich. In wenigen Minuten.', ['desktop' => ['fontSize' => '12px', 'color' => '#b1bec7', 'margin' => ['top' => '14px', 'bottom' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'justifyContent' => 'center', 'padding' => ['top' => '64px', 'bottom' => '60px']],
|
||||
'tablet' => ['padding' => ['top' => '44px', 'bottom' => '44px']],
|
||||
'mobile' => ['padding' => ['top' => '40px', 'bottom' => '8px']],
|
||||
]),
|
||||
$column('hero-visual', [
|
||||
$bild('hero-bild', $medien['haus'], 'Modernes Wohnhaus mit begrünten Balkonen', [
|
||||
'desktop' => ['imageHeight' => '480px', 'objectFit' => 'cover', 'objectPosition' => 'top left', 'margin' => $seiten('0px')],
|
||||
'tablet' => ['imageHeight' => '460px'],
|
||||
'mobile' => ['imageHeight' => '280px'],
|
||||
], null, '& img{display:block;width:100%}'),
|
||||
$flaeche('hero-verlauf', ['desktop' => ['position' => 'absolute', 'left' => '0px', 'right' => '0px', 'bottom' => '0px', 'width' => 'auto', 'height' => 'auto', 'zIndex' => 1]], 'top:45%;min-height:0;background:linear-gradient(transparent,rgba(16,35,51,.88));pointer-events:none'),
|
||||
$column('hero-bildzeile', [
|
||||
$text('hero-bildzeile-klein', 'RAUM FÜR MORGEN', ['desktop' => ['fontSize' => '11px', 'letterSpacing' => '0.18em', 'color' => '#f2e4c6', 'margin' => ['bottom' => '4px']]]),
|
||||
$text('hero-bildzeile-satz', 'Wohnen. Wohlfühlen. Ankommen.', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '25px', 'lineHeight' => 1.3, 'color' => '@papier', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '21px']]),
|
||||
], ['desktop' => ['position' => 'absolute', 'left' => '32px', 'right' => '32px', 'bottom' => '28px', 'zIndex' => 2], 'mobile' => ['left' => '20px', 'bottom' => '18px']]),
|
||||
], [
|
||||
'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'backgroundColor' => '#d9dde0', 'minHeight' => '480px'],
|
||||
'tablet' => ['minHeight' => '460px'],
|
||||
'mobile' => ['minHeight' => '280px'],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '56px', 'alignItems' => 'stretch', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
'mobile' => ['gap' => '24px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '0px', 'bottom' => '0px']],
|
||||
'mobile' => ['padding' => ['top' => '0px', 'bottom' => '24px']],
|
||||
], 'start'),
|
||||
|
||||
// 2 – Die Werteleiste aus dem Banner.
|
||||
$section('werte', [
|
||||
$zeile('werte-zeile', [
|
||||
$wert('modern', 'star', 'Modern & nachhaltig'),
|
||||
$wert('komfort', 'home', 'Zeitgemäßer Wohnkomfort'),
|
||||
$wert('leben', 'people', 'Zuhause für jedes Leben'),
|
||||
], '20px', ['justifyContent' => 'space-between', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e4e6e7', 'padding' => ['top' => '26px', 'bottom' => '26px']],
|
||||
['alignItems' => 'flex-start', 'gap' => '14px', 'padding' => ['top' => '22px', 'bottom' => '22px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '0px']]]),
|
||||
|
||||
// 3 – Mietanfrage: Einleitung links, Formularkarte rechts.
|
||||
$section('anfrage', [
|
||||
$row('anfrage-zeile', [
|
||||
$column('anfrage-text', [
|
||||
$kleinzeile('anfrage-label', 'IHRE MIETANFRAGE'),
|
||||
$heading('anfrage-h2', 'Ein paar Angaben.<br>Viele Möglichkeiten.', 2, [
|
||||
'desktop' => ['fontSize' => '38px', 'lineHeight' => 1.2, 'letterSpacing' => '-0.02em', 'margin' => ['bottom' => '22px']],
|
||||
'tablet' => ['fontSize' => '33px'],
|
||||
'mobile' => ['fontSize' => '34px'],
|
||||
]),
|
||||
$text('anfrage-intro', 'Sie interessieren sich für eine bestimmte Wohnung oder suchen noch das passende Zuhause? Erzählen Sie uns kurz von Ihren Wünschen.', [
|
||||
'desktop' => ['maxWidth' => '350px', 'fontSize' => '16px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '12px']],
|
||||
'mobile' => ['maxWidth' => '100%'],
|
||||
]),
|
||||
$schritt('01', 'Anfrage ausfüllen', 'Haushalt, Wohnwünsche und Kontaktdaten – in drei kurzen Schritten.'),
|
||||
$schritt('02', 'Persönlich ins Gespräch kommen', 'Wir melden uns bei Ihnen, um die Möglichkeiten zu besprechen.'),
|
||||
$zeile('anfrage-direkt', [
|
||||
$symbol('anfrage-direkt-symbol', 'mail', 24, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]),
|
||||
$text('anfrage-direkt-text', '<strong>Lieber direkt schreiben?</strong><a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]],
|
||||
"& strong{display:block;color:#102333}\n& a{color:#5f6b74;overflow-wrap:anywhere}\n& a:hover{color:#88703f;text-decoration:underline}"),
|
||||
], '15px', ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px'], 'margin' => ['top' => '38px']], ['margin' => ['top' => '26px'], 'padding' => ['top' => '20px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '14px']], 'mobile' => ['padding' => ['top' => '0px']]]),
|
||||
$column('anfrage-karte', [
|
||||
$zeile('anfrage-karte-kopf', [
|
||||
$column('anfrage-karte-titel', [
|
||||
$heading('anfrage-karte-h3', 'Ihr Weg zum neuen Zuhause', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '20px', 'fontWeight' => '600', 'letterSpacing' => '-0.015em', 'lineHeight' => 1.35, 'margin' => ['bottom' => '5px']]]),
|
||||
$text('anfrage-karte-pflicht', 'In drei kurzen Schritten. Pflichtfelder sind mit * markiert.', ['desktop' => ['fontSize' => '13px', 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
$symbol('anfrage-karte-symbol', 'home', 32, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]),
|
||||
], '16px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px', 'bottom' => '28px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#edf0f0', 'padding' => ['bottom' => '22px']]),
|
||||
['id' => 'anfrage-formular', 'type' => 'form', 'props' => ['form' => 'mietanfrage'], 'css' => $formularCss],
|
||||
$text('anfrage-hinweis', 'Ihre Angaben werden nur für Ihre Mietanfrage verwendet – mehr dazu in den <a href="/datenschutz">Datenschutzhinweisen</a>. Bitte senden Sie hier keine Ausweiskopien oder anderen vertraulichen Unterlagen.', [
|
||||
'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '18px', 'bottom' => '0px']],
|
||||
], $linkCss),
|
||||
$text('anfrage-unverbindlich', '✓ Unverbindliche Anfrage – kein Mietvertragsabschluss.', ['desktop' => ['fontSize' => '12px', 'color' => '@text', 'textAlign' => 'center', 'margin' => ['top' => '12px', 'bottom' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@papier', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e2e5e6', 'borderRadius' => ['topLeft' => '7px', 'topRight' => '7px', 'bottomRight' => '7px', 'bottomLeft' => '7px'], 'padding' => ['top' => '32px', 'right' => '34px', 'bottom' => '32px', 'left' => '34px'], 'shadowY' => '12px', 'shadowBlur' => '40px', 'shadowColor' => '#14253510'],
|
||||
'tablet' => ['padding' => ['top' => '26px', 'right' => '24px', 'bottom' => '26px', 'left' => '24px']],
|
||||
'mobile' => ['padding' => ['top' => '25px', 'right' => '20px', 'bottom' => '25px', 'left' => '20px']],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '65px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px']],
|
||||
'tablet' => ['gap' => '32px'],
|
||||
'mobile' => ['gap' => '26px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['padding' => ['top' => '74px', 'bottom' => '84px']],
|
||||
'tablet' => ['padding' => ['top' => '55px', 'bottom' => '60px']],
|
||||
'mobile' => ['padding' => ['top' => '44px', 'bottom' => '48px']],
|
||||
], 'mietanfrage'),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$erstellt = [];
|
||||
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db, &$erstellt): string {
|
||||
$id = $pages->createPage($slug, null, 'page', $dokument);
|
||||
if ('' !== $seoTitel) {
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
|
||||
}
|
||||
if ('' !== $teilbild) {
|
||||
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
|
||||
}
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
$erstellt[$slug] = $id;
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Der Text des Hero spielt beim Laden, das Hausbild bleibt ohne Einblendung (LCP).
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-label' => ['fade'],
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-intro' => ['rise', 'medium'],
|
||||
'hero-knopf' => ['rise', 'long'],
|
||||
'hero-notiz' => ['fade', 'long'],
|
||||
'anfrage-text' => ['rise'],
|
||||
'anfrage-karte' => ['rise', 'short'],
|
||||
]);
|
||||
|
||||
$anlegen('home', $startseite, 'Mietanfrage | KANTARCI Immobilien GmbH', $medien['haus']['id']);
|
||||
|
||||
$pruefen = static fn (string $was): string => '<strong>NOCH ZU PRÜFEN:</strong> '.$was;
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben der KANTARCI Immobilien GmbH.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('impressum', 'RECHTLICHE ANGABEN', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
|
||||
['Anbieterin', $firma.'<br>'.$strasse.'<br>'.$ort],
|
||||
['Vertreten durch', $gf.', Geschäftsführer'],
|
||||
['Registereintrag', $pruefen('Handelsregisternummer (HRB …) und Registergericht (Amtsgericht …).')],
|
||||
['Kontakt', "E-Mail: <a href=\"mailto:$mail\">$mail</a><br>".$pruefen('Telefonnummer ergänzen – das Gesetz verlangt neben der E-Mail einen zweiten schnellen Kontaktweg.')],
|
||||
['Umsatzsteuer', $pruefen('Umsatzsteuer-Identifikationsnummer nach § 27a UStG, falls vorhanden.')],
|
||||
['Aufsichtsbehörde', $pruefen('Wer Wohnungen für Dritte vermittelt oder verwaltet, braucht eine Erlaubnis nach § 34c GewO; dann die zuständige Erlaubnisbehörde hier eintragen. Vermietet die GmbH nur eigene Wohnungen, entfällt die Angabe.')],
|
||||
['Verantwortlich für den Inhalt', $gf.', Anschrift wie oben.'],
|
||||
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
|
||||
['Haftung für Inhalte', 'Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Wir sind jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Bei Bekanntwerden von Rechtsverletzungen entfernen wir diese Inhalte umgehend.'],
|
||||
['Urheberrecht', 'Die von uns erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung.'],
|
||||
['Bildnachweis', 'Logo und Hausmotiv: '.$firma.'. '.$pruefen('Urheber und Lizenz des Hausfotos eintragen, falls es nicht von KANTARCI selbst stammt.')],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | '.$firma);
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Wie die KANTARCI Immobilien GmbH Daten beim Besuch der Website und bei einer Mietanfrage verarbeitet.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('datenschutz', 'IHRE DATEN', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Mietanfrage verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortliche', "$firma, $strasse, $ort, vertreten durch den Geschäftsführer $gf. E-Mail <a href=\"mailto:$mail\">$mail</a>."],
|
||||
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.<br><br>'.$pruefen('Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen und die Speicherdauer der Serverprotokolle eintragen.')],
|
||||
['3. Cookies und externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
|
||||
['4. Mietanfrage', 'Wenn Sie das Formular absenden, verarbeiten wir Ihre Kontaktdaten (Name, E-Mail, auf Wunsch Telefon und Anschrift) und Ihre Wohnwünsche (etwa Haushaltsgröße, Zimmerzahl, Einzug, Mietobergrenze und Ihre Nachricht), um Ihre Anfrage zu bearbeiten und Ihnen passende Wohnungen anzubieten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Mietvertrags). Pflichtangaben sind nur Name, E-Mail und Haushaltsgröße; ohne sie können wir die Anfrage nicht bearbeiten. Das Formular schützt sich gegen automatisierte Eingaben, ohne dafür Dienste Dritter zu laden.'],
|
||||
['5. Wie lange wir speichern', 'Anfragedaten löschen wir, sobald sie für die Bearbeitung nicht mehr nötig sind – spätestens sechs Monate nach der letzten Kontaktaufnahme, wenn kein Mietverhältnis entsteht. Entsteht ein Mietverhältnis, gelten für die dann anfallenden Unterlagen die gesetzlichen Aufbewahrungsfristen.<br><br>'.$pruefen('Frist mit der Praxis abgleichen.')],
|
||||
['6. Empfänger', 'Ihre Angaben erreichen uns per E-Mail und liegen in der Verwaltung dieser Website. Darüber hinaus geben wir sie nicht weiter, außer wir sind dazu gesetzlich verpflichtet.<br><br>'.$pruefen('E-Mail-Anbieter und Hostingdienst als Auftragsverarbeiter benennen.')],
|
||||
['7. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen. Eine erteilte Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen.'],
|
||||
['8. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
|
||||
['', "Fragen zum Datenschutz beantworten wir gern unter <a href=\"mailto:$mail\">$mail</a>."],
|
||||
]),
|
||||
],
|
||||
], 'Datenschutz | '.$firma);
|
||||
|
||||
$fehlerId = $anlegen('404', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
|
||||
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
|
||||
'blocks' => [
|
||||
$section('fehler', [
|
||||
$kleinzeile('fehler-label', 'FEHLER 404', '@gold-hell', true),
|
||||
$heading('fehler-titel', 'Diese Seite<br><em>gibt es nicht.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '52px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '22px']],
|
||||
'mobile' => ['fontSize' => '38px'],
|
||||
], '& em{font-style:normal;color:#cbb17b}'),
|
||||
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
|
||||
'desktop' => ['maxWidth' => '460px', 'fontSize' => '17px', 'lineHeight' => 1.8, 'color' => '@text-navy', 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '110px', 'bottom' => '130px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Einstellungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => $firma,
|
||||
'description' => 'KANTARCI Immobilien GmbH: neue Mietwohnungen – modern, nachhaltig, mit zeitgemäßem Wohnkomfort. Mietinteresse unverbindlich anmelden.',
|
||||
'email' => $mail,
|
||||
'street' => $strasse, 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'DE',
|
||||
'localBusiness' => true, 'llmsTxt' => true,
|
||||
'faviconMediaId' => $medien['symbol']['id'],
|
||||
'stickyHeader' => false, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
|
||||
'consentBanner' => false, 'scrollTop' => false, 'postMetaHeader' => false,
|
||||
]);
|
||||
echo "Einstellungen gespeichert\n";
|
||||
|
||||
$passwort = bin2hex(random_bytes(12));
|
||||
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
|
||||
|
||||
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
|
||||
@@ -0,0 +1,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,99 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut das Favicon der DEEG-Installation aus dem Logo und setzt es.
|
||||
*
|
||||
* Bisher zeigte das Symbol auf das Logo selbst. Das Logo ist 209x100 – der Kern schneidet daraus
|
||||
* mittig ein Quadrat, und übrig blieb das „EE“ aus dem Schriftzug. Ein Favicon wird bei 16 px
|
||||
* gelesen, also braucht es ein eigenes, quadratisches Zeichen: ein Dachgiebel aus dem Logo, weiß
|
||||
* auf Logo-Blau. Das Dach ist das Markenzeichen und bleibt auch klein eine Silhouette; der
|
||||
* flächige blaue Grund trägt es auf hellen wie dunklen Browserleisten.
|
||||
*
|
||||
* Gerendert wird aus `logo-deeg.png`, damit das Symbol bei einem neuen Logo neu entsteht statt
|
||||
* als Fremdkörper in der Mediathek zu liegen. Läuft nur gegen die DEEG-Datenbank.
|
||||
*/
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
if (!str_contains($url, 'immobilien-deeg')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$quelle = $argv[1] ?? $root.'/var/immobilien-deeg-assets/logo-deeg.png';
|
||||
if (!is_file($quelle)) {
|
||||
fwrite(STDERR, "Logo nicht gefunden: $quelle\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Der linke Giebel des Doppeldachs im Logo. Die Maße stammen aus dem Bild selbst: das Dach liegt
|
||||
// über dem Schriftzug, x 3-203, y 0-40; die Hälfte davon ist ein Giebel.
|
||||
$AUSSCHNITT = ['x1' => 3, 'y1' => 0, 'x2' => 103, 'y2' => 40];
|
||||
$BLAU = [0, 129, 199]; // #0081c7, das Logo-Blau
|
||||
$KANTE = 512; // Vorlage für alle Ableitungen des Kerns (16 bis 180)
|
||||
$RAND = 0.06; // wenig Luft: klein gelesen zählt jede Fläche
|
||||
|
||||
$logo = imagecreatefrompng($quelle);
|
||||
$w = $AUSSCHNITT['x2'] - $AUSSCHNITT['x1'] + 1;
|
||||
$h = $AUSSCHNITT['y2'] - $AUSSCHNITT['y1'] + 1;
|
||||
$zeichen = imagecreatetruecolor($w, $h);
|
||||
imagealphablending($zeichen, false);
|
||||
imagesavealpha($zeichen, true);
|
||||
imagefilledrectangle($zeichen, 0, 0, $w, $h, imagecolorallocatealpha($zeichen, 0, 0, 0, 127));
|
||||
for ($y = 0; $y < $h; ++$y) {
|
||||
for ($x = 0; $x < $w; ++$x) {
|
||||
// Das Logo ist ein Palettenbild; imagecolorsforindex beantwortet Palette und Truecolor.
|
||||
$c = imagecolorsforindex($logo, imagecolorat($logo, $AUSSCHNITT['x1'] + $x, $AUSSCHNITT['y1'] + $y));
|
||||
$hell = $c['red'] > 230 && $c['green'] > 230 && $c['blue'] > 230;
|
||||
if ($c['alpha'] > 100 || $hell) {
|
||||
continue;
|
||||
}
|
||||
imagesetpixel($zeichen, $x, $y, imagecolorallocate($zeichen, 255, 255, 255));
|
||||
}
|
||||
}
|
||||
imagedestroy($logo);
|
||||
|
||||
$symbol = imagecreatetruecolor($KANTE, $KANTE);
|
||||
imagealphablending($symbol, false);
|
||||
imagesavealpha($symbol, true);
|
||||
imagefilledrectangle($symbol, 0, 0, $KANTE, $KANTE, imagecolorallocate($symbol, $BLAU[0], $BLAU[1], $BLAU[2]));
|
||||
imagealphablending($symbol, true);
|
||||
$platz = (int) round($KANTE * (1 - 2 * $RAND));
|
||||
$faktor = min($platz / $w, $platz / $h);
|
||||
$zw = (int) round($w * $faktor);
|
||||
$zh = (int) round($h * $faktor);
|
||||
imagecopyresampled($symbol, $zeichen, (int) (($KANTE - $zw) / 2), (int) (($KANTE - $zh) / 2), 0, 0, $zw, $zh, $w, $h);
|
||||
imagedestroy($zeichen);
|
||||
|
||||
$datei = sys_get_temp_dir().'/favicon-deeg.png';
|
||||
imagepng($symbol, $datei, 9);
|
||||
imagedestroy($symbol);
|
||||
echo "Symbol gerendert: {$KANTE}x{$KANTE}\n";
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
$media = new MediaLibrary($db, str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']));
|
||||
$settings = new SiteSettings($db, $media);
|
||||
|
||||
$eintrag = $media->upload(new UploadedFile($datei, 'favicon-deeg.png', null, null, true));
|
||||
$media->update($eintrag['id'], [
|
||||
'title' => 'Favicon DEEG',
|
||||
'alt' => 'Dachgiebel aus dem DEEG-Logo, weiß auf Blau',
|
||||
'archived' => false,
|
||||
'version' => $eintrag['version'],
|
||||
]);
|
||||
@unlink($datei);
|
||||
echo "In der Mediathek: {$eintrag['id']}\n";
|
||||
|
||||
$aktuell = $settings->load();
|
||||
$settings->save([
|
||||
...$aktuell,
|
||||
// Gespeichert als Liste, erwartet als Textfeld - wie es der Einstellungsbildschirm schickt.
|
||||
'sameAs' => implode("\n", $aktuell['sameAs']),
|
||||
'faviconMediaId' => $eintrag['id'],
|
||||
]);
|
||||
echo "Favicon gesetzt; der Kern hat 32 px (Browserleiste) und 180 px (Apple-Touch) daraus gerendert.\n";
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Räumt Texte auf, die vor 1.20.1 bei jedem Speichern eine Kodierungsstufe dazubekommen haben.
|
||||
*
|
||||
* `RichText::sanitise()` hat Text ohne ein einziges Tag nur escaped, statt ihn vorher zu
|
||||
* dekodieren. Aus „Freudenstadt & Umgebung“ wurde deshalb `&amp;`, dann `&amp;amp;`
|
||||
* – bis die Entity sichtbar auf der Seite stand. Der Fehler ist behoben; dieser Befehl bringt in
|
||||
* Ordnung, was schon in der Datenbank liegt.
|
||||
*
|
||||
* Angefasst wird, was in Gebrauch ist: die veröffentlichte und die Entwurfsfassung jeder Seite
|
||||
* und die Bereiche. Ältere Fassungen bleiben Historie – wer eine zurückholt, speichert sie
|
||||
* danach einmal, und der reparierte Kern schreibt sie richtig.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:repair:entities', description: 'Repariert doppelt kodierte Texte (&amp;) aus Fassungen vor 1.20.1.')]
|
||||
final class RepairEntitiesCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('schreiben', null, InputOption::VALUE_NONE, 'Die Korrekturen wirklich speichern. Ohne diese Option wird nur gezeigt, was sich ändern würde.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$schreiben = (bool) $input->getOption('schreiben');
|
||||
$gesamt = 0;
|
||||
$zeilen = [];
|
||||
|
||||
// Die aktiven Fassungen jeder Seite: veröffentlicht und Entwurf.
|
||||
$aktive = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page') as $seite) {
|
||||
foreach (['published_revision_id' => 'veröffentlicht', 'draft_revision_id' => 'Entwurf'] as $spalte => $was) {
|
||||
if (!empty($seite[$spalte])) {
|
||||
$aktive[(string) $seite[$spalte]] = '/'.$seite['path'].' ('.$was.')';
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($aktive as $revision => $bezeichnung) {
|
||||
$roh = $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]);
|
||||
if (!is_string($roh)) {
|
||||
continue;
|
||||
}
|
||||
$dokument = json_decode($roh, true, 512, JSON_THROW_ON_ERROR);
|
||||
$zahl = 0;
|
||||
$beispiele = [];
|
||||
$dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele);
|
||||
if (0 === $zahl) {
|
||||
continue;
|
||||
}
|
||||
$gesamt += $zahl;
|
||||
$zeilen[] = [$bezeichnung, $zahl, implode(' · ', $beispiele)];
|
||||
if ($schreiben) {
|
||||
// Geprüft wie beim Speichern: eine Korrektur darf kein ungültiges Dokument hinterlassen.
|
||||
$this->validator->validate($dokument, writing: true);
|
||||
$this->db->executeStatement('UPDATE revision SET document = ? WHERE id = ?', [$this->json($dokument), $revision]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, kind, name, document FROM partial') as $bereich) {
|
||||
$dokument = json_decode((string) $bereich['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$zahl = 0;
|
||||
$beispiele = [];
|
||||
$dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele);
|
||||
if (0 === $zahl) {
|
||||
continue;
|
||||
}
|
||||
$gesamt += $zahl;
|
||||
$zeilen[] = ['Bereich '.$bereich['kind'].' „'.$bereich['name'].'“', $zahl, implode(' · ', $beispiele)];
|
||||
if ($schreiben) {
|
||||
// Ein Bereich traegt keine Ueberschrift - genau wie im PartialRepository.
|
||||
$this->validator->validate($dokument, requireHeading: false, writing: true);
|
||||
$this->db->executeStatement('UPDATE partial SET document = ? WHERE id = ?', [$this->json($dokument), $bereich['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === $gesamt) {
|
||||
$io->success('Nichts zu reparieren: kein Text trägt eine doppelt kodierte Entity.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->table(['Wo', 'Stellen', 'Beispiel'], $zeilen);
|
||||
if ($schreiben) {
|
||||
$io->success(sprintf('%d Stelle(n) repariert.', $gesamt));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->warning(sprintf('%d Stelle(n) gefunden. Mit --schreiben anwenden.', $gesamt));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $liste */
|
||||
private function bausteine(array $liste, int &$zahl, array &$beispiele): array
|
||||
{
|
||||
foreach ($liste as $index => $block) {
|
||||
foreach (['text', 'caption', 'label', 'title', 'hint', 'provider'] as $feld) {
|
||||
$wert = $block['props'][$feld] ?? null;
|
||||
if (!is_string($wert) || '' === $wert) {
|
||||
continue;
|
||||
}
|
||||
$neu = $this->entschachteln($wert);
|
||||
if ($neu !== $wert) {
|
||||
$liste[$index]['props'][$feld] = $neu;
|
||||
++$zahl;
|
||||
if (count($beispiele) < 3) {
|
||||
$beispiele[] = mb_substr($wert, 0, 40).' → '.mb_substr($neu, 0, 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$liste[$index]['children'] = $this->bausteine($block['children'], $zahl, $beispiele);
|
||||
}
|
||||
}
|
||||
|
||||
return $liste;
|
||||
}
|
||||
|
||||
/** Entschachtelt so oft wie nötig: aus &amp;amp; wird &. Ein einzelnes & ist richtig und bleibt. */
|
||||
private function entschachteln(string $text): string
|
||||
{
|
||||
$vorher = null;
|
||||
while ($vorher !== $text) {
|
||||
$vorher = $text;
|
||||
$text = preg_replace('/&(amp|lt|gt|quot|apos|nbsp|#\d{1,6}|#x[0-9a-fA-F]{1,6});/', '&$1;', $text) ?? $text;
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $dokument */
|
||||
private function json(array $dokument): string
|
||||
{
|
||||
return json_encode($dokument, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,23 @@ namespace App\Content;
|
||||
use App\Domain\InvalidDocument;
|
||||
|
||||
/**
|
||||
* Own CSS and own script per element (document schema v13).
|
||||
* Own CSS and own script per element (document schema v13), and the raw HTML of the „Eigenes
|
||||
* HTML“ block (schema v24).
|
||||
*
|
||||
* The two are not comparable in what they can do, so they are not treated alike.
|
||||
* The three are not comparable in what they can do, so they are not treated alike.
|
||||
*
|
||||
* **CSS** is parsed and written out again by this class, scoped to the block. A declaration list
|
||||
* and nested rules starting with `&` are accepted; anything else is refused. Because the output is
|
||||
* re-serialised, a stray `}` cannot escape the block, and `@import` or a `javascript:` URL never
|
||||
* gets through. Everyone with the editor role may use it.
|
||||
*
|
||||
* **HTML** is markup that goes onto the page unchanged. It cannot be made harmless either - it
|
||||
* can load images from anywhere and point a form at a foreign address - so it follows the same
|
||||
* rule as a script: administrators only, checked against what is already stored. Two tags are
|
||||
* refused rather than silently swallowed by the content security policy: `<script>`, which
|
||||
* belongs in the script field, and `<iframe>`, which belongs in the „Externer Inhalt“ block,
|
||||
* because only that one asks for consent and puts the host into `frame-src`.
|
||||
*
|
||||
* **Script** is arbitrary JavaScript on the published site. There is no way to make that harmless:
|
||||
* whoever may set it can run code in the browser of every visitor and every administrator. It is
|
||||
* therefore reserved for administrators, checked on the way in by comparing against what is
|
||||
@@ -24,6 +32,7 @@ final class CustomCode
|
||||
{
|
||||
public const MAX_CSS = 4000;
|
||||
public const MAX_SCRIPT = 8000;
|
||||
public const MAX_HTML = 20000;
|
||||
|
||||
public static function supports(mixed $schemaVersion): bool
|
||||
{
|
||||
@@ -62,6 +71,66 @@ final class CustomCode
|
||||
return $script;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the markup of an „Eigenes HTML“ block and returns it unchanged.
|
||||
*
|
||||
* @throws InvalidDocument
|
||||
*/
|
||||
public function checkHtml(string $html): string
|
||||
{
|
||||
if (mb_strlen($html) > self::MAX_HTML) {
|
||||
throw new InvalidDocument(sprintf('Eigenes HTML ist auf %d Zeichen begrenzt.', self::MAX_HTML));
|
||||
}
|
||||
// Beide Tags wuerden von der Sicherheitsrichtlinie der Seite ohnehin nicht ausgefuehrt
|
||||
// bzw. nicht geladen. Eine Meldung mit dem richtigen Weg ist besser als ein Baustein,
|
||||
// der etwas annimmt und nachher nichts tut.
|
||||
if (1 === preg_match('/<\s*script/i', $html)) {
|
||||
throw new InvalidDocument('Eigenes HTML führt keine Skripte aus. Dafür gibt es unter „Erweitert“ das Feld „Eigenes Skript“.');
|
||||
}
|
||||
if (1 === preg_match('/<\s*iframe/i', $html)) {
|
||||
throw new InvalidDocument('Für einen eingebetteten Rahmen gibt es den Baustein „Externer Inhalt“: nur er fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein.');
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw HTML of every „Eigenes HTML“ block, keyed by block ID.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function htmlOf(array $document): array
|
||||
{
|
||||
$found = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$found): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('html' === ($block['type'] ?? '') && is_string($block['props']['html'] ?? null) && is_string($block['id'] ?? null)) {
|
||||
$found[$block['id']] = $block['props']['html'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a write that would add, change or remove raw HTML without the administrator role.
|
||||
*
|
||||
* @throws InvalidDocument
|
||||
*/
|
||||
public function assertHtmlUnchanged(array $incoming, array $stored): void
|
||||
{
|
||||
$before = $this->htmlOf($stored);
|
||||
$after = $this->htmlOf($incoming);
|
||||
ksort($before);
|
||||
ksort($after);
|
||||
if ($before !== $after) {
|
||||
throw new InvalidDocument('Eigenes HTML darf nur von einem Administrator geändert werden. Die übrigen Änderungen wurden nicht gespeichert.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns own CSS into rules scoped to one block.
|
||||
*
|
||||
@@ -124,17 +193,34 @@ final class CustomCode
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
|
||||
}
|
||||
$rules[$suffix] = $this->declarations($match[2][0]);
|
||||
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
|
||||
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
|
||||
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
|
||||
// wo `& .post-card{border:0}` und `& .post-card{transform:none}` nebeneinander
|
||||
// standen und der Rahmen des Kerns wieder auftauchte.
|
||||
$rules[$suffix] = $this->verbinden($rules[$suffix] ?? '', $this->declarations($match[2][0]));
|
||||
$rest = substr_replace($rest, '', $match[0][1], strlen($match[0][0]));
|
||||
}
|
||||
if (str_contains($rest, '{') || str_contains($rest, '}')) {
|
||||
throw new InvalidDocument('Eigenes CSS besteht aus Eigenschaften für dieses Element und aus verschachtelten Regeln, die mit & beginnen. Andere Selektoren sind nicht vorgesehen.');
|
||||
}
|
||||
$rules[''] = $this->declarations($rest);
|
||||
// Die Eigenschaften des Elements selbst stehen ueblicherweise vor den verschachtelten
|
||||
// Regeln; eine Regel `&{…}` schreibt sie aber in denselben Schluessel.
|
||||
$rules[''] = $this->verbinden($this->declarations($rest), $rules['']);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** Zwei Deklarationslisten desselben Selektors, in der Reihenfolge, in der sie standen. */
|
||||
private function verbinden(string $erste, string $zweite): string
|
||||
{
|
||||
if ('' === $erste || '' === $zweite) {
|
||||
return '' === $erste ? $zweite : $erste;
|
||||
}
|
||||
|
||||
return $erste.';'.$zweite;
|
||||
}
|
||||
|
||||
/** @throws InvalidDocument */
|
||||
private function declarations(string $block): string
|
||||
{
|
||||
@@ -153,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));
|
||||
}
|
||||
|
||||
+67
-19
@@ -122,34 +122,75 @@ final class DemoContent
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
/**
|
||||
* The start page of a fresh installation - not demo content.
|
||||
*
|
||||
* Short on purpose. Somebody who has just installed should find a page they can edit, not a
|
||||
* brochure about the CMS they have to delete first. Exactly one H1, as every page requires.
|
||||
* A website that is already reachable but not yet written needs a page that says exactly that:
|
||||
* the name of the site, the notice that something is being built here, and nothing else. No
|
||||
* instructions for the owner - those stand on the last screen of the installer and in the
|
||||
* admin area, not on a page every visitor sees. Exactly one H1, as every page requires.
|
||||
*/
|
||||
public static function starterPage(string $siteName): array
|
||||
{
|
||||
return [
|
||||
'schemaVersion' => 16,
|
||||
'schemaVersion' => 24,
|
||||
'title' => $siteName,
|
||||
'description' => 'NOCH AUSZUFÜLLEN: ein Satz darüber, was es hier gibt. Er erscheint in Suchergebnissen.',
|
||||
'description' => 'Hier entsteht eine neue Webpräsenz. Diese Seite wird gerade eingerichtet.',
|
||||
'blocks' => [
|
||||
self::section('start-hero', [
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
self::text('start-intro', 'Diese Seite ist der Anfang. Melde dich an, öffne sie im Editor und ersetze diesen Text durch deinen eigenen.'),
|
||||
], 'mint'),
|
||||
self::section('start-body', [
|
||||
self::heading('start-next-heading', 'Die nächsten Schritte', 2),
|
||||
self::text('start-next', "Impressum und Datenschutzerklärung sind bereits angelegt und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.\n\nUnter „Design“ legst du Farben, Schriften und die Breite fest, unter „Menüs“ die Navigation, unter „Einstellungen“ den Namen und die Angaben zum Unternehmen."),
|
||||
], 'paper', 'narrow'),
|
||||
[
|
||||
'id' => 'start-hero',
|
||||
'type' => 'section',
|
||||
'layout' => ['tone' => 'mint', 'width' => 'narrow'],
|
||||
'style' => [
|
||||
// Eine Seite ohne Inhalt: der Abschnitt füllt den Bildschirm und stellt das
|
||||
// Wenige, das dasteht, in die Mitte, statt es oben in eine Ecke zu setzen.
|
||||
'desktop' => [
|
||||
'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center',
|
||||
'minHeight' => '70vh', 'textAlign' => 'center',
|
||||
'padding' => ['top' => '96px', 'bottom' => '96px'],
|
||||
'gradientFrom' => '@paper', 'gradientTo' => '@mint', 'gradientAngle' => 165,
|
||||
],
|
||||
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
],
|
||||
'children' => [
|
||||
[
|
||||
'id' => 'start-logo',
|
||||
'type' => 'image',
|
||||
'props' => ['src' => '/assets/kantsites-logo.svg', 'alt' => 'KantSites', 'decorative' => false, 'width' => 128, 'height' => 128],
|
||||
'style' => ['desktop' => ['maxWidth' => '86px', 'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '30px']]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-label',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'In Arbeit'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '2px',
|
||||
'textTransform' => 'uppercase', 'color' => '@accent', 'margin' => ['bottom' => '16px'],
|
||||
]],
|
||||
],
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
[
|
||||
'id' => 'start-intro',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'Hier entsteht eine neue Webpräsenz.'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '21px', 'maxWidth' => '38ch',
|
||||
'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '0px'],
|
||||
]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-rule',
|
||||
'type' => 'divider',
|
||||
'style' => ['desktop' => ['width' => '72px', 'margin' => ['top' => '40px', 'right' => 'auto', 'bottom' => '22px', 'left' => 'auto']]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-note',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'Erstellt mit KantSites'],
|
||||
'style' => ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.4px', 'margin' => ['bottom' => '0px']]],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -164,6 +205,13 @@ final class DemoContent
|
||||
return $areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
public static function areas(): array
|
||||
{
|
||||
$row = static fn (string $id, array $children): array => [
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* PDFs neben den Bildern der Mediathek.
|
||||
*
|
||||
* Ein Bild wird beim Hochladen neu berechnet und nur in dieser Fassung ausgeliefert - was auch
|
||||
* immer in der Datei steckte, ist danach weg. Bei einem PDF geht das nicht: es wird so
|
||||
* ausgeliefert, wie es kam. Deshalb die Pruefung am Anfang der Datei statt an der Endung, und
|
||||
* beim Ausliefern Kopfzeilen, die den Browser nichts anderes darin sehen lassen als ein PDF.
|
||||
*
|
||||
* Die Dateien liegen im Medienordner, als <id>.pdf - so gehen sie mit jeder Sicherung mit.
|
||||
*/
|
||||
final class DocumentLibrary
|
||||
{
|
||||
/** Ein Prospekt oder eine Preisliste, kein Katalog mit tausend Fotos. */
|
||||
public const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/** Die Adresse eines Dokuments. So steht sie auch in einem Link-Feld. */
|
||||
public const PATH = '#^/media/([a-f0-9]{32})\.pdf$#D';
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> neueste zuerst */
|
||||
public function list(bool $withArchived = true): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM document'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC',
|
||||
));
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
return null;
|
||||
}
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM document WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file): array
|
||||
{
|
||||
if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) {
|
||||
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 20 MB pro PDF; das PHP-Hosting kann ein kleineres Limit setzen.');
|
||||
}
|
||||
// Ein PDF beginnt mit %PDF- - in den ersten 1024 Bytes, so lesen es auch die Betrachter.
|
||||
// Die Endung oder der Typ, den der Browser mitschickt, sagen darueber nichts.
|
||||
$head = (string) file_get_contents($file->getPathname(), false, null, 0, 1024);
|
||||
if (!str_contains($head, '%PDF-')) {
|
||||
throw new \InvalidArgumentException('Das ist kein PDF. Bitte eine PDF-Datei wählen.');
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$path = $this->path($id);
|
||||
if (!@copy($file->getPathname(), $path) || !is_file($path)) {
|
||||
throw new \RuntimeException('Das Dokument konnte nicht gespeichert werden.');
|
||||
}
|
||||
try {
|
||||
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Dokument', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('document', $row);
|
||||
} catch (\Throwable $error) {
|
||||
unlink($path);
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $this->present($row);
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE document SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
|
||||
return $this->find($id) ?? throw new StaleWrite();
|
||||
}
|
||||
|
||||
/** Die Datei eines Dokuments, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */
|
||||
public function file(string $id): ?string
|
||||
{
|
||||
if (null === $this->find($id)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->path($id);
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
private function path(string $id): string
|
||||
{
|
||||
return $this->directory.'/'.$id.'.pdf';
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => $row['id'],
|
||||
'title' => $row['title'],
|
||||
'bytes' => (int) $row['bytes'],
|
||||
'archived' => (bool) $row['archived'],
|
||||
'version' => (int) $row['version'],
|
||||
'created_at' => $row['created_at'],
|
||||
'src' => '/media/'.$row['id'].'.pdf',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
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'];
|
||||
@@ -38,8 +40,9 @@ final class DocumentValidator
|
||||
public const V15_TYPES = ['icon', 'progress', 'slider'];
|
||||
/** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */
|
||||
public const V23_TYPES = ['social'];
|
||||
public const V24_TYPES = ['html'];
|
||||
/** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */
|
||||
public const SCHEMA_VERSION = 23;
|
||||
public const SCHEMA_VERSION = 26;
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -98,7 +101,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -235,14 +238,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) {
|
||||
@@ -260,7 +281,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) {
|
||||
@@ -328,7 +349,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) {
|
||||
@@ -389,6 +410,14 @@ final class DocumentValidator
|
||||
$this->require(null === parse_url($url, PHP_URL_PORT) && null === parse_url($url, PHP_URL_USER), 'Die Adresse darf keinen Port und keine Zugangsdaten enthalten.');
|
||||
$this->require(is_int($props['height'] ?? null) && $props['height'] >= 150 && $props['height'] <= 1200, 'Die Höhe liegt zwischen 150 und 1200 Pixeln.');
|
||||
}
|
||||
if ('html' === $type) {
|
||||
// Eigenes HTML geht unveraendert auf die Seite. Geprueft wird es hier, damit ein
|
||||
// unbrauchbarer Wert beim Speichern eine Meldung bekommt statt beim Ausliefern
|
||||
// stillschweigend zu verschwinden. Wer es setzen darf, ist eine Frage der Rolle und
|
||||
// wird im Controller beantwortet - wie beim eigenen Skript.
|
||||
$this->require(is_string($props['html'] ?? null), 'Eigenes HTML ist Text.');
|
||||
$this->code->checkHtml($props['html']);
|
||||
}
|
||||
if ('form' === $type) {
|
||||
// Only the address is content. Fields, recipient and consent text stay in the database,
|
||||
// so a stored document can never redirect a submission or change what is asked.
|
||||
@@ -405,7 +434,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'])) {
|
||||
@@ -489,6 +518,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,18 +215,27 @@ final class MediaLibrary
|
||||
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
|
||||
}
|
||||
|
||||
/** Die Größen, die ein Favicon braucht: der Reiter im Browser und das Symbol auf dem Startbildschirm. */
|
||||
public const FAVICON_SIZES = [32, 180];
|
||||
/**
|
||||
* Die Größen, die ein Favicon braucht: 32 für den Reiter im Browser, 180 für den
|
||||
* Startbildschirm, 48 und 192 für die Google-Suche - Google nimmt nur Vielfache von 48 und
|
||||
* behielt sonst das zuerst gefundene Symbol, bei Elektro Bernhard das mitgelieferte K.
|
||||
*/
|
||||
public const FAVICON_SIZES = [32, 48, 180, 192];
|
||||
|
||||
/**
|
||||
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
|
||||
*
|
||||
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
|
||||
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein mittiger quadratischer
|
||||
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein quadratischer
|
||||
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
|
||||
* Einstellung, nicht bei jedem Aufruf.
|
||||
*
|
||||
* $crop ist der im Zuschnitt gewählte Ausschnitt als [links, oben, Kante]: links und oben als
|
||||
* Anteil von Breite und Höhe, die Kante als Anteil der kürzeren Seite. Ohne Angabe die Mitte.
|
||||
*
|
||||
* @param array{0: float, 1: float, 2: float}|null $crop
|
||||
*/
|
||||
public function renderFavicon(string $mediaId): void
|
||||
public function renderFavicon(string $mediaId, ?array $crop = null): void
|
||||
{
|
||||
// Ueber die Varianten, die dieses Bild tatsaechlich hat, nicht ueber die vier Regelbreiten:
|
||||
// ein 192 px breites Zeichen hat genau eine Variante mit 192 - und die Suche nach 480
|
||||
@@ -244,10 +253,18 @@ final class MediaLibrary
|
||||
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
|
||||
}
|
||||
try {
|
||||
// Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke.
|
||||
$kante = min(imagesx($source), imagesy($source));
|
||||
$links = (int) ((imagesx($source) - $kante) / 2);
|
||||
$oben = (int) ((imagesy($source) - $kante) / 2);
|
||||
// Quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke. Die Anteile
|
||||
// gelten für jede Variante gleich; gerechnet wird in der größten, die es gibt.
|
||||
$breite = imagesx($source);
|
||||
$hoehe = imagesy($source);
|
||||
$kante = min($breite, $hoehe);
|
||||
$links = (int) (($breite - $kante) / 2);
|
||||
$oben = (int) (($hoehe - $kante) / 2);
|
||||
if (null !== $crop) {
|
||||
$kante = max(1, min($kante, (int) round($crop[2] * min($breite, $hoehe))));
|
||||
$links = max(0, min($breite - $kante, (int) round($crop[0] * $breite)));
|
||||
$oben = max(0, min($hoehe - $kante, (int) round($crop[1] * $hoehe)));
|
||||
}
|
||||
foreach (self::FAVICON_SIZES as $size) {
|
||||
$target = imagecreatetruecolor($size, $size);
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,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)) {
|
||||
@@ -175,6 +181,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,8 +459,9 @@ final class PageRepository
|
||||
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
|
||||
$pages = max(1, (int) ceil($total / $limit));
|
||||
$page = max(1, min($pages, $page));
|
||||
$items = $qb->select('p.*', 'r.document')->orderBy('post' === $type ? 'p.created_at' : 'p.path', 'post' === $type ? 'DESC' : 'ASC')
|
||||
->addOrderBy('p.id')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
|
||||
$items = $qb->select('p.*', 'r.document')->orderBy('p.created_at', 'DESC')
|
||||
// Neueste zuerst, bei Seiten wie bei Beiträgen (Umut: „absteigend also neueste zuerst“).
|
||||
->addOrderBy('p.id', 'DESC')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
|
||||
|
||||
return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit];
|
||||
}
|
||||
|
||||
@@ -50,7 +50,19 @@ final class RichText
|
||||
}
|
||||
if (!str_contains($html, '<')) {
|
||||
// Plain text: escape it and keep line breaks, so typing without formatting still works.
|
||||
return nl2br(htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), false);
|
||||
//
|
||||
// Erst dekodieren: Was aus dem Editor kommt, ist HTML - auch ohne ein einziges Tag.
|
||||
// „Freudenstadt & Umgebung“ meint ein kaufmaennisches Und. Ohne diesen Schritt
|
||||
// wuerde daraus bei jedem Speichern eine Entity mehr (&amp;, &amp;amp; ...),
|
||||
// bis sie sichtbar auf der Seite steht. Der Zweig mit Tags macht es ueber den DOM
|
||||
// laengst richtig; dieser hier zog nach.
|
||||
$text = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Der Umbruch wird ersetzt, nicht ergaenzt: nl2br() liess den Zeilenumbruch
|
||||
// hinter dem <br> stehen, und beim naechsten Speichern machte breakLines()
|
||||
// daraus ein zweites <br> - aus einem Absatz wurden zwei. So enden beide Zweige
|
||||
// bei derselben Darstellung.
|
||||
return str_replace(["\r\n", "\r", "\n"], '<br>', htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||
}
|
||||
|
||||
$document = new \DOMDocument();
|
||||
|
||||
@@ -50,6 +50,10 @@ final class SiteSettings
|
||||
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
|
||||
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
|
||||
'faviconMediaId' => '',
|
||||
// Der Zuschnitt dazu als "links,oben,kante" in Anteilen, leer heißt: die Mitte. Die Version
|
||||
// hängt am Verweis, damit ein neuer Ausschnitt im Browser ankommt.
|
||||
'faviconCrop' => '',
|
||||
'faviconVersion' => '',
|
||||
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
|
||||
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
|
||||
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
|
||||
@@ -229,6 +233,14 @@ final class SiteSettings
|
||||
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||||
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
|
||||
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
|
||||
$crop = '' === $favicon ? '' : trim((string) ($input['faviconCrop'] ?? ''));
|
||||
$anteile = null;
|
||||
if ('' !== $crop) {
|
||||
$anteile = array_map('floatval', explode(',', $crop));
|
||||
if (1 !== preg_match('/^[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?$/D', $crop) || max($anteile) > 1 || $anteile[2] < 0.02) {
|
||||
throw new InvalidPage('Der Ausschnitt für das Symbol ist ungültig. Bitte das Bild neu zuschneiden.');
|
||||
}
|
||||
}
|
||||
if ('' !== $favicon) {
|
||||
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
|
||||
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
|
||||
@@ -237,12 +249,15 @@ final class SiteSettings
|
||||
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
|
||||
// wäre schlimmer als das alte Symbol.
|
||||
try {
|
||||
$this->media->renderFavicon($favicon);
|
||||
$this->media->renderFavicon($favicon, $anteile);
|
||||
} catch (\RuntimeException $error) {
|
||||
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
|
||||
}
|
||||
}
|
||||
$values['faviconMediaId'] = $favicon;
|
||||
$values['faviconCrop'] = $crop;
|
||||
// Ohne Zuschnitt die Kennung wie bisher, damit bestehende Verweise gleich bleiben.
|
||||
$values['faviconVersion'] = '' === $favicon ? '' : ('' === $crop ? substr($favicon, 0, 8) : substr(md5($favicon.'|'.$crop), 0, 8));
|
||||
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
|
||||
$wert = trim((string) ($input[$key] ?? ''));
|
||||
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -68,6 +68,10 @@ final class Theme
|
||||
'linkColor' => '@link',
|
||||
'containerWidth' => '1240px',
|
||||
'headingScale' => 1.25,
|
||||
// Die Ecken jedes Bildes auf der Website. Stand bis 1.20.2 fest im Stylesheet -
|
||||
// sichtbar gerundet, aber in keinem Feld zu finden. 12px bleibt die Vorgabe,
|
||||
// damit bestehende Websites gleich aussehen; 0px macht Bilder eckig.
|
||||
'imageRadius' => '12px',
|
||||
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
|
||||
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
|
||||
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
|
||||
@@ -229,7 +233,7 @@ final class Theme
|
||||
}
|
||||
$base[$field] = $value;
|
||||
}
|
||||
foreach (['fontSize', 'containerWidth'] as $field) {
|
||||
foreach (['fontSize', 'containerWidth', 'imageRadius'] as $field) {
|
||||
$value = trim((string) ($input[$field] ?? $base[$field]));
|
||||
if (1 !== preg_match(StyleSchema::LENGTH, $value)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist keine Größenangabe wie 18px oder 70rem.', $value));
|
||||
@@ -338,6 +342,7 @@ final class Theme
|
||||
}
|
||||
$base = $theme['base'];
|
||||
$variables[] = sprintf('--k-container:%s', $base['containerWidth']);
|
||||
$variables[] = sprintf('--k-image-radius:%s', $base['imageRadius']);
|
||||
|
||||
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ final class EditorController extends AbstractController
|
||||
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
|
||||
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
|
||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||
// Vorhandene Kategorien und Schlagwoerter als Vorschlag - sonst entstehen „Projekt“
|
||||
// und „Projekte“ nebeneinander.
|
||||
'termNames' => 'post' === $type ? $this->termNames() : ['category' => [], 'tag' => []],
|
||||
];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
@@ -118,6 +121,19 @@ final class EditorController extends AbstractController
|
||||
return $this->redirectToRoute('admin_editor', ['id' => $id]);
|
||||
}
|
||||
|
||||
/** @return array{category: list<string>, tag: list<string>} */
|
||||
private function termNames(): array
|
||||
{
|
||||
$names = ['category' => [], 'tag' => []];
|
||||
foreach ($this->posts->allTerms() as $term) {
|
||||
if (isset($names[$term['kind']])) {
|
||||
$names[$term['kind']][] = $term['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** The editor reads the style vocabulary and the palette from here, so both stay in one place. */
|
||||
#[Route('/api/design', name: 'admin_design', methods: ['GET'])]
|
||||
public function design(): JsonResponse
|
||||
@@ -139,9 +155,26 @@ final class EditorController extends AbstractController
|
||||
'buttonOptions' => \App\Content\ButtonDesign::OPTIONS,
|
||||
'buttonPresets' => \App\Content\ButtonDesign::PRESETS,
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
// Was ein Baustein per Adresse verknuepft, waehlt man aus einer Liste - niemand soll
|
||||
// die Adresse eines Formulars oder einer Kategorie auswendig wissen muessen.
|
||||
'choices' => $this->choices(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, string>> Adresse => Name, je Art */
|
||||
private function choices(): array
|
||||
{
|
||||
$choices = ['forms' => [], 'sections' => $this->partials->choices('section'), 'categories' => [], 'tags' => []];
|
||||
foreach ($this->forms->all() as $form) {
|
||||
$choices['forms'][$form['slug']] = $form['name'];
|
||||
}
|
||||
foreach ($this->posts->allTerms() as $term) {
|
||||
$choices['category' === $term['kind'] ? 'categories' : 'tags'][$term['slug']] = $term['name'];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
|
||||
/** Saves or removes a reusable style, so a look can be applied to other blocks and pages. */
|
||||
#[Route('/api/style-presets', name: 'admin_style_preset', methods: ['POST'])]
|
||||
public function stylePreset(Request $request): JsonResponse
|
||||
@@ -292,6 +325,7 @@ final class EditorController extends AbstractController
|
||||
// ueber den Umweg einer alten Fassung.
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||||
$this->code->assertHtmlUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||||
}
|
||||
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
@@ -458,7 +492,7 @@ final class EditorController extends AbstractController
|
||||
// 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);
|
||||
}
|
||||
@@ -488,6 +522,7 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($body['document'], $page['document']);
|
||||
$this->code->assertHtmlUnchanged($body['document'], $page['document']);
|
||||
}
|
||||
$page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']);
|
||||
// Die gespeicherte Fassung, nicht die geschickte: Rich Text wird beim Speichern
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -26,7 +29,7 @@ final class MediaController extends AbstractController
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query];
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||
@@ -52,16 +55,18 @@ final class MediaController extends AbstractController
|
||||
$done = 0;
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$this->media->upload($file);
|
||||
// Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der
|
||||
// Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt.
|
||||
$this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file);
|
||||
++$done;
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Bild(er) davor wurden hochgeladen.', $done) : ''));
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : ''));
|
||||
} catch (\RuntimeException) {
|
||||
return $this->back($request, 'Das Bild konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.');
|
||||
return $this->back($request, sprintf('%s konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.', $file->getClientOriginalName()));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
|
||||
return $this->back($request, sprintf('%d Datei(en) hochgeladen.', $done), 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
@@ -90,6 +95,38 @@ final class MediaController extends AbstractController
|
||||
return $this->back($request, 'Die Bildangaben wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/dokument/{id}', name: 'admin_document_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdateDocument(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$this->documents->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
try {
|
||||
$this->documents->update($id, [
|
||||
'title' => $request->request->getString('title'),
|
||||
'archived' => $request->request->getBoolean('archived'),
|
||||
'version' => $request->request->getInt('version'),
|
||||
]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->back($request, 'Das Dokument wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
private function isPdf(UploadedFile $file): bool
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
|
||||
return 'application/pdf' === $mime || (!str_starts_with((string) $mime, 'image/') && 'pdf' === strtolower($file->getClientOriginalExtension()));
|
||||
}
|
||||
|
||||
/** Back to the page the form was on, so a change does not jump to the first page. */
|
||||
private function back(Request $request, string $message, string $type = 'error'): Response
|
||||
{
|
||||
@@ -111,6 +148,53 @@ final class MediaController extends AbstractController
|
||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
/** Die PDFs fuer die Auswahl im Link-Feld. Wenige genug, um sie auf einmal zu schicken. */
|
||||
#[Route('/%kant.admin_path%/api/dokumente', methods: ['GET'])]
|
||||
public function listDocuments(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
return $this->json(['items' => $this->documents->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/dokumente/upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/dokumente/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function writeDocument(Request $request, ?string $id = null): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
|
||||
}
|
||||
try {
|
||||
if (null === $id) {
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->documents->upload($file)], 201);
|
||||
}
|
||||
if (!$this->documents->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
if (strlen($request->getContent()) > 4096) {
|
||||
throw new \InvalidArgumentException('Angaben zum Dokument sind zu groß.');
|
||||
}
|
||||
$data = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data)) {
|
||||
throw new \InvalidArgumentException('Ungültige Angaben zum Dokument.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->documents->update($id, $data)]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->json(['error' => 'Das Dokument wurde inzwischen geändert. Die Auswahl bitte neu öffnen.'], 409);
|
||||
} catch (\InvalidArgumentException|\JsonException $error) {
|
||||
return $this->json(['error' => $error->getMessage()], 422);
|
||||
} catch (\RuntimeException) {
|
||||
return $this->json(['error' => 'Das Dokument konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
@@ -159,21 +243,79 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Symbol der Website, in den beiden Größen, die Browser abfragen.
|
||||
* Das Symbol der Website, in den Größen, die Browser und Suchmaschinen abfragen.
|
||||
*
|
||||
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
|
||||
* favicon.svg, auf das die Seiten weiterhin verweisen.
|
||||
* Zeichen unter /favicon.svg. /favicon.ico fragen Crawler auf gut Glück ab; es bekommt das
|
||||
* 48er-PNG, das liest jeder Browser auch unter diesem Namen.
|
||||
*/
|
||||
#[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])]
|
||||
#[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])]
|
||||
public function favicon(Request $request): Response
|
||||
#[Route('/favicon.png', name: 'favicon_png', defaults: ['size' => 32], methods: ['GET'])]
|
||||
#[Route('/favicon-192.png', name: 'favicon_192', defaults: ['size' => 192], methods: ['GET'])]
|
||||
#[Route('/favicon.ico', name: 'favicon_ico', defaults: ['size' => 48], methods: ['GET'])]
|
||||
#[Route('/apple-touch-icon.png', name: 'favicon_touch', defaults: ['size' => 180], methods: ['GET'])]
|
||||
public function favicon(int $size): Response
|
||||
{
|
||||
$size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32;
|
||||
$path = $this->media->faviconFile($size) ?? throw $this->createNotFoundException();
|
||||
$path = $this->faviconFile($size);
|
||||
if (null === $path) {
|
||||
if (48 === $size) {
|
||||
return $this->redirect('/favicon.svg');
|
||||
}
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das mitgelieferte Zeichen - aber nur, solange kein eigenes Symbol gesetzt ist.
|
||||
*
|
||||
* Früher lag es als Datei in public/ und kam damit immer, auch nach dem Wechsel. Google hatte
|
||||
* es beim ersten Besuch gefunden und zeigte es weiter. Jetzt führt die Adresse zum eigenen
|
||||
* Symbol, vorübergehend (302), damit ein Zurückwechseln im Browser wieder ankommt.
|
||||
*/
|
||||
#[Route('/favicon.svg', name: 'favicon_svg', methods: ['GET'])]
|
||||
public function faviconSvg(): Response
|
||||
{
|
||||
if (null !== $this->faviconFile(192)) {
|
||||
$response = $this->redirect('/favicon-192.png?v='.$this->siteSettings->load()['faviconVersion'], 302);
|
||||
$response->headers->set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
return new BinaryFileResponse($this->getParameter('kernel.project_dir').'/public/assets/favicon.svg', headers: ['Content-Type' => 'image/svg+xml', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=3600']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Symboldatei, oder null ohne eigenes Symbol.
|
||||
*
|
||||
* Die Größen 48 und 192 kamen später dazu. Wer sein Symbol vorher gesetzt hat, hat nur 32
|
||||
* und 180 - die fehlenden entstehen beim ersten Abruf aus derselben Einstellung, statt dass
|
||||
* erst jemand die Einstellungen neu speichern muss.
|
||||
*/
|
||||
private function faviconFile(int $size): ?string
|
||||
{
|
||||
// Zuerst die Einstellung: wird das Symbol entfernt, bleiben die Dateien liegen, gelten
|
||||
// aber nicht mehr.
|
||||
$settings = $this->siteSettings->load();
|
||||
$favicon = (string) ($settings['faviconMediaId'] ?? '');
|
||||
if ('' === $favicon) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->media->faviconFile($size);
|
||||
if (null !== $path || !in_array($size, MediaLibrary::FAVICON_SIZES, true) || null === $this->media->find($favicon)) {
|
||||
return $path;
|
||||
}
|
||||
$crop = (string) ($settings['faviconCrop'] ?? '');
|
||||
try {
|
||||
$this->media->renderFavicon($favicon, '' === $crop ? null : array_map('floatval', explode(',', $crop)));
|
||||
} catch (\RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->media->faviconFile($size);
|
||||
}
|
||||
|
||||
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
|
||||
public function serve(string $id, int $width): Response
|
||||
{
|
||||
@@ -181,4 +323,29 @@ final class MediaController extends AbstractController
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF, so wie es hochgeladen wurde.
|
||||
*
|
||||
* inline, damit der Browser es anzeigt statt es abzulegen - mit dem Titel als Dateinamen fuer
|
||||
* den Fall, dass jemand es speichert. Einbetten darf es nur die eigene Website: der
|
||||
* Betrachter zeigt es in einem Rahmen, eine fremde Seite soll das nicht koennen.
|
||||
*/
|
||||
#[Route('/media/{id}.pdf', name: 'document_serve', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function serveDocument(string $id): Response
|
||||
{
|
||||
$path = $this->documents->file($id) ?? throw $this->createNotFoundException();
|
||||
$name = trim(preg_replace('/[\\\\\/\x00-\x1F\x7F"]+/u', ' ', $this->documents->find($id)['title'])).'.pdf';
|
||||
$fallback = trim(preg_replace('/[^A-Za-z0-9 ._-]+/', '-', (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name)), '- ');
|
||||
$response = new BinaryFileResponse($path, headers: [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Content-Security-Policy' => "frame-ancestors 'self'",
|
||||
'X-Robots-Tag' => 'noindex',
|
||||
]);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $name, str_ends_with($fallback, '.pdf') && '.pdf' !== $fallback ? $fallback : 'dokument.pdf');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,66 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
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;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Sprungziele einer Seite, fuer die Auswahl im Menue: jeder Baustein mit eigenem Anker,
|
||||
* beschriftet mit seiner ersten Ueberschrift. Wer ein Sprungziel setzt, soll es waehlen und
|
||||
* nicht wissen muessen, wie es im Editor hiess.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/api/sprungziele/{id}', name: 'admin_menu_anchors', requirements: ['id' => '[A-Za-z0-9_-]{1,64}'], methods: ['GET'])]
|
||||
public function anchors(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$page = $this->pages->findForEditor($id);
|
||||
if (null === $page) {
|
||||
return $this->json(['items' => []], 404);
|
||||
}
|
||||
$items = [];
|
||||
$heading = static function (array $block) use (&$heading): string {
|
||||
if ('heading' === ($block['type'] ?? null)) {
|
||||
return trim(html_entity_decode(strip_tags((string) ($block['props']['text'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
foreach ($block['children'] ?? [] as $child) {
|
||||
$text = $heading($child);
|
||||
if ('' !== $text) {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
$walk = static function (array $blocks) use (&$walk, &$items, $heading): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (is_string($block['anchor'] ?? null) && '' !== $block['anchor']) {
|
||||
$title = $heading($block);
|
||||
$items[] = ['anchor' => $block['anchor'], 'label' => '' === $title ? '#'.$block['anchor'] : mb_strimwidth($title, 0, 70, '…').' (#'.$block['anchor'].')'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
|
||||
return $this->json(['items' => $items]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
@@ -25,6 +70,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) {
|
||||
@@ -39,6 +85,8 @@ final class MenuController extends AbstractController
|
||||
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
|
||||
'menu' => $menu, 'rows' => $rows,
|
||||
'menuIcons' => \App\Content\DocumentValidator::ICONS,
|
||||
// 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],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -81,6 +129,7 @@ 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,
|
||||
];
|
||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||
}
|
||||
@@ -110,6 +159,7 @@ 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),
|
||||
];
|
||||
}
|
||||
usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']);
|
||||
@@ -124,6 +174,9 @@ final class MenuController extends AbstractController
|
||||
if ($row['newTab']) {
|
||||
$item['newTab'] = true;
|
||||
}
|
||||
if (null !== $row['button']) {
|
||||
$item['button'] = $row['button'];
|
||||
}
|
||||
if ('url' === $row['type']) {
|
||||
$item['url'] = $row['url'];
|
||||
} else {
|
||||
|
||||
@@ -31,6 +31,9 @@ final class PageController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
/** 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}';
|
||||
@@ -127,11 +130,15 @@ final class PageController extends AbstractController
|
||||
// Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt.
|
||||
$pluginAssets = $this->plugins->assetsFor($documents);
|
||||
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
|
||||
// Ein Link auf ein PDF der Mediathek - in einem Button, einem Bild oder im Fliesstext.
|
||||
// Gesucht wird im gespeicherten Dokument als Text: so findet sich auch ein Link, den
|
||||
// jemand in einen formatierten Absatz geschrieben hat.
|
||||
$needsDocument = (bool) array_filter($documents, static fn (array $doc): bool => 1 === preg_match('#/media/[a-f0-9]{32}\.pdf#', json_encode($doc, JSON_UNESCAPED_SLASHES) ?: ''));
|
||||
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
|
||||
// for the small script that marks the page as scrolled.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
$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);
|
||||
}
|
||||
@@ -195,6 +202,9 @@ final class PageController extends AbstractController
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $needsZoom,
|
||||
// Der PDF-Betrachter, nach demselben Muster und im selben Skript: ohne es ein
|
||||
// gewoehnlicher Link.
|
||||
'needsDocument' => $needsDocument,
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
@@ -209,10 +219,14 @@ final class PageController extends AbstractController
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
'eagerImage' => $eagerImage = $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'heroBackground' => $heroBackground = $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
// Ist es doch ein gewoehnliches Bild, wird auch das vorab geholt - sonst findet der
|
||||
// Browser es erst, wenn das Stylesheet steht, und diese Rundreise geht direkt in die
|
||||
// LCP-Zeit. Nur eines von beiden: zwei grosse Bilder vorab kosten mehr, als sie bringen.
|
||||
'heroImage' => [] === $heroBackground ? $this->heroImage($page['document'], $eagerImage) : null,
|
||||
'pageStyles' => $styles,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
@@ -221,7 +235,7 @@ final class PageController extends AbstractController
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
@@ -231,7 +245,7 @@ final class PageController extends AbstractController
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, $frames);
|
||||
return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,8 +259,9 @@ final class PageController extends AbstractController
|
||||
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
|
||||
* Nothing is added to the policy for an embed still waiting
|
||||
* for consent.
|
||||
* @param bool $frameSelf whether the page may frame its own PDFs (the viewer)
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
@@ -273,7 +288,9 @@ final class PageController extends AbstractController
|
||||
implode(' ', $scriptSources),
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
|
||||
// Der PDF-Betrachter zeigt eine Datei dieser Website in einem Rahmen - nur dort, wo
|
||||
// eine Seite auf ein PDF verlinkt, und nur von hier.
|
||||
[] === $frameHosts && !$frameSelf ? "'none'" : implode(' ', [...($frameSelf ? ["'self'"] : []), ...array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)]),
|
||||
));
|
||||
|
||||
return $response;
|
||||
@@ -772,6 +789,55 @@ final class PageController extends AbstractController
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Das vorgezogene Bild als Vorabladung im Kopf der Seite.
|
||||
*
|
||||
* Die Angaben sind dieselben, die `blocks/block.html.twig` an das `img` schreibt - Quelle,
|
||||
* Satz der Varianten und die Groessenangabe. Weicht eines davon ab, waehlt der Browser fuer
|
||||
* die Vorabladung eine andere Variante als fuer das Bild und laedt zweimal.
|
||||
*
|
||||
* @return array{src: string, srcset: string, sizes: string}|null
|
||||
*/
|
||||
private function heroImage(array $document, ?string $eagerImage): ?array
|
||||
{
|
||||
if (null === $eagerImage) {
|
||||
return null;
|
||||
}
|
||||
$block = $this->blockById($document['blocks'] ?? [], $eagerImage);
|
||||
$props = $block['props'] ?? [];
|
||||
if (null === $block || 'image' !== ($block['type'] ?? null) || '' === (string) ($props['src'] ?? '')) {
|
||||
return null;
|
||||
}
|
||||
$varianten = [];
|
||||
foreach (is_array($props['sources'] ?? null) ? $props['sources'] : [] as $quelle) {
|
||||
if (is_array($quelle) && isset($quelle['src'], $quelle['width'])) {
|
||||
$varianten[] = $quelle['src'].' '.$quelle['width'].'w';
|
||||
}
|
||||
}
|
||||
$feste = (string) ($block['style']['desktop']['maxWidth'] ?? '');
|
||||
|
||||
return [
|
||||
'src' => (string) $props['src'],
|
||||
'srcset' => implode(', ', $varianten),
|
||||
'sizes' => str_ends_with($feste, 'px') ? $feste : self::IMAGE_SIZES,
|
||||
];
|
||||
}
|
||||
|
||||
private function blockById(array $blocks, string $id): ?array
|
||||
{
|
||||
foreach ($blocks as $block) {
|
||||
if ($id === ($block['id'] ?? null)) {
|
||||
return $block;
|
||||
}
|
||||
$inside = $this->blockById($block['children'] ?? [], $id);
|
||||
if (null !== $inside) {
|
||||
return $inside;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function firstImage(array ...$documents): ?string
|
||||
{
|
||||
foreach ($documents as $document) {
|
||||
|
||||
@@ -145,6 +145,7 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($document, $partial['document']);
|
||||
$this->code->assertHtmlUnchanged($document, $partial['document']);
|
||||
}
|
||||
$this->partials->save($id, $document, $body['expectedVersion']);
|
||||
// Die gespeicherte Fassung zurueck - bereinigter Rich Text steht sofort im Editor.
|
||||
@@ -178,7 +179,7 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
|
||||
// Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben.
|
||||
$styles = $this->theme->css($theme).$this->styles->compileDocument($document).$this->code->compileDocument($document);
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
@@ -20,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme, private NotFoundPage $notFoundPage)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -31,8 +30,22 @@ final class SettingsController extends AbstractController
|
||||
// Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
|
||||
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$settings = $this->settings->load();
|
||||
$choosable = [];
|
||||
// Datenschutz und Impressum werden per Suche gewählt; das Feld zeigt Titel und Adresse der
|
||||
// gespeicherten Seite. Eine Adresse ohne Seite bleibt sichtbar, statt still zu verschwinden.
|
||||
$legal = ['privacyPath' => null, 'imprintPath' => null];
|
||||
foreach (array_keys($legal) as $key) {
|
||||
if ('' !== $settings[$key]) {
|
||||
$legal[$key] = ['path' => $settings[$key], 'title' => $settings[$key]];
|
||||
}
|
||||
}
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
foreach ($legal as $key => $selection) {
|
||||
if (null !== $selection && $selection['path'] === $page['path']) {
|
||||
$legal[$key]['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['path'];
|
||||
}
|
||||
}
|
||||
// Only a published page can serve as the root; a draft would answer every visit with 404.
|
||||
if ('page' === $page['type'] && null !== $page['published_revision_id']) {
|
||||
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -41,15 +54,14 @@ final class SettingsController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $this->settings->load(),
|
||||
'settings' => $settings,
|
||||
'legalSelection' => $legal,
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
'choosableHome' => $choosable,
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
'missingLegal' => $this->settings->missingLegalPages(),
|
||||
'consentGroupHelp' => Consent::GROUP_HELP,
|
||||
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
|
||||
'images' => $this->media->page(1, false, 96)['items'],
|
||||
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||||
'palette' => $this->theme->load()['colors'],
|
||||
'socialNetworks' => SocialProfiles::NETWORKS,
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Command\SiteOriginCommand;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -21,7 +24,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -55,6 +58,7 @@ final class SystemController extends AbstractController
|
||||
// ein ganz anderer Host (Vorschauadresse des Hosters, IP) bleibt erreichbar und
|
||||
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
|
||||
'origin' => $this->originFacts($request),
|
||||
'originRejected' => $request->getSession()->remove('origin_rejected'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -258,6 +262,62 @@ final class SystemController extends AbstractController
|
||||
: 'Der Wartungszustand ist aufgehoben.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Adresse der Website, wie „WordPress-Adresse“ in WordPress: sie landet in .env als
|
||||
* SITE_ORIGIN und gilt ab der nächsten Anfrage.
|
||||
*
|
||||
* Vorher fragt die Installation sich selbst unter der neuen Adresse an. Eine Umstellung auf
|
||||
* www ohne DNS-Eintrag leitete sonst jede Seite ins Leere, die Verwaltung eingeschlossen.
|
||||
* Wer weiß, was er tut (Server erreicht sich selbst nicht, http im lokalen Betrieb), kann die
|
||||
* Prüfung ausdrücklich übergehen.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/adresse', name: 'admin_system_origin', methods: ['POST'])]
|
||||
public function origin(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ('.env' !== $this->environmentSource('SITE_ORIGIN')) {
|
||||
return $this->back('Die Adresse ist in der Server-Umgebung gesetzt und hat dort Vorrang. Sie lässt sich nur beim Hoster ändern.');
|
||||
}
|
||||
try {
|
||||
$neu = SiteOriginCommand::normalize($request->request->getString('origin'));
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($error->getMessage());
|
||||
}
|
||||
if ($neu === rtrim(trim($this->siteOrigin), '/')) {
|
||||
return $this->back('Die Adresse ist unverändert.', 'success');
|
||||
}
|
||||
if (!$request->request->getBoolean('force') && null !== $problem = $this->originCheck->problem($neu)) {
|
||||
$request->getSession()->set('origin_rejected', $neu);
|
||||
|
||||
return $this->back($problem.' Nichts geändert.');
|
||||
}
|
||||
try {
|
||||
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
|
||||
} catch (\RuntimeException $error) {
|
||||
return $this->back($error->getMessage());
|
||||
}
|
||||
|
||||
// Die Anmeldung gilt nur für die Schreibweise, unter der sie entstand. Wechselt die
|
||||
// Verwaltung dadurch den Host, ist dort eine neue Anmeldung fällig - das sagt die Meldung,
|
||||
// solange sie noch auf dem alten Host steht.
|
||||
return $this->back(sprintf(
|
||||
'Die Website ist jetzt eingetragen als %s. Canonical, Sitemap und Feed nennen diese Adresse, die Schreibweise %s www leitet dorthin um.%s',
|
||||
$neu,
|
||||
str_starts_with((string) parse_url($neu, PHP_URL_HOST), 'www.') ? 'ohne' : 'mit',
|
||||
strtolower($request->getHost()) !== strtolower((string) parse_url($neu, PHP_URL_HOST)) ? ' Unter der neuen Adresse ist eine neue Anmeldung nötig.' : '',
|
||||
), 'success');
|
||||
}
|
||||
|
||||
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
|
||||
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||||
public function originProbe(): Response
|
||||
{
|
||||
return new Response($this->originCheck->token(), 200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Robots-Tag' => 'noindex']);
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon)#', $path)) {
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) {
|
||||
return;
|
||||
}
|
||||
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||
|
||||
@@ -29,6 +29,16 @@ final class ArchiveUpdate
|
||||
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
|
||||
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
|
||||
|
||||
/**
|
||||
* Was frühere Fassungen in public/ mitbrachten und jetzt die Anwendung beantwortet.
|
||||
*
|
||||
* Das Zusammenführen kopiert nur hinzu. Bliebe favicon.svg liegen, lieferte der Webserver
|
||||
* sie weiter selbst aus - das mitgelieferte Zeichen, auch wenn ein eigenes Symbol gesetzt ist.
|
||||
* Gelöscht wird in finish(): Das Kopieren erledigt noch der Code der alten Fassung, der
|
||||
* diese Liste nicht kennt.
|
||||
*/
|
||||
private const ENTFALLEN_IN_PUBLIC = ['favicon.svg'];
|
||||
|
||||
/**
|
||||
* Die Marke zwischen den beiden Schritten.
|
||||
*
|
||||
@@ -306,6 +316,9 @@ final class ArchiveUpdate
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) {
|
||||
@unlink($this->root.'/public/'.$eintrag);
|
||||
}
|
||||
$schritte[] = ['step' => 'Aufräumen', 'ok' => true, 'detail' => $this->aufraeumen()];
|
||||
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Prüft, ob eine neue Adresse der Website wirklich bei dieser Installation ankommt.
|
||||
*
|
||||
* Die Adresse entscheidet, wohin die Schreibweise mit oder ohne www umgeleitet wird - für die
|
||||
* Website und für die Verwaltung. Stellt jemand auf www um, und www hat keinen DNS-Eintrag oder
|
||||
* kein Zertifikat, leitet danach jede Seite ins Leere, und in die Verwaltung kommt man nur noch
|
||||
* über FTP. Deshalb wird vor dem Speichern angefragt: Unter der neuen Adresse muss eine Kennung
|
||||
* zurückkommen, die nur diese Installation kennt - abgeleitet aus APP_SECRET, ohne es zu verraten.
|
||||
*/
|
||||
class OriginCheck
|
||||
{
|
||||
public const PATH = '/.well-known/kantsites-origin';
|
||||
|
||||
private Download $download;
|
||||
|
||||
public function __construct(private string $secret, ?Download $download = null)
|
||||
{
|
||||
// Kurz: die Anfrage geht an den eigenen Server und hält die Verwaltung so lange auf.
|
||||
$this->download = $download ?? new Download(5, 10);
|
||||
}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
return hash_hmac('sha256', 'site-origin', $this->secret);
|
||||
}
|
||||
|
||||
/** Null, wenn die Adresse hier ankommt - sonst der Grund in einem Satz. */
|
||||
public function problem(string $origin): ?string
|
||||
{
|
||||
if (!str_starts_with($origin, 'https://')) {
|
||||
return 'Geprüft werden nur HTTPS-Adressen. Eine Adresse mit http:// lässt sich nur mit „Trotzdem übernehmen“ speichern.';
|
||||
}
|
||||
$antwort = $this->download->get($origin.self::PATH);
|
||||
if (null === $antwort) {
|
||||
$grund = $this->download->reason();
|
||||
|
||||
return 'Unter '.$origin.' antwortet diese Website nicht'.('' !== $grund ? ': '.rtrim($grund, '.').'.' : '.').' DNS-Eintrag und Zertifikat für diese Domain prüfen.';
|
||||
}
|
||||
if (!hash_equals($this->token(), trim($antwort))) {
|
||||
return 'Unter '.$origin.' antwortet eine andere Website. Zeigt die Domain auf diesen Server?';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user