Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21e800f2c | ||
|
|
d8c8cbe7bd | ||
|
|
45d1141951 | ||
|
|
42e74af052 | ||
|
|
106f84dfd5 | ||
|
|
6632836580 | ||
|
|
d0019c80af | ||
|
|
ab9e6a7ea0 | ||
|
|
8328bf0964 | ||
|
|
245f189d57 | ||
|
|
99b3aba39a | ||
|
|
c91f996524 | ||
|
|
0ed4c83e41 | ||
|
|
9e0365f841 | ||
|
|
ff2b57db58 | ||
|
|
741eaa80f7 | ||
|
|
23fdca4edb | ||
|
|
a8368068aa | ||
|
|
f4ec0d9877 | ||
|
|
d08d6f648a | ||
|
|
4539f357c1 | ||
|
|
d885b3ddb5 | ||
|
|
59a40157a4 | ||
|
|
6b1aa50d02 | ||
|
|
30aac4389d |
No files matched your search
+41
-1
@@ -25,7 +25,7 @@
|
||||
"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-3fc9a10171d3/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8091
|
||||
},
|
||||
@@ -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,26 @@
|
||||
"$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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,20 @@ 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`.
|
||||
|
||||
## 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.
|
||||
@@ -22,7 +36,7 @@ Archivseiten (`/beitraege`, Kategorien, Schlagwörter) sind jetzt gewöhnliche S
|
||||
|
||||
## Plugins (unveröffentlicht)
|
||||
|
||||
Erweiterungen liegen als Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional Stylesheet und Skript unter `assets/`. Unter **Plugins** (Administrator) werden sie ein- und ausgeschaltet; ihre Bausteine erscheinen dann im Editor unter „Erweiterungen“, mit einem Eigenschaftenpanel aus dem Schema des Manifests. Der Kern prüft die Eigenschaften beim Speichern und beim Rendern, liefert Stylesheet und Skript nur auf Seiten mit dem Baustein aus und lässt die Bausteine eines ausgeschalteten Plugins unangetastet in den Seiten stehen. Ein Plugin kann außerdem Container-Bausteine (mit Liste erlaubter Kinder), Einstellungen für den Betreiber (Formular auf dem Bildschirm „Plugins“), eigene Adressen wie `/vergleich/daten` (Twig-Vorlage, Antwort als html, json, text oder xml), Seitenvorlagen für den Editor und ein Designpaket mitbringen („Designpaket übernehmen“ ersetzt Palette, Schriften, Grundwerte und Buttons; die Inhalte bleiben). PHP-Code bringt ein Plugin nicht mit – deshalb gibt es keine Hooks und keine eigenen Tabellen (ADR 103). Mitgeliefert sind **Bildvergleich** (`plugins/vergleich`: Bildvergleich, Vergleichsgalerie, Vorlage, Adresse, Einstellungen) und das Designpaket **Nachtblau** (`plugins/nachtblau`). Wie man ein Plugin schreibt, steht in `docs/PLUGINS.md`. Migrationen `20260915000100` und `20260916000100` legen die Tabelle `plugin` mit Einstellungen an.
|
||||
Erweiterungen liegen als Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional Stylesheet und Skript unter `assets/`. Unter **Plugins** (Administrator) werden sie ein- und ausgeschaltet; ihre Bausteine erscheinen dann im Editor unter „Erweiterungen“, mit einem Eigenschaftenpanel aus dem Schema des Manifests. Der Kern prüft die Eigenschaften beim Speichern und beim Rendern, liefert Stylesheet und Skript nur auf Seiten mit dem Baustein aus und lässt die Bausteine eines ausgeschalteten Plugins unangetastet in den Seiten stehen. Ein Plugin kann außerdem Container-Bausteine (mit Liste erlaubter Kinder), Einstellungen für den Betreiber (Formular auf dem Bildschirm „Plugins“), eigene Adressen wie `/vergleich/daten` (Twig-Vorlage, Antwort als html, json, text oder xml), und Seitenvorlagen für den Editor mitbringen. Farben, Schriften und Buttons fasst ein Plugin nicht an – die ändert man unter **Design** (ADR 105). PHP-Code bringt ein Plugin nicht mit – deshalb gibt es keine Hooks und keine eigenen Tabellen (ADR 103). Mitgeliefert ist **Bildvergleich** (`plugins/vergleich`: Bildvergleich, Vergleichsgalerie, Vorlage, Adresse, Einstellungen). Wie man ein Plugin schreibt, steht in `docs/PLUGINS.md`. Migrationen `20260915000100` und `20260916000100` legen die Tabelle `plugin` mit Einstellungen an.
|
||||
|
||||
## Formulare in Schritten, mit Bedingungen und Zusammenfassung (unveröffentlicht)
|
||||
|
||||
@@ -363,6 +377,14 @@ 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.
|
||||
|
||||
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`.
|
||||
+24
-4
@@ -2,6 +2,26 @@
|
||||
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026)
|
||||
|
||||
Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte.
|
||||
|
||||
**Den Rahmen gab es schon.** Der Baustein `embed` („Externer Inhalt“, Schema v14) bettet eine fremde Seite ein: Adresse, Titel, Anbieter, Höhe – und davor die Einwilligungsfrage. Vor der Einwilligung wird nichts geladen, kein Bild und keine Anfrage; erst danach kommt der Host in `frame-src` der Sicherheitsrichtlinie. Ein `<iframe>`, das jemand als rohes Markup einfügt, hätte beides nicht: keine Frage, und der Browser lädt es gar nicht erst, weil `frame-src` den Host nicht kennt. Deshalb ist `<iframe>` im HTML-Baustein eine Fehlermeldung mit dem Weg zum richtigen Baustein – nicht etwas, das man einfügen kann und das dann schweigend nichts tut. Dasselbe gilt für `<script>`: das Feld „Eigenes Skript“ unter „Erweitert“ liefert es als eigene Datei aus, inline würde die Richtlinie es ohnehin blockieren.
|
||||
|
||||
**Der Baustein (Schema v24).** `html` mit einer einzigen Eigenschaft: das Markup, bis 20.000 Zeichen, unverändert ausgegeben. Es gibt keine Filterliste – wer ihn setzen darf, ist die eigentliche Antwort, und die ist dieselbe wie beim eigenen Skript: **nur ein Administrator**. Geprüft wird wie dort gegen den gespeicherten Stand (`CustomCode::assertHtmlUnchanged()`), in beiden Controllern und auch beim Zurückholen einer alten Fassung – ein Redakteur kann eine Seite mit HTML also weiter bearbeiten, ohne das HTML anfassen zu können. Im Editor ist das Feld für ihn sichtbar und schreibgeschützt, wie das Skriptfeld.
|
||||
|
||||
**Warum keine Filterung wie bei Rich Text.** Genau die Fälle, für die man den Baustein nimmt – ein fremdes Widget, eine eigene Tabelle, eine Auszeichnung, die es als Baustein nicht gibt – fallen durch jede Whitelist. Ein gefilterter HTML-Baustein hätte den Wunsch nicht erfüllt, sondern nur verschoben.
|
||||
|
||||
**Folgen.** `README.md` nennt den Baustein und seine Rolle. Nicht gebaut: eine Vorschau, die fremde Skripte ausführt (die Editorvorschau führt keine aus, das bleibt so), und eine Ausnahme von der Administratorregel für einzelne Seiten.
|
||||
|
||||
## ADR 105 – Designpakete in Plugins zurückgenommen: das Design gehört der Website (16.09.2026)
|
||||
|
||||
Umut hatte auf der DEEG-Installation „Nachtblau“ eingeschaltet und wieder ausgeschaltet – und die Farben blieben. Die Ursache war nicht der Schalter (`activate()`/`deactivate()` fassen das Design nie an), sondern der zweite Knopf daneben: „Designpaket übernehmen“ schrieb die Palette des Plugins über `Theme::save()` endgültig in die Website. Technisch war das gewollt (ADR 103: ein Import, damit sich das Ergebnis unter Design weiter anpassen lässt und beim Ausschalten nichts verlorengeht) – in der Bedienung war es eine Falle: eine Aktion, die die gesamte Palette ersetzt, ohne Rückfrage und ohne Weg zurück, auf einem Bildschirm, wo daneben ein Schalter sitzt, den man gefahrlos umlegen kann.
|
||||
|
||||
**Entscheidung: `theme` fällt aus dem Manifest.** Kein `themePackage()`, kein `applyTheme()`, keine Route `admin_plugin_theme`, kein Knopf auf dem Bildschirm „Plugins“, kein `plugins/nachtblau`. Ein Manifest braucht jetzt mindestens einen Baustein, eine Adresse oder eine Vorlage. Der Alternativweg – Sicherung der alten Palette plus „Design zurücksetzen“ – wäre eine Spalte, eine Migration und ein zweiter Zustand gewesen, um eine Funktion zu retten, die niemand vermisst: Farben, Schriften und Buttons ändert man unter **Design**, dort mit Vorschau und in einem Schritt rückgängig.
|
||||
|
||||
**Folgen.** Ein Plugin kann das Aussehen der Website nur noch über das CSS seiner eigenen Bausteine beeinflussen – nie über die Palette. Der Test dazu heißt jetzt umgekehrt: Ein- und Ausschalten lässt Design **und** Inhalte unangetastet (`PluginTest::testSwitchingAPluginOnAndOffLeavesTheDesignAlone`). Bestehende Installationen behalten die Palette, die sie haben; wer ein übernommenes Paket loswerden will, setzt die Farben unter Design. Auf der DEEG-Installation ist die eigene Palette (Logo-Blau `#0081c7`, Anthrazit, Arial, eckige Buttons) aus dem Aufbauskript wieder eingespielt. Die Abnahme „Templatewechsel erhält die Inhalte“ aus dem Ausbauplan gilt damit als nicht gebaut; ein Templatewechsel gehört, wenn er kommt, in den Designbereich und nicht in ein Plugin.
|
||||
|
||||
## ADR 104 – Die Rückstände der Gesamtprüfung abgearbeitet: Archiv als Seite, Bereiche vollständig, Palette bis in die Fehlerfarbe, Betrieb auf MySQL (16.09.2026)
|
||||
|
||||
Umut: „Bitte umsetzen“ – die Liste aus ADR 100, die bis dahin nur dokumentiert war.
|
||||
@@ -26,7 +46,7 @@ Umut: „Bitte umsetzen“ – die Liste aus ADR 100, die bis dahin nur dokument
|
||||
|
||||
**Folgen.** `tests/BacklogTest.php` hält alles fest. Nicht gebaut: eine Wiederherstellung auf MySQL ohne `mysql`-Client (dann liegt der Dump zum Einspielen von Hand bereit), Druckstile für Beiträge mit Bildkopf.
|
||||
|
||||
## ADR 103 – Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen und Designpakete für Plugins – und warum es keine PHP-Hooks gibt (16.09.2026)
|
||||
## ADR 103 – Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen und Designpakete für Plugins (Designpakete zurückgenommen, ADR 105) – und warum es keine PHP-Hooks gibt (16.09.2026)
|
||||
|
||||
Umut: „Dann mach H.“ Der erste Teil (ADR 102) brachte Manifest, Schalter und geprüfte Bausteine. Offen waren Container, Einstellungen, eigene Routen, Tabellen, Hooks und Templates.
|
||||
|
||||
@@ -40,9 +60,9 @@ Umut: „Dann mach H.“ Der erste Teil (ADR 102) brachte Manifest, Schalter und
|
||||
|
||||
**Seitenvorlagen.** `"presets": {name: {label, blocks}}` – fertige Bausteine, die der Editor als Ganzes einfügt, hinter den eingebauten Vorlagen mit demselben Knopf. Jedes Einfügen vergibt frische Kennungen; geprüft wird beim Speichern wie jedes Dokument. Ein Plugin kann so seinen Baustein gleich im passenden Abschnitt anbieten.
|
||||
|
||||
**Designpakete.** `"theme": {colors, fonts, base, buttons}` im Format des Designbereichs. „Designpaket übernehmen“ auf dem Bildschirm „Plugins“ ruft `Theme::save()` – dieselbe Prüfung wie beim Speichern unter Design, dieselben Fehler. Inhalte bleiben unangetastet; genau das ist die Abnahme des Plans („Templatewechsel erhält die Inhalte“), und der Test wechselt auf einer Seite mit Plugin-Baustein das Paket und vergleicht das Dokument davor und danach. Ein Plugin darf nur aus einem Designpaket bestehen (`plugins/nachtblau`); übernehmen geht nur eingeschaltet.
|
||||
**Designpakete.** *(Zurückgenommen in ADR 105 – es gibt sie nicht mehr.)* `"theme": {colors, fonts, base, buttons}` im Format des Designbereichs. „Designpaket übernehmen“ auf dem Bildschirm „Plugins“ ruft `Theme::save()` – dieselbe Prüfung wie beim Speichern unter Design, dieselben Fehler. Inhalte bleiben unangetastet; genau das ist die Abnahme des Plans („Templatewechsel erhält die Inhalte“), und der Test wechselt auf einer Seite mit Plugin-Baustein das Paket und vergleicht das Dokument davor und danach. Ein Plugin darf nur aus einem Designpaket bestehen (`plugins/nachtblau`); übernehmen geht nur eingeschaltet.
|
||||
|
||||
**Referenz.** `plugins/vergleich` 1.1.0 hat jetzt Einstellungen (Standardbeschriftungen, Griff), einen Container „Vergleichsgalerie“ nur für Bildvergleiche, die Vorlage „Vorher-Nachher-Abschnitt“ und die Adresse `/vergleich/daten` (JSON mit den Einstellungen). `plugins/nachtblau` ist das Designpaket.
|
||||
**Referenz.** `plugins/vergleich` 1.1.0 hat jetzt Einstellungen (Standardbeschriftungen, Griff), einen Container „Vergleichsgalerie“ nur für Bildvergleiche, die Vorlage „Vorher-Nachher-Abschnitt“ und die Adresse `/vergleich/daten` (JSON mit den Einstellungen). `plugins/nachtblau` war das Designpaket und ist mit ADR 105 entfallen.
|
||||
|
||||
**Folgen.** `docs/PLUGINS.md` beschreibt alles. Nicht gebaut und nicht geplant, solange Plugins ohne PHP bleiben: Hooks, Tabellen, Ressourcen in der Verwaltung, eigene Verwaltungsbildschirme.
|
||||
|
||||
@@ -1034,7 +1054,7 @@ Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon is
|
||||
|
||||
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
|
||||
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein quadratischer Ausschnitt in 32 px (Reiter), 180 px (Startbildschirm) sowie 48 und 192 px (Google nimmt nur Vielfache von 48), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png`, `/favicon-192.png`, `/favicon.ico` (48 px) und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt. Auch `/favicon.svg` beantwortet die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen, mit eigenem ein 302 auf das 192er – als Datei in `public/` hätte der Webserver immer das mitgelieferte Zeichen ausgeliefert, und Suchmaschinen hätten es behalten.
|
||||
|
||||
Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
|
||||
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ Umut hat den Umfang von G erweitert. Vier Punkte, in dieser Reihenfolge:
|
||||
|
||||
Abnahme: Kopf- und Fußbereich sind im Editor gestaltbar und öffentlich sichtbar. Eine verknüpfte Sektion auf zwei Seiten ändert sich an beiden Stellen gleichzeitig. Ein Textelement lässt sich formatiert und als HTML bearbeiten, und keine Auszeichnung außerhalb der Positivliste übersteht das Speichern. Eigenes CSS wirkt, eigenes Skript wirkt nur für Administratoren gesetzt und nur auf der veröffentlichten Seite. Die Verwaltung ist ohne Anleitung bedienbar.
|
||||
|
||||
### H – Plugins und Templates — **erledigt am 16.09.2026** (ADR 102 und 103: Manifest, Schalter, geprüfte Bausteine, Dateien je Seite, Editoranbindung, Container, Einstellungen, eigene Adressen, Seitenvorlagen, Designpakete; Referenzplugins „Bildvergleich“ und „Nachtblau“. Bewusst nicht: PHP-Hooks und eigene Tabellen – Plugins bleiben Manifest plus Twig, ADR 103)
|
||||
### H – Plugins und Templates — **erledigt am 16.09.2026** (ADR 102 und 103: Manifest, Schalter, geprüfte Bausteine, Dateien je Seite, Editoranbindung, Container, Einstellungen, eigene Adressen, Seitenvorlagen; Referenzplugin „Bildvergleich“. Designpakete in Plugins am selben Tag wieder entfernt – das Design gehört der Website, ADR 105. Bewusst nicht: PHP-Hooks und eigene Tabellen – Plugins bleiben Manifest plus Twig, ADR 103)
|
||||
|
||||
*Braucht: den Erhaltungsvertrag (erledigt).*
|
||||
|
||||
|
||||
+12
-12
@@ -1,7 +1,7 @@
|
||||
# Plugins schreiben
|
||||
|
||||
Ein Plugin bringt eigene Bausteine, Vorlagen, Adressen und Designpakete in den Editor und auf die
|
||||
Website. Es ist ein Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional
|
||||
Ein Plugin bringt eigene Bausteine, Vorlagen und Adressen in den Editor und auf die
|
||||
Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört der Website (ADR 105). Es ist ein Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional
|
||||
Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins**
|
||||
(Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
@@ -22,8 +22,8 @@ plugins/
|
||||
assets/bildvergleich.js
|
||||
```
|
||||
|
||||
Die mitgelieferten Plugins sind die Vorlagen zum Abschreiben: `plugins/vergleich` (Bausteine,
|
||||
Container, Einstellungen, Adresse, Seitenvorlage) und `plugins/nachtblau` (nur ein Designpaket).
|
||||
Das mitgelieferte Plugin ist die Vorlage zum Abschreiben: `plugins/vergleich` (Bausteine,
|
||||
Container, Einstellungen, Adresse, Seitenvorlage).
|
||||
|
||||
## Das Manifest
|
||||
|
||||
@@ -62,8 +62,7 @@ Container, Einstellungen, Adresse, Seitenvorlage) und `plugins/nachtblau` (nur e
|
||||
},
|
||||
"routes": {
|
||||
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
|
||||
},
|
||||
"theme": { "colors": [ ], "fonts": [ ], "base": { }, "buttons": { } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -73,8 +72,8 @@ Container, Einstellungen, Adresse, Seitenvorlage) und `plugins/nachtblau` (nur e
|
||||
Browser nach einem Update die neue Fassung holt.
|
||||
- `requires` (optional): die kleinste KantSites-Fassung. Passt sie nicht, erscheint das Plugin in
|
||||
der Verwaltung mit dem Grund und lässt sich nicht einschalten.
|
||||
- Ein Manifest braucht mindestens einen Baustein, eine Adresse, eine Vorlage oder ein
|
||||
Designpaket. Fehlt eine genannte Datei, ist das Manifest ungültig.
|
||||
- Ein Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage. Fehlt eine
|
||||
genannte Datei, ist das Manifest ungültig.
|
||||
|
||||
### Eigenschaften und Einstellungen (Schema)
|
||||
|
||||
@@ -115,11 +114,12 @@ was der Kern beantwortet (`/admin`, `/media`, `/assets`, `/formular`, `/beitraeg
|
||||
`html`, `json`, `text` oder `xml`. Eine veröffentlichte Seite mit derselben Adresse geht vor.
|
||||
Ausgeschaltet antwortet die Adresse mit 404; alles außer `html` trägt `noindex`.
|
||||
|
||||
### Designpaket (`theme`)
|
||||
### Was ein Plugin nicht kann: das Design
|
||||
|
||||
`colors`, `fonts`, `base` und optional `buttons` im Format des Designbereichs (siehe die
|
||||
Aufbauskripte unter `scripts/` oder `plugins/nachtblau`). „Designpaket übernehmen“ ersetzt die
|
||||
Palette der Website – dieselbe Prüfung wie beim Speichern unter Design, die Inhalte bleiben.
|
||||
Es gibt kein `theme` im Manifest. Ein Plugin gestaltet seine eigenen Bausteine über deren
|
||||
Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der Website ändert
|
||||
nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort
|
||||
eingestellt wurde, geht durch ein Plugin verloren (ADR 105).
|
||||
|
||||
## Die Vorlagen
|
||||
|
||||
|
||||
+135
@@ -1,5 +1,140 @@
|
||||
# Projektstatus
|
||||
|
||||
## 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.
|
||||
|
||||
## Favicon auch für die Google-Suche (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: Google zeigt bei Elektro Bernhard weiter das KantSites-K, obwohl ein eigenes Symbol gesetzt ist – „mach das im Kern sobald man Favicon setzt alles ersetzen“.
|
||||
|
||||
- **Ursache:** Das eigene Symbol gab es nur in 32 und 180 px, Google nimmt nur Vielfache von 48 px. Und `/favicon.svg` lag als Datei in `public/`, der Webserver lieferte also immer das K aus – dort hatte Google es beim ersten Besuch gefunden.
|
||||
- **Neu:** `FAVICON_SIZES` sind 32, 48, 180 und 192. Der Kopf verweist zusätzlich auf `/favicon-192.png` (`sizes="192x192"`). `/favicon.ico` liefert das 48er-PNG. `/favicon.svg` beantwortet jetzt die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen (liegt jetzt unter `public/assets/favicon.svg`), mit eigenem ein 302 auf `/favicon-192.png`. Fehlen bei älteren Installationen die neuen Größen, entstehen sie beim ersten Abruf aus der gespeicherten Einstellung. Wird das Symbol entfernt, gelten die liegengebliebenen PNGs nicht mehr.
|
||||
- **Update:** Das Archiv-Update kopiert `public/` nur hinzu; `ENTFALLEN_IN_PUBLIC` löscht deshalb die alte `public/favicon.svg`, sonst bliebe sie auf dem Server und überdeckte die Route.
|
||||
- **Danach bei Google:** In der Search Console die Startseite unter „URL prüfen“ neu zur Indexierung einreichen. Google holt Favicons selten neu ab, das kann trotzdem Tage bis Wochen dauern.
|
||||
|
||||
**Geprüft:** neuer Test `testGoogleGetsTheOwnSiteIconUnderEveryAddress` und der bisherige Favicon-Test grün, Update-Tests unverändert (eine vorbestehende Warnung), php-cs-fixer sauber; lokal (ohne eigenes Symbol) `/favicon.svg` 200, `/favicon.ico` → `/favicon.svg`.
|
||||
|
||||
## Favicon mit Zuschnitt, Pflichtseiten per Suche, neueste zuerst (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Was ein Dropdown? nicht dein ernst? Sofort fixen über Mediathek auswählbar machen zurecht schneidbar machen und auch uploadbar dort machen!“ – dazu „Genauso Adresse Datenschutz und Impressum über search felder auswählbar machen“ und „Beiträge und Seiten sortieren absteigend also neueste zuerst“.
|
||||
|
||||
- **Symbol der Website** hat einen eigenen Abschnitt in den Einstellungen statt einer Auswahlliste am Ende der Unternehmensdaten. Der Knopf öffnet die Mediathek (dort auch Upload), danach folgt ein Zuschnitt: Quadrat ziehen, Größe an der Ecke, per Regler oder mit +/−, Pfeiltasten zum Verschieben, Vorschau in 180 und 32 px. Gespeichert wird `faviconCrop` als `links,oben,kante` in Anteilen (links/oben von Breite/Höhe, Kante von der kürzeren Seite), `renderFavicon()` schneidet daraus die PNGs. `faviconVersion` hängt am Verweis; ohne Zuschnitt bleibt es bei den ersten acht Zeichen der Bildkennung, bestehende Verweise ändern sich also nicht.
|
||||
- `openMedia()` kennt `needsAlt: false`: Für das Symbol wird kein Alternativtext verlangt. Die Stile des Mediathek-Dialogs stehen jetzt in `media-dialog.css` statt in `editor.css`, damit der Dialog auch außerhalb des Editors funktioniert.
|
||||
- **Datenschutz und Impressum** werden über die Inhaltssuche gewählt wie die 404-Seite. Die Suche kann jetzt statt der Kennung den Pfad speichern (`valueKey: 'path'` bzw. `data-value="path"`), gespeichert wird also weiter die Adresse.
|
||||
- **Seiten- und Beitragsliste** der Verwaltung sortieren nach Anlagedatum absteigend. Die Einrückung der Unterseiten ist dort entfallen – ohne Baumreihenfolge hätte sie eine falsche Elternseite angedeutet.
|
||||
|
||||
## Sechste Installation: der Redesign-Entwurf „EB Solar“ (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „bitte redesign aufsetzen ebsolar.de achte auf die richtlinien wie immer machs wie die anderen tagespflege, elektro-baernhard co“ – dazu das Paket `ebsolar-redesign.zip` (statische Website: Startseite, Projektübersicht, acht Projektseiten, eigene SVG-Symbole, Kontaktformular per `mailto`).
|
||||
|
||||
**Neu.** Neben Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Maler Drews und DEEG steht jetzt **EB Solar** (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden): Datenbank `var/ebsolar.db`, Medien `var/ebsolar-media`, Bildquellen `var/ebsolar-assets` (Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos, `projects.json`), Startkonfigurationen `ebsolar` (Port 8096, dev) und `ebsolar-prod` (Port 8097, `APP_ENV=prod` zum Messen). `scripts/seed-ebsolar.php` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als **wiederverwendbare Sektion**, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge, Menü und zwei Formulare; `sh scripts/reset-ebsolar.sh` wirft weg, migriert und baut neu. Schrift ist Arial (System) wie in der Vorlage, es gibt nichts zu holen.
|
||||
|
||||
**Aus der Vorlage übernommen:** Farben (`#b52c2c` Rot, `#252b30` Tinte, `#f5f6f6` helle Fläche, `#e6e8e8` Linie), Schriftgrößen, Abstände, Wortlaut und die Reihenfolge der Abschnitte – Hero mit Bildunterschrift über dem Foto, die vier Zusicherungen zwischen zwei Linien, drei Schritte, „Gutes Handwerk. Neue Energie.“ auf heller Fläche, Projektraster, Herstellerleiste, „Was kann Ihr Dach?“.
|
||||
|
||||
**Bewusste Abweichungen.**
|
||||
- Das Kontaktformular der Vorlage öffnet nur das E-Mail-Programm des Besuchers. Hier ist es das serverseitige Formular `kontakt`; die Anfrage liegt in der Verwaltung und geht an pv@elektro-bernhard.de.
|
||||
- Der **Solarcheck** ist im Paket nur ein Verweis auf ebsolar.de. Hier ist er eine eigene Seite mit dem Formular `solarcheck`: dieselben acht Schritte und dieselben Fragen wie im Original (abgerufen 16.09.2026), nur der Schieberegler für den Jahresverbrauch ist ein Zahlenfeld – ein Schieberegler ist im Formularbaustein nicht vorgesehen.
|
||||
- Die acht Projektseiten sind **Beiträge** der Kategorie „Projekte“ mit Galerie und Bildbetrachter; `/projekte` ist eine Beitragsliste. Die Projektart („Photovoltaik“, „Terrassenmontage · SolarEdge“) steht als **Schlagwort** am Beitrag und nicht als Kategorie: „vorheriges/nächstes Projekt“ folgt der ersten Kategorie, und die muss bei allen acht dieselbe sein, sonst führt die Navigation an Rheinstetten vorbei. Die Beitragsliste zeigt nur Kategorien, deshalb tragen die Karten anders als in der Vorlage keine Artzeile.
|
||||
- Impressum und Datenschutz sind eigene Seiten (Angaben vom Impressum der laufenden Website); was nur Elektro Bernhard beantworten kann, steht als „NOCH ZU PRÜFEN“ im Text.
|
||||
- Kein Cookie-Banner: es wird nichts von Dritten geladen.
|
||||
|
||||
**Bildkarten im Solarcheck (17.09.2026).** Umut: „du kannst so schöne svg's erstellen kannst du bitte noch schöne icons für den solarcheck machen statt checkboxe schöne bilder?“ Die fünf Auswahlfragen (Personen, Dachform, Dachfläche, Eindeckung, Speicher) zeigen jetzt Bildkarten statt Optionsfelder – 20 Symbole in Tinte und Rot auf heller Fläche, im Stil der übrigen Symbole der Website. `scripts/build-ebsolar-options.mjs` zeichnet sie als SVG und rastert sie mit Chromium auf 800×600 (4:3 wie die Karte), weil die Mediathek kein SVG nimmt; SVG und PNG liegen in `var/ebsolar-assets/options/`, das Aufbauskript hängt die PNGs per `images` an die Felder. Lighthouse `/solarcheck` danach: Leistung 98/98, Barrierefreiheit, Best Practices, SEO 100.
|
||||
|
||||
**Im Kern behoben: Pfeile waren Schriftzeichen.** Der Button haengte ein `↗` an, die Beitragsnavigation ein `←`/`→` – Buchstaben also, die je nach Schrift anders aussehen und mit der Schriftstaerke nicht mitwachsen. Der Symbolsatz (`templates/blocks/icon.html.twig`, `DocumentValidator::ICONS`) kennt jetzt drei Pfeile: `arrow-right`, `arrow-left`, `arrow-up-right`, gezeichnet wie die uebrigen 17 Symbole. Button und Beitragsnavigation setzen sie als eingebettetes SVG in denselben `<span aria-hidden>` wie bisher – `& span{display:none}` nimmt den Pfeil also weiterhin weg, wo eine Vorlage keinen will (DEEG). Die Groesse haengt an der Schrift des Knopfs (`.button-arrow>svg{width:1.15em}`), nicht an einer festen Zahl. Auf EB Solar tragen auch die Textlinks ihren Pfeil als Symbolbaustein neben dem Text; er zeigt auf dieselbe Adresse und ist als Schmuck von Vorlesesoftware und Tastatur ausgenommen.
|
||||
|
||||
**Im Kern behoben: zweimal derselbe Selektor in eigenem CSS loeschte die erste Regel.** `CustomCode::parse()` legte die Deklarationen je Selektor in einem Array ab – `$rules[$suffix] = …`. Standen `& .post-card{border:0}` und `& .post-card{transform:none}` untereinander, gewann die zweite, und `border:0` verschwand ohne Meldung; im Projektraster von EB Solar kam dadurch der 1-px-Rahmen des Kerns zurueck. In CSS ist zweimal derselbe Selektor eine Kaskade, kein Widerruf: die Deklarationen werden jetzt in der Reihenfolge zusammengefuegt, in der sie standen. Dasselbe galt fuer `&{…}` neben den Eigenschaften des Elements selbst – auch das ging vorher verloren.
|
||||
|
||||
**Im Kern behoben: das vorgezogene Bild wurde nicht vorab geholt.** Für ein Hintergrundbild des ersten Abschnitts schreibt `page.html.twig` längst ein `<link rel="preload" as="image">` (`heroBackground`). Für ein gewöhnliches `<img>` über der Falz fehlte das Gegenstück – der Browser fand es erst, wenn das Stylesheet geparst war, und diese Rundreise ging auf die Startseite von EB Solar (deren Startbild ein Bild und kein Hintergrund ist) direkt in die LCP-Zeit. Neu liefert `PageController::heroImage()` Quelle, Variantensatz und Größenangabe genau so, wie `blocks/block.html.twig` sie an das `img` schreibt (sonst lädt der Browser zweimal), und die Vorlage schreibt daraus die Vorabladung – aber nur, wenn nicht ohnehin schon ein Hintergrundbild vorgeholt wird.
|
||||
|
||||
**Und eine Regel fürs Aufbauen:** eine Einblendung auf dem größten Element über der Falz kostet fast eine Sekunde LCP (gemessen: 916 ms „element render delay“, mobil 92 statt 99). Das Startbild blendet deshalb nicht ein; Kleinzeile, Überschrift, Text und Knöpfe daneben schon.
|
||||
|
||||
**Geprüft:** `npm run test:links` gegen Port 8096: 17 Adressen in der Sitemap, 48 Seiten, 127 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur und ohne JavaScript bestanden, **keine redaktionellen Befunde**; Ladebudget der Startseite 14 Anfragen, 17 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8097 für `/`, `/projekte`, `/solarcheck`, `/michelbach`, `/rheinstetten`, `/impressum`, `/datenschutz`: **Barrierefreiheit, Best Practices und SEO überall 100**, Leistung 99–100, agentisches Browsing überall 100 außer `/solarcheck` mobil (98). Startseite LCP 2,1 s mobil / 0,8 s Desktop, CLS 0, TBT 0 ms; Berichte in `reports/lighthouse-ebsolar/`. Bildbetrachter der Galerie geöffnet und geblättert („1 von 4“), Beitragsnavigation stimmt mit der Reihenfolge der Vorlage überein (Michelbach ← Rheinstetten, → Au am Rhein), 404 und Solarcheck im Browser angesehen, Desktop und 375 px. PHP-Tests **581 grün** (PHP 8.4), php-cs-fixer und Twig-Lint ohne Befund.
|
||||
|
||||
**Stolperstein beim Messen:** Im Produktionsmodus liegen die uebersetzten Twig-Vorlagen in `var/cache/prod`. Nach einer Aenderung an `blocks/icon.html.twig` warf jede Seite mit Pfeil dort **500** („Key ‚arrow-right‘ … does not exist“), waehrend der Entwicklungsserver auf 8096 laengst richtig lieferte. `APP_ENV=prod … bin/console cache:clear` raeumt das auf; wer im Produktionsmodus misst, muss das nach jeder Vorlagenaenderung tun.
|
||||
|
||||
**Bekannt:** `/solarcheck` mobil hat CLS 0,08 (agentisches Browsing 98). Das Formularskript blendet nach dem Laden alle Schritte bis auf den ersten aus – ohne Skript stehen sie untereinander, mit Skript springt die Seite einmal. Der Wert liegt unter dem Ziel von 0,1, aber die Bewegung ist sichtbar; sie gehört in den Kern (Schritte serverseitig zusammenfalten), nicht ins Aufbauskript.
|
||||
|
||||
|
||||
## Startseite nach der Installation: eine Baustellenseite statt einer Anleitung (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du bitte für die Erstseite bzw. nach der Installation eine schöne Erstseite erstellen – Under Construction oder so, ohne Details mit ‚melde dich an‘ usw. Am besten auch noch Logo von KantSites: ‚Hier entsteht eine neue Webpräsenz mit KantSites‘.“
|
||||
|
||||
**Was los war.** `DemoContent::starterPage()` lieferte zwei Abschnitte mit Arbeitsanweisungen an die Betreiberin oder den Betreiber: „Melde dich an, öffne sie im Editor …“ und „Die nächsten Schritte“ mit Design, Menüs und Einstellungen. Das stand auf der einzigen Seite, die bis zum ersten Bearbeiten **öffentlich** erreichbar ist – gelesen hat es also der Besucher, nicht der Betreiber. Für den stehen dieselben Schritte längst auf dem letzten Installerbildschirm (`templates/install/done.html.twig`) und in der Verwaltung.
|
||||
|
||||
**Neu.** Eine Baustellenseite, die genau das sagt und sonst nichts: das KantSites-Zeichen (`public/assets/kantsites-logo.svg`, dieselbe Form wie das Favicon), die Kleinzeile „In Arbeit“ in der Akzentfarbe, als einzige H1 der Name der Website, darunter „Hier entsteht eine neue Webpräsenz.“, ein kurzer Trennstrich und die Zeile „Erstellt mit KantSites“. Der Abschnitt ist ein Flex-Container mit `min-height: 70vh` und einem Verlauf von `@paper` nach `@mint`, stellt den Inhalt also mittig auf den Bildschirm; die Farben sind Palettenverweise und folgen jedem Designwechsel. Alles sind gewöhnliche Bausteine mit gewöhnlichen Stilwerten – im Editor also vollständig änder- und löschbar. Die Seitenbeschreibung ist jetzt ein echter Satz statt „NOCH AUSZUFÜLLEN“, damit eine frisch installierte Website in Suchergebnissen nicht mit einem Platzhalter steht.
|
||||
|
||||
**Unverändert:** Kopf- und Fußbereich, die beiden Rechtsseiten als Gerüst mit markierten Pflichtangaben, Menüs.
|
||||
|
||||
**Geprüft:** `tests/InstallFlowTest.php` (Installation über die sechs Bildschirme, danach liefert `/` die neue Seite), `php vendor/bin/phpunit` 579 von 579. Im Browser angesehen: eine frisch migrierte Datenbank mit dem Inhalt einer Neuinstallation, Desktop und 375 px.
|
||||
|
||||
## Fassung 1.21.0: Bildecken gehören dem Designbereich (16.09.2026)
|
||||
|
||||
**Anlass:** Umut zum Hero-Bild von DEEG: „Das sollte abgerundet sein, aber wurde von irgendwo gemacht, und die Ecken sehen komisch aus. Es gibt auch im Editor gar nicht Bildecken abrunden, oder?“
|
||||
|
||||
**Was los war.** `.block-image{border-radius:12px}` stand fest in `enhancements.css` – jedes Bild jeder Installation war gerundet, ohne dass ein Feld das gesagt hätte. Auf DEEG (eine durchgehend eckige Vorlage) fiel es auf, weil über dem gerundeten Bild eckige Elemente liegen: der dunkle Verlauf, das Schild, die blaue Aufruf-Fläche. An den Ecken schaute deshalb die eckige Fläche aus der Rundung heraus.
|
||||
|
||||
**Behoben.** Die Ecken sind jetzt eine Einstellung unter **Design → Bildecken** (`theme.base.imageRadius`, geprüft wie die Inhaltsbreite, ausgegeben als `--k-image-radius`). Vorgabe bleibt `12px`, damit bestehende Websites gleich aussehen; `0px` macht alle Bilder eckig. Für ein einzelnes Bild gab es die Einstellung schon immer – im Editor unter **Design → Bild → Bildecken** (`imageRadius` im Stilschema, für Bild, Galerie, Logoleiste und Beitragsliste); sie überschreibt den Wert der Website.
|
||||
|
||||
**DEEG:** `imageRadius` auf `0px`, im Aufbauskript und in der Installation. Das Hero-Bild ist damit eckig wie die Vorlage, und die Ecken stimmen wieder.
|
||||
|
||||
**Geprüft:** neuer Test in `tests/CmsTest.php`: Vorgabe ist `12px`, eine Seite mit Bild trägt `--k-image-radius:12px`, nach dem Speichern von `0px` trägt sie `--k-image-radius:0px`, und „rund“ wird abgewiesen wie jede andere Nicht-Größenangabe.
|
||||
|
||||
## Fassung 1.20.1: das kaufmännische Und, das bei jedem Speichern wuchs (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Beim Startseitenbild wird der Text & umgewandelt, aber in der Ansicht nach dem Speichern wird & daraus.“
|
||||
|
||||
**Ursache.** `RichText::sanitise()` hat Text ohne ein einziges Tag als reinen Text behandelt und nur escaped. Was der Editor schickt, ist aber HTML – auch ohne Tag. Aus `Freudenstadt & Umgebung` wurde so bei **jedem** Speichern eine Stufe mehr: `&amp;`, `&amp;amp;` … bis die Entity sichtbar auf der Seite stand. Auf kant-it.de war der Entwurf der Startseite schon bei drei Stufen. Derselbe Zweig hatte einen zweiten Fehler gleicher Art: `nl2br()` ließ den Zeilenumbruch hinter dem `<br>` stehen, und der nächste Durchlauf machte über `breakLines()` ein zweites `<br>` daraus – aus einem Absatz wurden zwei.
|
||||
|
||||
**Behoben.** Der Zweig dekodiert jetzt zuerst (`html_entity_decode`) und escaped danach, und er ersetzt den Umbruch, statt ihn zu ergänzen. Damit endet er bei derselben Darstellung wie der DOM-Zweig, und beide sind unveränderlich gegenüber wiederholtem Speichern.
|
||||
|
||||
**Bestand.** Der Konsolenbefehl `bin/console cms:repair:entities` räumt auf, was schon gespeichert ist: veröffentlichte Fassung und Entwurf jeder Seite sowie die Bereiche, mit Trockenlauf und `--schreiben`, jede Änderung gegen den Validator geprüft (Seiten wie beim Speichern, Bereiche ohne Überschriftspflicht). Als Befehl und nicht als Skript unter `scripts/`, weil dieser Ordner nicht ins Releasearchiv geht – eine Installation ohne Git hätte das Skript sonst nie gesehen. Ältere Fassungen bleiben Historie. Auf DEEG angewandt (eine Stelle im Entwurf der Startseite). **Offen: kant-it.de** – dort stehen zwei dreifach kodierte Stellen im Entwurf der Startseite; das Skript gehört nach dem Update dort einmal ausgeführt, sonst steht der Murks beim nächsten Veröffentlichen live.
|
||||
|
||||
**Geprüft:** `tests/RepairEntitiesCommandTest.php` (Trockenlauf ändert nichts; Schreiben entschachtelt drei Stufen und lässt ein richtiges `&` stehen; ein zweiter Lauf findet nichts mehr) und ein neuer Test in `tests/CmsTest.php`: sechs Eingaben (Entity, nacktes Und, `<`, Zeilenumbruch, Markup, wörtliche Tags) dreimal hintereinander durch `sanitise()` – jedes Mal dasselbe Ergebnis; dazu eine Seite, die dreimal gespeichert und veröffentlicht wird und danach immer noch ein einziges `&` trägt.
|
||||
|
||||
**Nebenbei (DEEG):** Das Favicon zeigte den mittigen Ausschnitt des Logos, also „EE“ aus dem Schriftzug. Neu ist ein eigenes quadratisches Zeichen: der Dachgiebel aus dem Logo, weiß auf Logo-Blau, gerendert aus `logo-deeg.png` von `scripts/set-immobilien-deeg-favicon.php`. Vier Entwürfe standen zur Wahl (Doppeldach, Giebel, Dach blau auf weiß, Buchstabe D), Umut hat den Giebel gewählt.
|
||||
|
||||
## Baustein „Eigenes HTML“, DEEG-Angebotsseite (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann … z. B. eine neue Angebotsseite bei DEEG, da soll ein iframe rein, im gleichen Stil wie das Impressum.“
|
||||
|
||||
**Gebaut.** Neuer Kernbaustein `html` („Eigenes HTML“, Dokumentschema **v24**): eine Eigenschaft, das Markup bis 20.000 Zeichen, unverändert ausgegeben. `CustomCode::checkHtml()` weist `<script>` und `<iframe>` mit dem Weg zum richtigen Ort ab; `CustomCode::assertHtmlUnchanged()` hält Änderungen auf die Administratorrolle fest – in `EditorController` (Speichern und Zurückholen einer Fassung) und in `PartialController`, wie beim eigenen Skript. Im Editor: Eintrag in der Gruppe „Seite und Daten“, mehrzeiliges Feld, schreibgeschützt ohne Administratorrolle. In `site.css` nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt.
|
||||
|
||||
**Nicht gebaut, weil vorhanden:** der iframe. Der Baustein „Externer Inhalt“ (v14) macht das seit Langem – mit Einwilligung davor und dem Host in `frame-src`. Begründung in ADR 106.
|
||||
|
||||
**DEEG.** Die Seite `/angebote` gab es schon (im Editor angelegt, Starterabschnitt plus Rahmen mit Platzhaltertexten „test“). Sie ist jetzt im Stil der Rechtsseiten aufgebaut: Kleinzeile, große leichte Überschrift „Unsere Angebote“, Vorspann auf heller Fläche, darunter die IS24-Ergebnisliste (Portal 16200281, Höhe 1000) und eine Hinweiszeile zum Portal und zum Kontakt. „Immobilien“ in der Navigation und der Knopf im Angebote-Band der Startseite zeigen auf `/angebote` statt auf die alte Website. Aufbau per Skript: `scripts/add-immobilien-deeg-angebote.php`.
|
||||
|
||||
**Einwilligung und Gestaltung (DEEG).** Die Gruppe „Externe Inhalte“ ist eingeschaltet (`consentBanner`, `consentGroups`), der Balken trägt die Farben der Website (eckig, Logo-Blau als Akzent, Papier als Fläche). Vor der Zustimmung steht der Platzhalter, und der trägt über eigenes CSS am Block denselben Zuschnitt wie die Vorlage: eckiger Rahmen auf heller Fläche, linksbündig, blauer Knopf. **Gestaltbar ist der Rahmen, nicht sein Inhalt** – Abstände, Breite, Rand, Ecken und Höhe gehören dieser Website, die Darstellung der Liste gehört ImmobilienScout24; an fremdes Dokument im iframe kommt kein CSS heran, das trennt der Browser. Im Browser geprüft: Platzhalter, Zustimmung, geladene Liste.
|
||||
|
||||
**Nicht mehr offen:** Die Einwilligungsfrage war auf dieser Installation aus (`consentBanner` false, keine Gruppen). Das ist erledigt: Umut hat die Einwilligung eingeschaltet, ein fremder Rahmen lädt jetzt erst nach der Zustimmung.
|
||||
|
||||
**Geprüft:** `phpunit` 574 Tests grün, `npm test` 35 (neu: der Baustein startet leer, steht im Werkzeugkasten, hebt das Dokument auf v24). `npm run test:editor` um den ganzen Weg erweitert und bestanden: Baustein aus dem Werkzeugkasten einfügen, Markup eintippen, in der Vorschau sehen, veröffentlichen, auf der Website Zeichen für Zeichen wiederfinden – und `<script>` wie `<iframe>` werden mit ihrer Meldung abgewiesen (422, Meldung in der Leiste über dem Editor), ohne die veröffentlichte Seite anzufassen. Zwei neue PHP-Tests: Markup erscheint unverändert auf der veröffentlichten Seite und `<script>`, `<iframe>` sowie Schema v23 werden abgewiesen; HTML-Änderungen werden gegen den gespeicherten Stand geprüft.
|
||||
|
||||
## Fassung 1.19.0 veröffentlicht (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Ja committe das bitte und push mit neuem Release.“ Die Rücknahme der Designpakete geht als 1.19.0 heraus.
|
||||
|
||||
**Dabei aufgefallen:** Die Commits `c8a8fa7` und `53fb713` (v1.18.0) hatten den Arbeitsstand dieser Sitzung schon mitgenommen – in v1.18.0 fehlen `applyTheme()` und `plugins/nachtblau` bereits, während die mitgelieferte Dokumentation Designpakete noch beschreibt. 1.19.0 bringt beides wieder zur Deckung. Der Code ändert sich zwischen 1.18.0 und 1.19.0 nur um die Rollenmatrix des Tests.
|
||||
|
||||
**Geprüft:** `phpunit` 572 Tests in einem Prozess grün (PHP 8.4), `npm test` 34, Twig-Lint 53 Dateien, php-cs-fixer ohne Befund, `npm run test:editor` bestanden (Bildschirm „Plugins“ ohne Designpaket-Knopf, Barrierefreiheitsprüfungen durch). DEEG-Startseite im Browser gesichtet: eigene Palette wieder da. Danach Commit, Tag `v1.19.0`, Push nach `origin` und Releasearchiv.
|
||||
|
||||
## Designpakete in Plugins entfernt (16.09.2026)
|
||||
|
||||
**Anlass:** Umut auf der DEEG-Installation: „Ich habe das Designplugin aktiviert und nach dem Deaktivieren blieben die Farben.“ Nicht der Schalter war schuld, sondern „Designpaket übernehmen“ – der Knopf schreibt die Palette des Plugins endgültig in die Website. Entscheidung: die Funktion fällt weg, statt sie abzusichern (ADR 105).
|
||||
|
||||
**Gemacht.** DEEG-Design aus `scripts/seed-immobilien-deeg.php` wieder eingespielt (Logo-Blau, Anthrazit, Arial, eckige Buttons; Inhalte und Stilvorlagen waren nie betroffen). Aus dem Kern entfernt: `PluginRegistry::themePackage()` und `applyTheme()`, `theme` im Manifest und in der Pluginstruktur, Route `admin_plugin_theme` samt `Theme`-Abhängigkeit von `PluginAdminController`, der Knopf und die Designpaket-Zeile auf dem Bildschirm „Plugins“, der Ordner `plugins/nachtblau`. Ein Manifest braucht jetzt mindestens einen Baustein, eine Adresse oder eine Vorlage.
|
||||
|
||||
**Geprüft:** `tests/PluginTest.php` hat statt des Designpaket-Tests jetzt „Ein- und Ausschalten lässt das Design in Ruhe“ (Palette vorher und nachher gleich, Dokument unverändert); 8 Tests grün.
|
||||
|
||||
## Fassung 1.18.0 veröffentlicht (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bau eine neue Version bitte, Commit, Push und Release.“ Der Stand aus DEEG-Nachbau, Formularschritten, Pluginsystem und den Rückständen der Gesamtpruefung geht als 1.18.0 heraus.
|
||||
|
||||
+18
-2
@@ -751,9 +751,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 }
|
||||
|
||||
@@ -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 };
|
||||
@@ -160,6 +162,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 +313,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 23;
|
||||
export const SCHEMA_VERSION = 24;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
|
||||
@@ -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%}
|
||||
|
||||
@@ -460,6 +460,29 @@ function renderContent(block, panel) {
|
||||
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
|
||||
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
|
||||
}
|
||||
if (block.type === 'html') {
|
||||
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
|
||||
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
|
||||
const label = document.createElement('label'); label.htmlFor = 'own-html'; label.textContent = 'HTML';
|
||||
const input = document.createElement('textarea');
|
||||
input.id = 'own-html'; input.rows = 10; input.spellcheck = false; input.className = 'own-code';
|
||||
input.value = block.props?.html ?? '';
|
||||
input.readOnly = !mayScript;
|
||||
input.addEventListener('change', () => {
|
||||
history.change(doc => {
|
||||
upgradeDocument(doc, 24);
|
||||
const current = locate(doc.blocks, block.id)?.block;
|
||||
if (!current) return;
|
||||
current.props = { ...current.props, html: input.value };
|
||||
});
|
||||
changed();
|
||||
});
|
||||
const note = document.createElement('p'); note.className = 'field-help';
|
||||
note.innerHTML = mayScript
|
||||
? 'Markup, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. <strong><script></strong> gehört unter „Erweitert“ in das Feld „Eigenes Skript“, ein <strong><iframe></strong> in den Baustein „Externer Inhalt“ – nur der fragt vorher um Einwilligung. Die Vorschau zeigt das HTML so, wie die Seite es zeigt.'
|
||||
: 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.';
|
||||
panel.append(label, input, note);
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
|
||||
@@ -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.
@@ -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 |
@@ -0,0 +1,30 @@
|
||||
/* 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}}
|
||||
@@ -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,11 +129,11 @@ 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+30
-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-arrow,.nav-arrow{display:inline-flex;align-items:center}
|
||||
.button-arrow>svg,.nav-arrow>svg{width:1.15em;height:1.15em}
|
||||
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
||||
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
|
||||
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
|
||||
@@ -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
|
||||
@@ -433,9 +443,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,9 +482,13 @@ body.consent-open{padding-bottom:320px}
|
||||
weder faerben noch abschalten. Linie und Schatten kommen dann aus Rahmen und Wirkung. */
|
||||
.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}
|
||||
.site-header .menu-burger[open]>summary{border-radius:8px}
|
||||
/* Die Flaeche malt ihren eigenen Untergrund, also setzt sie auch ihre eigene Schriftfarbe.
|
||||
Ohne das erbte sie die Farbe des Kopfbereichs: bei einem Kopf, der ueber einem dunklen Bild
|
||||
schwebt und deshalb weiss schreibt, stand das aufgeklappte Menue weiss auf weiss. */
|
||||
.site-header .menu-burger-body{position:absolute;left:0;right:0;top:100%;z-index:30;
|
||||
padding:15px max(16px,calc((100% - var(--k-container,1180px)) / 2 + 16px)) 25px;
|
||||
background:var(--paper);box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
|
||||
background:var(--paper);color:var(--ink);
|
||||
box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
|
||||
.site-header .menu-burger-body .menu-list{gap:0}
|
||||
.site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px}
|
||||
/* Visibility never overwrites the block's grid/flex/display settings. */
|
||||
@@ -478,6 +497,13 @@ body.consent-open{padding-bottom:320px}
|
||||
@media (min-width:1025px){.visibility-tablet,.visibility-mobile,.visibility-hide-desktop{display:none!important}}
|
||||
@media (min-width:768px) and (max-width:1024px){.visibility-desktop,.visibility-mobile,.visibility-hide-tablet{display:none!important}}
|
||||
@media (max-width:767px){.visibility-desktop,.visibility-tablet,.visibility-hide-mobile{display:none!important}}
|
||||
/* Das Sprungziel des Knopfs „nach oben“, ein leerer <span> am Anfang von <body>.
|
||||
WebKit springt nicht zu einem Ziel, dessen Box inline ist und keine Flaeche hat - der Tipp
|
||||
kam an, #seitenanfang stand in der Adresse, und die Seite blieb stehen. Auf dem iPhone,
|
||||
und ebenso in Safari auf dem Mac; Chromium ist toleranter, deshalb fiel es am Schreibtisch
|
||||
nie auf. Ein leerer Block ohne Hoehe reicht WebKit, es ist die Inline-Box, die stoert.
|
||||
Nachgestellt in Playwright-WebKit mit iPhone-Profil: inline bleibt bei 1500, block springt. */
|
||||
#seitenanfang{display:block}
|
||||
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
|
||||
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
|
||||
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Zeichnet die Bildkarten des Solarchecks von EB Solar und rastert sie zu PNG.
|
||||
*
|
||||
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
|
||||
* Mediathek hat - und die Mediathek nimmt JPEG, PNG und WebP, kein SVG. Deshalb entstehen die
|
||||
* Symbole hier als SVG (leicht zu ändern, alles in einer Datei) und werden mit Chromium auf
|
||||
* 800 × 600 gerastert, dem Seitenverhältnis 4:3 der Karte. Stil wie die übrigen Symbole der
|
||||
* Website: dünne Linie in Tinte, runde Enden, ein roter Akzent, helle Fläche dahinter.
|
||||
*
|
||||
* node scripts/build-ebsolar-options.mjs → var/ebsolar-assets/options/*.png und *.svg
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ziel = process.argv[2] || 'var/ebsolar-assets/options';
|
||||
const TINTE = '#252b30', ROT = '#b52c2c', HELL = '#f5f6f6', PAPIER = '#ffffff', GRAU = '#c9ced0';
|
||||
|
||||
/** Ein Bild: 800 × 600, helle Fläche, Inhalt in der Mitte. */
|
||||
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
|
||||
<rect width="800" height="600" fill="${HELL}"/>
|
||||
<g fill="none" stroke="${TINTE}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
|
||||
</svg>`;
|
||||
|
||||
/** Eine Person: Kopf und Schultern. */
|
||||
const person = (x, y, s = 1, farbe = TINTE) => `
|
||||
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
|
||||
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
|
||||
|
||||
/** Ein Haus: Wände plus Dach nach Angabe; das Dach ist der Akzent. */
|
||||
const haus = (dach) => `
|
||||
<path d="M190 520V300M610 520V300M150 520h500"/>
|
||||
<rect x="360" y="410" width="80" height="110" rx="6"/>
|
||||
${dach}`;
|
||||
|
||||
/** Ein Dach als schräge Fläche mit Modulen im Raster. */
|
||||
const module = (spalten, zeilen) => {
|
||||
const x0 = 160, y0 = 150, b = 480, h = 300, scher = 90;
|
||||
const zellen = [];
|
||||
for (let z = 0; z < zeilen; z++) {
|
||||
for (let s = 0; s < spalten; s++) {
|
||||
const bx = b / spalten, bh = h / zeilen;
|
||||
const x = x0 + s * bx + (z * bh) * (scher / h), y = y0 + z * bh;
|
||||
zellen.push(`<path d="M${x + 8} ${y + 8}h${bx - 16}l${(bh - 16) * scher / h} ${bh - 16}h-${bx - 16}z" fill="${ROT}" fill-opacity=".14" stroke="${ROT}" stroke-width="8"/>`);
|
||||
}
|
||||
}
|
||||
return `<path d="M${x0} ${y0}h${b}l${scher} ${h}h-${b}z"/>${zellen.join('')}`;
|
||||
};
|
||||
|
||||
const fragezeichen = (x, y, s = 1) => `<path d="M${x - 40 * s} ${y - 50 * s}a${40 * s} ${40 * s} 0 1 1 ${58 * s} ${36 * s}c-${14 * s} ${9 * s}-${18 * s} ${18 * s}-${18 * s} ${34 * s}" stroke="${ROT}"/><circle cx="${x}" cy="${y + 65 * s}" r="9" fill="${ROT}" stroke="none"/>`;
|
||||
|
||||
const batterie = (inhalt, farbe = TINTE) => `
|
||||
<rect x="190" y="200" width="380" height="200" rx="26" stroke="${farbe}"/>
|
||||
<path d="M570 260h30a20 20 0 0 1 20 20v40a20 20 0 0 1-20 20h-30" stroke="${farbe}"/>
|
||||
${inhalt}`;
|
||||
|
||||
const bilder = {
|
||||
// Wie viele Personen leben im Haushalt?
|
||||
'personen-1': person(400, 320, 1.3),
|
||||
'personen-2': person(300, 320, 1.15) + person(500, 320, 1.15),
|
||||
'personen-3-4': person(210, 330) + person(400, 330) + person(590, 330),
|
||||
'personen-4-plus': person(160, 330, .85) + person(320, 330, .85) + person(480, 330, .85) + person(640, 330, .85)
|
||||
+ `<path d="M715 140v70M680 175h70" stroke="${ROT}"/>`,
|
||||
|
||||
// Welche Form hat Ihr Dach?
|
||||
'dach-flach': haus(`<path d="M150 300h500" stroke="${ROT}"/><path d="M200 300v-40h400v40" stroke="${ROT}"/>`),
|
||||
'dach-sattel': haus(`<path d="M140 320 400 120l260 200" stroke="${ROT}"/>`),
|
||||
'dach-pult': haus(`<path d="M150 330 650 190" stroke="${ROT}"/><path d="M610 300v-95" />`),
|
||||
'dach-versetzt': `
|
||||
<path d="M120 520h560M160 520V370M440 520V270M660 520V330"/>
|
||||
<rect x="260" y="420" width="70" height="100" rx="6"/>
|
||||
<path d="M120 380 290 250l150 120" stroke="${ROT}"/><path d="M440 290 560 180l130 140" stroke="${ROT}"/>`,
|
||||
'dach-sonstiges': haus(`<path d="M140 320 400 120l260 200" stroke="${GRAU}"/>`) + fragezeichen(400, 250, 1.1),
|
||||
|
||||
// Wie groß ist Ihre Dachfläche?
|
||||
'flaeche-50': module(2, 1),
|
||||
'flaeche-100': module(3, 2),
|
||||
'flaeche-200': module(5, 3),
|
||||
'flaeche-200-plus': module(7, 4),
|
||||
'flaeche-unsicher': `<path d="M160 150h480l90 300H160z" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
|
||||
|
||||
// Aus welchem Material besteht die Dacheindeckung?
|
||||
'eindeckung-ziegel': [0, 1, 2, 3].map(z => [0, 1, 2, 3, 4].map(s => {
|
||||
if (z % 2 === 1 && s === 4) return '';
|
||||
const x = 150 + s * 100 + (z % 2) * 50, y = 170 + z * 80;
|
||||
return `<path d="M${x} ${y}a50 50 0 0 0 100 0" stroke="${z === 2 && s === 2 ? ROT : TINTE}"/>`;
|
||||
}).join('')).join(''),
|
||||
'eindeckung-bitumen': `
|
||||
<path d="M130 440h540v50H130z"/>
|
||||
<circle cx="250" cy="360" r="70"/><circle cx="250" cy="360" r="26"/>
|
||||
<path d="M320 400h340" stroke="${ROT}" stroke-width="22"/>
|
||||
<path d="M250 430h410" stroke="${ROT}" stroke-width="22" stroke-opacity=".35"/>`,
|
||||
'eindeckung-blech': `
|
||||
<path d="M120 340h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30"/>
|
||||
<path d="M120 420h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30" stroke="${GRAU}"/>
|
||||
<path d="M120 340v80M680 340v80" stroke="${GRAU}"/>
|
||||
<path d="M350 340l30-70h60l30 70" stroke="${ROT}"/>`,
|
||||
'eindeckung-anderes': `<rect x="200" y="150" width="400" height="300" rx="20" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
|
||||
|
||||
// Wünschen Sie einen Stromspeicher?
|
||||
'speicher-ja': batterie(`<path d="M420 230 350 310h60l-30 70 70-80h-60z" fill="${ROT}" stroke="${ROT}" stroke-width="10"/>`),
|
||||
'speicher-nein': batterie('', GRAU) + `<path d="M190 470 610 130" stroke="${ROT}"/>`,
|
||||
};
|
||||
|
||||
await mkdir(ziel, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
|
||||
for (const [name, inhalt] of Object.entries(bilder)) {
|
||||
const svg = bild(inhalt);
|
||||
await writeFile(`${ziel}/${name}.svg`, svg);
|
||||
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
|
||||
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
|
||||
console.log('gezeichnet:', name);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Schneidet aus dem gelieferten Banner (kantarci-banner.jpeg, 901 × 458) die Bilder der
|
||||
* KANTARCI-Landingpage zu: das Logo, das Hausmotiv und ein quadratisches Seitensymbol.
|
||||
* Die Mediathek nimmt kein SVG, deshalb entsteht das Symbol als PNG.
|
||||
*
|
||||
* Aufruf: php scripts/build-kantarci-assets.php <banner.jpeg> <zielordner>
|
||||
*/
|
||||
|
||||
[$_, $banner, $ziel] = $argv + [null, null, null];
|
||||
if (!is_file((string) $banner) || null === $ziel) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-kantarci-assets.php <banner.jpeg> <zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel)) {
|
||||
mkdir($ziel, 0777, true);
|
||||
}
|
||||
$quelle = imagecreatefromjpeg($banner);
|
||||
|
||||
$zuschnitt = static function (int $x, int $y, int $b, int $h, int $faktor = 1) use ($quelle) {
|
||||
$bild = imagecreatetruecolor($b * $faktor, $h * $faktor);
|
||||
imagecopyresampled($bild, $quelle, 0, 0, $x, $y, $b * $faktor, $h * $faktor, $b, $h);
|
||||
|
||||
return $bild;
|
||||
};
|
||||
|
||||
// Logo: Bildmarke und Schriftzug oben links, auf Weiß.
|
||||
imagepng($zuschnitt(22, 8, 396, 104), $ziel.'/logo-kantarci.png', 9);
|
||||
|
||||
// Hausmotiv: der Fotoanteil rechts, oberhalb des goldenen Bands.
|
||||
imagejpeg($zuschnitt(500, 0, 401, 334), $ziel.'/haus.jpg', 90);
|
||||
|
||||
// Seitensymbol: die Bildmarke mittig auf Weiß, 256 × 256.
|
||||
$symbol = imagecreatetruecolor(256, 256);
|
||||
imagefill($symbol, 0, 0, imagecolorallocate($symbol, 255, 255, 255));
|
||||
imagecopyresampled($symbol, $quelle, 28, 28, 26, 12, 200, 200, 80, 80);
|
||||
imagepng($symbol, $ziel.'/symbol-kantarci.png', 9);
|
||||
|
||||
echo "Bilder geschrieben nach $ziel\n";
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Zeichnet die Bildkarten der KANTARCI-Mietanfrage und rastert sie zu PNG.
|
||||
*
|
||||
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
|
||||
* Mediathek hat - und die Mediathek nimmt kein SVG. Deshalb entstehen die Symbole hier als SVG
|
||||
* und werden mit Chromium auf 800 × 600 (4:3 wie die Karte) gerastert. Stil der Landingpage:
|
||||
* dünne Linie in Navy, runde Enden, ein goldener Akzent, warme helle Fläche dahinter.
|
||||
*
|
||||
* node scripts/build-kantarci-options.mjs → var/kantarci-immobilien-assets/options/*.png und *.svg
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ziel = process.argv[2] || 'var/kantarci-immobilien-assets/options';
|
||||
const NAVY = '#142535', GOLD = '#b49a66', HELL = '#f5f3ee', PAPIER = '#ffffff';
|
||||
|
||||
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
|
||||
<rect width="800" height="600" fill="${HELL}"/>
|
||||
<g fill="none" stroke="${NAVY}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
|
||||
</svg>`;
|
||||
|
||||
/** Eine Person: Kopf und Schultern. */
|
||||
const person = (x, y, s = 1, farbe = NAVY) => `
|
||||
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
|
||||
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
|
||||
|
||||
/** Ein Grundriss: Außenwand, dazu Innenwände; der jeweils letzte Raum ist golden hinterlegt. */
|
||||
const grundriss = (waende, akzent) => `
|
||||
${akzent ? `<path d="${akzent}" fill="${GOLD}" fill-opacity=".22" stroke="none"/>` : ''}
|
||||
<rect x="170" y="130" width="460" height="340" rx="8"/>
|
||||
${waende}`;
|
||||
|
||||
const bilder = {
|
||||
// Wer zieht ein?
|
||||
'haushalt-allein': person(400, 330, 1.3),
|
||||
'haushalt-zu-zweit': person(275, 330, 1.1) + person(525, 330, 1.1, GOLD),
|
||||
'haushalt-familie': person(250, 320, 1.1) + person(550, 320, 1.1) + person(400, 400, .7, GOLD),
|
||||
'haushalt-wg': person(200, 340, .9) + person(400, 340, .9, GOLD) + person(600, 340, .9),
|
||||
|
||||
// Wie viele Zimmer?
|
||||
'zimmer-1': grundriss('<path d="M340 470v-50" />', 'M177 137h446v326H177z'),
|
||||
'zimmer-2': grundriss('<path d="M400 130v230M400 420v50"/>', 'M407 137h216v326H407z'),
|
||||
'zimmer-3': grundriss('<path d="M360 130v230M360 420v50M360 300h270"/>', 'M367 307h256v156H367z'),
|
||||
'zimmer-4': grundriss('<path d="M400 130v340M170 300h170M460 300h170"/>', 'M407 307h216v156H407z')
|
||||
+ `<path d="M690 110v80M650 150h80" stroke="${GOLD}"/>`,
|
||||
|
||||
// Was ist Ihnen wichtig?
|
||||
'ausstattung-balkon': `
|
||||
<path d="M250 110h300v230H250z"/><path d="M400 110v230"/>
|
||||
<path d="M170 340h460M170 340v150M630 340v150M170 490h460" stroke="${GOLD}"/>
|
||||
<path d="M245 340v150M320 340v150M400 340v150M480 340v150M555 340v150" stroke="${GOLD}" stroke-width="10"/>`,
|
||||
'ausstattung-aufzug': `
|
||||
<rect x="230" y="100" width="340" height="400" rx="12"/><path d="M400 100v400"/>
|
||||
<path d="M620 210l40-50 40 50z" fill="${GOLD}" stroke="${GOLD}"/><path d="M620 300l40 50 40-50z" fill="${GOLD}" stroke="${GOLD}"/>`,
|
||||
'ausstattung-stellplatz': `
|
||||
<rect x="230" y="110" width="340" height="380" rx="40"/>
|
||||
<path d="M340 420V180h80a60 60 0 0 1 0 120h-80" stroke="${GOLD}" stroke-width="22"/>`,
|
||||
'ausstattung-kueche': `
|
||||
<rect x="180" y="200" width="440" height="290" rx="10"/><path d="M180 270h440"/>
|
||||
<circle cx="300" cy="235" r="14" fill="${NAVY}" stroke="none"/><circle cx="360" cy="235" r="14" fill="${NAVY}" stroke="none"/>
|
||||
<rect x="250" y="320" width="300" height="120" rx="8" stroke="${GOLD}"/>
|
||||
<path d="M300 140c0-30 30-30 30-60M400 140c0-30 30-30 30-60M500 140c0-30 30-30 30-60" stroke="${GOLD}" stroke-width="10"/>`,
|
||||
'ausstattung-barrierefrei': `
|
||||
<circle cx="380" cy="130" r="36" stroke="${GOLD}"/>
|
||||
<path d="M380 180v130h120l50 110" stroke="${GOLD}"/><path d="M380 240h100"/>
|
||||
<path d="M330 260a120 120 0 1 0 170 150"/>`,
|
||||
'ausstattung-haustier': `
|
||||
<path d="M400 330c-70 0-130 70-130 120 0 40 40 50 70 40s50-10 60-10 30 0 60 10 70 0 70-40c0-50-60-120-130-120z" fill="${GOLD}" fill-opacity=".25" stroke="${GOLD}"/>
|
||||
<ellipse cx="260" cy="270" rx="42" ry="55"/><ellipse cx="350" cy="190" rx="42" ry="58"/>
|
||||
<ellipse cx="450" cy="190" rx="42" ry="58"/><ellipse cx="540" cy="270" rx="42" ry="55"/>`,
|
||||
};
|
||||
|
||||
await mkdir(ziel, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
|
||||
for (const [name, inhalt] of Object.entries(bilder)) {
|
||||
const svg = bild(inhalt);
|
||||
await writeFile(`${ziel}/${name}.svg`, svg);
|
||||
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
|
||||
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
|
||||
console.log('gezeichnet:', name);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -940,6 +940,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 +1014,10 @@ try {
|
||||
// looked at from a visitor's browser: the banner has to appear, and the embed must not load.
|
||||
await page.goto(origin + '/admin/settings');
|
||||
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check();
|
||||
await page.getByLabel('Adresse der Datenschutzseite', { exact: true }).fill('grundlagen');
|
||||
// The privacy page is chosen by search, the way the 404 page is: type, then take the hit.
|
||||
await page.getByLabel('Datenschutzseite', { exact: true }).fill('grundlagen');
|
||||
await page.locator('#privacyPath-results [role=option]').filter({ hasText: '/grundlagen' }).first().click();
|
||||
assert.equal(await page.locator('.content-picker:has(#privacyPath) .picker-value').inputValue(), 'grundlagen');
|
||||
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
||||
await page.locator('.notice.ok').waitFor();
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Wirft die EB-Solar-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/ebsolar.db"
|
||||
export MEDIA_DIRECTORY="$root/var/ebsolar-media"
|
||||
rm -f "$root/var/ebsolar.db"
|
||||
rm -rf "$root/var/ebsolar-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/ebsolar-assets}"
|
||||
# Die Vorlage nutzt Arial; es gibt keine Schrift zu holen.
|
||||
"$php" -d memory_limit=512M scripts/seed-ebsolar.php "$assets"
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Wirft die KANTARCI-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/kantarci-immobilien.db"
|
||||
export MEDIA_DIRECTORY="$root/var/kantarci-immobilien-media"
|
||||
rm -f "$root/var/kantarci-immobilien.db"
|
||||
rm -rf "$root/var/kantarci-immobilien-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/kantarci-immobilien-assets}"
|
||||
# Logo, Hausmotiv und Symbol sind Zuschnitte aus dem gelieferten Banner, die Bildkarten
|
||||
# der Mietanfrage zeichnet Chromium (Playwright).
|
||||
"$php" scripts/build-kantarci-assets.php "$assets/kantarci-banner.jpeg" "$assets"
|
||||
node scripts/build-kantarci-options.mjs "$assets/options"
|
||||
# Die Vorlage nutzt Arial und Georgia; es gibt keine Schrift zu holen.
|
||||
"$php" -d memory_limit=512M scripts/seed-kantarci-immobilien.php "$assets"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,99 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut das Favicon der DEEG-Installation aus dem Logo und setzt es.
|
||||
*
|
||||
* Bisher zeigte das Symbol auf das Logo selbst. Das Logo ist 209x100 – der Kern schneidet daraus
|
||||
* mittig ein Quadrat, und übrig blieb das „EE“ aus dem Schriftzug. Ein Favicon wird bei 16 px
|
||||
* gelesen, also braucht es ein eigenes, quadratisches Zeichen: ein Dachgiebel aus dem Logo, weiß
|
||||
* auf Logo-Blau. Das Dach ist das Markenzeichen und bleibt auch klein eine Silhouette; der
|
||||
* flächige blaue Grund trägt es auf hellen wie dunklen Browserleisten.
|
||||
*
|
||||
* Gerendert wird aus `logo-deeg.png`, damit das Symbol bei einem neuen Logo neu entsteht statt
|
||||
* als Fremdkörper in der Mediathek zu liegen. Läuft nur gegen die DEEG-Datenbank.
|
||||
*/
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
if (!str_contains($url, 'immobilien-deeg')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$quelle = $argv[1] ?? $root.'/var/immobilien-deeg-assets/logo-deeg.png';
|
||||
if (!is_file($quelle)) {
|
||||
fwrite(STDERR, "Logo nicht gefunden: $quelle\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Der linke Giebel des Doppeldachs im Logo. Die Maße stammen aus dem Bild selbst: das Dach liegt
|
||||
// über dem Schriftzug, x 3-203, y 0-40; die Hälfte davon ist ein Giebel.
|
||||
$AUSSCHNITT = ['x1' => 3, 'y1' => 0, 'x2' => 103, 'y2' => 40];
|
||||
$BLAU = [0, 129, 199]; // #0081c7, das Logo-Blau
|
||||
$KANTE = 512; // Vorlage für alle Ableitungen des Kerns (16 bis 180)
|
||||
$RAND = 0.06; // wenig Luft: klein gelesen zählt jede Fläche
|
||||
|
||||
$logo = imagecreatefrompng($quelle);
|
||||
$w = $AUSSCHNITT['x2'] - $AUSSCHNITT['x1'] + 1;
|
||||
$h = $AUSSCHNITT['y2'] - $AUSSCHNITT['y1'] + 1;
|
||||
$zeichen = imagecreatetruecolor($w, $h);
|
||||
imagealphablending($zeichen, false);
|
||||
imagesavealpha($zeichen, true);
|
||||
imagefilledrectangle($zeichen, 0, 0, $w, $h, imagecolorallocatealpha($zeichen, 0, 0, 0, 127));
|
||||
for ($y = 0; $y < $h; ++$y) {
|
||||
for ($x = 0; $x < $w; ++$x) {
|
||||
// Das Logo ist ein Palettenbild; imagecolorsforindex beantwortet Palette und Truecolor.
|
||||
$c = imagecolorsforindex($logo, imagecolorat($logo, $AUSSCHNITT['x1'] + $x, $AUSSCHNITT['y1'] + $y));
|
||||
$hell = $c['red'] > 230 && $c['green'] > 230 && $c['blue'] > 230;
|
||||
if ($c['alpha'] > 100 || $hell) {
|
||||
continue;
|
||||
}
|
||||
imagesetpixel($zeichen, $x, $y, imagecolorallocate($zeichen, 255, 255, 255));
|
||||
}
|
||||
}
|
||||
imagedestroy($logo);
|
||||
|
||||
$symbol = imagecreatetruecolor($KANTE, $KANTE);
|
||||
imagealphablending($symbol, false);
|
||||
imagesavealpha($symbol, true);
|
||||
imagefilledrectangle($symbol, 0, 0, $KANTE, $KANTE, imagecolorallocate($symbol, $BLAU[0], $BLAU[1], $BLAU[2]));
|
||||
imagealphablending($symbol, true);
|
||||
$platz = (int) round($KANTE * (1 - 2 * $RAND));
|
||||
$faktor = min($platz / $w, $platz / $h);
|
||||
$zw = (int) round($w * $faktor);
|
||||
$zh = (int) round($h * $faktor);
|
||||
imagecopyresampled($symbol, $zeichen, (int) (($KANTE - $zw) / 2), (int) (($KANTE - $zh) / 2), 0, 0, $zw, $zh, $w, $h);
|
||||
imagedestroy($zeichen);
|
||||
|
||||
$datei = sys_get_temp_dir().'/favicon-deeg.png';
|
||||
imagepng($symbol, $datei, 9);
|
||||
imagedestroy($symbol);
|
||||
echo "Symbol gerendert: {$KANTE}x{$KANTE}\n";
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
$media = new MediaLibrary($db, str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']));
|
||||
$settings = new SiteSettings($db, $media);
|
||||
|
||||
$eintrag = $media->upload(new UploadedFile($datei, 'favicon-deeg.png', null, null, true));
|
||||
$media->update($eintrag['id'], [
|
||||
'title' => 'Favicon DEEG',
|
||||
'alt' => 'Dachgiebel aus dem DEEG-Logo, weiß auf Blau',
|
||||
'archived' => false,
|
||||
'version' => $eintrag['version'],
|
||||
]);
|
||||
@unlink($datei);
|
||||
echo "In der Mediathek: {$eintrag['id']}\n";
|
||||
|
||||
$aktuell = $settings->load();
|
||||
$settings->save([
|
||||
...$aktuell,
|
||||
// Gespeichert als Liste, erwartet als Textfeld - wie es der Einstellungsbildschirm schickt.
|
||||
'sameAs' => implode("\n", $aktuell['sameAs']),
|
||||
'faviconMediaId' => $eintrag['id'],
|
||||
]);
|
||||
echo "Favicon gesetzt; der Kern hat 32 px (Browserleiste) und 180 px (Apple-Touch) daraus gerendert.\n";
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Räumt Texte auf, die vor 1.20.1 bei jedem Speichern eine Kodierungsstufe dazubekommen haben.
|
||||
*
|
||||
* `RichText::sanitise()` hat Text ohne ein einziges Tag nur escaped, statt ihn vorher zu
|
||||
* dekodieren. Aus „Freudenstadt & Umgebung“ wurde deshalb `&amp;`, dann `&amp;amp;`
|
||||
* – bis die Entity sichtbar auf der Seite stand. Der Fehler ist behoben; dieser Befehl bringt in
|
||||
* Ordnung, was schon in der Datenbank liegt.
|
||||
*
|
||||
* Angefasst wird, was in Gebrauch ist: die veröffentlichte und die Entwurfsfassung jeder Seite
|
||||
* und die Bereiche. Ältere Fassungen bleiben Historie – wer eine zurückholt, speichert sie
|
||||
* danach einmal, und der reparierte Kern schreibt sie richtig.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:repair:entities', description: 'Repariert doppelt kodierte Texte (&amp;) aus Fassungen vor 1.20.1.')]
|
||||
final class RepairEntitiesCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('schreiben', null, InputOption::VALUE_NONE, 'Die Korrekturen wirklich speichern. Ohne diese Option wird nur gezeigt, was sich ändern würde.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$schreiben = (bool) $input->getOption('schreiben');
|
||||
$gesamt = 0;
|
||||
$zeilen = [];
|
||||
|
||||
// Die aktiven Fassungen jeder Seite: veröffentlicht und Entwurf.
|
||||
$aktive = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page') as $seite) {
|
||||
foreach (['published_revision_id' => 'veröffentlicht', 'draft_revision_id' => 'Entwurf'] as $spalte => $was) {
|
||||
if (!empty($seite[$spalte])) {
|
||||
$aktive[(string) $seite[$spalte]] = '/'.$seite['path'].' ('.$was.')';
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($aktive as $revision => $bezeichnung) {
|
||||
$roh = $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]);
|
||||
if (!is_string($roh)) {
|
||||
continue;
|
||||
}
|
||||
$dokument = json_decode($roh, true, 512, JSON_THROW_ON_ERROR);
|
||||
$zahl = 0;
|
||||
$beispiele = [];
|
||||
$dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele);
|
||||
if (0 === $zahl) {
|
||||
continue;
|
||||
}
|
||||
$gesamt += $zahl;
|
||||
$zeilen[] = [$bezeichnung, $zahl, implode(' · ', $beispiele)];
|
||||
if ($schreiben) {
|
||||
// Geprüft wie beim Speichern: eine Korrektur darf kein ungültiges Dokument hinterlassen.
|
||||
$this->validator->validate($dokument, writing: true);
|
||||
$this->db->executeStatement('UPDATE revision SET document = ? WHERE id = ?', [$this->json($dokument), $revision]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, kind, name, document FROM partial') as $bereich) {
|
||||
$dokument = json_decode((string) $bereich['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$zahl = 0;
|
||||
$beispiele = [];
|
||||
$dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele);
|
||||
if (0 === $zahl) {
|
||||
continue;
|
||||
}
|
||||
$gesamt += $zahl;
|
||||
$zeilen[] = ['Bereich '.$bereich['kind'].' „'.$bereich['name'].'“', $zahl, implode(' · ', $beispiele)];
|
||||
if ($schreiben) {
|
||||
// Ein Bereich traegt keine Ueberschrift - genau wie im PartialRepository.
|
||||
$this->validator->validate($dokument, requireHeading: false, writing: true);
|
||||
$this->db->executeStatement('UPDATE partial SET document = ? WHERE id = ?', [$this->json($dokument), $bereich['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === $gesamt) {
|
||||
$io->success('Nichts zu reparieren: kein Text trägt eine doppelt kodierte Entity.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->table(['Wo', 'Stellen', 'Beispiel'], $zeilen);
|
||||
if ($schreiben) {
|
||||
$io->success(sprintf('%d Stelle(n) repariert.', $gesamt));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->warning(sprintf('%d Stelle(n) gefunden. Mit --schreiben anwenden.', $gesamt));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $liste */
|
||||
private function bausteine(array $liste, int &$zahl, array &$beispiele): array
|
||||
{
|
||||
foreach ($liste as $index => $block) {
|
||||
foreach (['text', 'caption', 'label', 'title', 'hint', 'provider'] as $feld) {
|
||||
$wert = $block['props'][$feld] ?? null;
|
||||
if (!is_string($wert) || '' === $wert) {
|
||||
continue;
|
||||
}
|
||||
$neu = $this->entschachteln($wert);
|
||||
if ($neu !== $wert) {
|
||||
$liste[$index]['props'][$feld] = $neu;
|
||||
++$zahl;
|
||||
if (count($beispiele) < 3) {
|
||||
$beispiele[] = mb_substr($wert, 0, 40).' → '.mb_substr($neu, 0, 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$liste[$index]['children'] = $this->bausteine($block['children'], $zahl, $beispiele);
|
||||
}
|
||||
}
|
||||
|
||||
return $liste;
|
||||
}
|
||||
|
||||
/** Entschachtelt so oft wie nötig: aus &amp;amp; wird &. Ein einzelnes & ist richtig und bleibt. */
|
||||
private function entschachteln(string $text): string
|
||||
{
|
||||
$vorher = null;
|
||||
while ($vorher !== $text) {
|
||||
$vorher = $text;
|
||||
$text = preg_replace('/&(amp|lt|gt|quot|apos|nbsp|#\d{1,6}|#x[0-9a-fA-F]{1,6});/', '&$1;', $text) ?? $text;
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $dokument */
|
||||
private function json(array $dokument): string
|
||||
{
|
||||
return json_encode($dokument, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,23 @@ namespace App\Content;
|
||||
use App\Domain\InvalidDocument;
|
||||
|
||||
/**
|
||||
* Own CSS and own script per element (document schema v13).
|
||||
* Own CSS and own script per element (document schema v13), and the raw HTML of the „Eigenes
|
||||
* HTML“ block (schema v24).
|
||||
*
|
||||
* The two are not comparable in what they can do, so they are not treated alike.
|
||||
* The three are not comparable in what they can do, so they are not treated alike.
|
||||
*
|
||||
* **CSS** is parsed and written out again by this class, scoped to the block. A declaration list
|
||||
* and nested rules starting with `&` are accepted; anything else is refused. Because the output is
|
||||
* re-serialised, a stray `}` cannot escape the block, and `@import` or a `javascript:` URL never
|
||||
* gets through. Everyone with the editor role may use it.
|
||||
*
|
||||
* **HTML** is markup that goes onto the page unchanged. It cannot be made harmless either - it
|
||||
* can load images from anywhere and point a form at a foreign address - so it follows the same
|
||||
* rule as a script: administrators only, checked against what is already stored. Two tags are
|
||||
* refused rather than silently swallowed by the content security policy: `<script>`, which
|
||||
* belongs in the script field, and `<iframe>`, which belongs in the „Externer Inhalt“ block,
|
||||
* because only that one asks for consent and puts the host into `frame-src`.
|
||||
*
|
||||
* **Script** is arbitrary JavaScript on the published site. There is no way to make that harmless:
|
||||
* whoever may set it can run code in the browser of every visitor and every administrator. It is
|
||||
* therefore reserved for administrators, checked on the way in by comparing against what is
|
||||
@@ -24,6 +32,7 @@ final class CustomCode
|
||||
{
|
||||
public const MAX_CSS = 4000;
|
||||
public const MAX_SCRIPT = 8000;
|
||||
public const MAX_HTML = 20000;
|
||||
|
||||
public static function supports(mixed $schemaVersion): bool
|
||||
{
|
||||
@@ -62,6 +71,66 @@ final class CustomCode
|
||||
return $script;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the markup of an „Eigenes HTML“ block and returns it unchanged.
|
||||
*
|
||||
* @throws InvalidDocument
|
||||
*/
|
||||
public function checkHtml(string $html): string
|
||||
{
|
||||
if (mb_strlen($html) > self::MAX_HTML) {
|
||||
throw new InvalidDocument(sprintf('Eigenes HTML ist auf %d Zeichen begrenzt.', self::MAX_HTML));
|
||||
}
|
||||
// Beide Tags wuerden von der Sicherheitsrichtlinie der Seite ohnehin nicht ausgefuehrt
|
||||
// bzw. nicht geladen. Eine Meldung mit dem richtigen Weg ist besser als ein Baustein,
|
||||
// der etwas annimmt und nachher nichts tut.
|
||||
if (1 === preg_match('/<\s*script/i', $html)) {
|
||||
throw new InvalidDocument('Eigenes HTML führt keine Skripte aus. Dafür gibt es unter „Erweitert“ das Feld „Eigenes Skript“.');
|
||||
}
|
||||
if (1 === preg_match('/<\s*iframe/i', $html)) {
|
||||
throw new InvalidDocument('Für einen eingebetteten Rahmen gibt es den Baustein „Externer Inhalt“: nur er fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein.');
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw HTML of every „Eigenes HTML“ block, keyed by block ID.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function htmlOf(array $document): array
|
||||
{
|
||||
$found = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$found): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('html' === ($block['type'] ?? '') && is_string($block['props']['html'] ?? null) && is_string($block['id'] ?? null)) {
|
||||
$found[$block['id']] = $block['props']['html'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a write that would add, change or remove raw HTML without the administrator role.
|
||||
*
|
||||
* @throws InvalidDocument
|
||||
*/
|
||||
public function assertHtmlUnchanged(array $incoming, array $stored): void
|
||||
{
|
||||
$before = $this->htmlOf($stored);
|
||||
$after = $this->htmlOf($incoming);
|
||||
ksort($before);
|
||||
ksort($after);
|
||||
if ($before !== $after) {
|
||||
throw new InvalidDocument('Eigenes HTML darf nur von einem Administrator geändert werden. Die übrigen Änderungen wurden nicht gespeichert.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns own CSS into rules scoped to one block.
|
||||
*
|
||||
@@ -124,17 +193,34 @@ final class CustomCode
|
||||
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
|
||||
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
|
||||
}
|
||||
$rules[$suffix] = $this->declarations($match[2][0]);
|
||||
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
|
||||
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
|
||||
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
|
||||
// wo `& .post-card{border:0}` und `& .post-card{transform:none}` nebeneinander
|
||||
// standen und der Rahmen des Kerns wieder auftauchte.
|
||||
$rules[$suffix] = $this->verbinden($rules[$suffix] ?? '', $this->declarations($match[2][0]));
|
||||
$rest = substr_replace($rest, '', $match[0][1], strlen($match[0][0]));
|
||||
}
|
||||
if (str_contains($rest, '{') || str_contains($rest, '}')) {
|
||||
throw new InvalidDocument('Eigenes CSS besteht aus Eigenschaften für dieses Element und aus verschachtelten Regeln, die mit & beginnen. Andere Selektoren sind nicht vorgesehen.');
|
||||
}
|
||||
$rules[''] = $this->declarations($rest);
|
||||
// Die Eigenschaften des Elements selbst stehen ueblicherweise vor den verschachtelten
|
||||
// Regeln; eine Regel `&{…}` schreibt sie aber in denselben Schluessel.
|
||||
$rules[''] = $this->verbinden($this->declarations($rest), $rules['']);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** Zwei Deklarationslisten desselben Selektors, in der Reihenfolge, in der sie standen. */
|
||||
private function verbinden(string $erste, string $zweite): string
|
||||
{
|
||||
if ('' === $erste || '' === $zweite) {
|
||||
return '' === $erste ? $zweite : $erste;
|
||||
}
|
||||
|
||||
return $erste.';'.$zweite;
|
||||
}
|
||||
|
||||
/** @throws InvalidDocument */
|
||||
private function declarations(string $block): string
|
||||
{
|
||||
|
||||
+67
-19
@@ -122,34 +122,75 @@ final class DemoContent
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
/**
|
||||
* The start page of a fresh installation - not demo content.
|
||||
*
|
||||
* Short on purpose. Somebody who has just installed should find a page they can edit, not a
|
||||
* brochure about the CMS they have to delete first. Exactly one H1, as every page requires.
|
||||
* A website that is already reachable but not yet written needs a page that says exactly that:
|
||||
* the name of the site, the notice that something is being built here, and nothing else. No
|
||||
* instructions for the owner - those stand on the last screen of the installer and in the
|
||||
* admin area, not on a page every visitor sees. Exactly one H1, as every page requires.
|
||||
*/
|
||||
public static function starterPage(string $siteName): array
|
||||
{
|
||||
return [
|
||||
'schemaVersion' => 16,
|
||||
'schemaVersion' => 24,
|
||||
'title' => $siteName,
|
||||
'description' => 'NOCH AUSZUFÜLLEN: ein Satz darüber, was es hier gibt. Er erscheint in Suchergebnissen.',
|
||||
'description' => 'Hier entsteht eine neue Webpräsenz. Diese Seite wird gerade eingerichtet.',
|
||||
'blocks' => [
|
||||
self::section('start-hero', [
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
self::text('start-intro', 'Diese Seite ist der Anfang. Melde dich an, öffne sie im Editor und ersetze diesen Text durch deinen eigenen.'),
|
||||
], 'mint'),
|
||||
self::section('start-body', [
|
||||
self::heading('start-next-heading', 'Die nächsten Schritte', 2),
|
||||
self::text('start-next', "Impressum und Datenschutzerklärung sind bereits angelegt und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.\n\nUnter „Design“ legst du Farben, Schriften und die Breite fest, unter „Menüs“ die Navigation, unter „Einstellungen“ den Namen und die Angaben zum Unternehmen."),
|
||||
], 'paper', 'narrow'),
|
||||
[
|
||||
'id' => 'start-hero',
|
||||
'type' => 'section',
|
||||
'layout' => ['tone' => 'mint', 'width' => 'narrow'],
|
||||
'style' => [
|
||||
// Eine Seite ohne Inhalt: der Abschnitt füllt den Bildschirm und stellt das
|
||||
// Wenige, das dasteht, in die Mitte, statt es oben in eine Ecke zu setzen.
|
||||
'desktop' => [
|
||||
'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center',
|
||||
'minHeight' => '70vh', 'textAlign' => 'center',
|
||||
'padding' => ['top' => '96px', 'bottom' => '96px'],
|
||||
'gradientFrom' => '@paper', 'gradientTo' => '@mint', 'gradientAngle' => 165,
|
||||
],
|
||||
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
],
|
||||
'children' => [
|
||||
[
|
||||
'id' => 'start-logo',
|
||||
'type' => 'image',
|
||||
'props' => ['src' => '/assets/kantsites-logo.svg', 'alt' => 'KantSites', 'decorative' => false, 'width' => 128, 'height' => 128],
|
||||
'style' => ['desktop' => ['maxWidth' => '86px', 'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '30px']]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-label',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'In Arbeit'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '2px',
|
||||
'textTransform' => 'uppercase', 'color' => '@accent', 'margin' => ['bottom' => '16px'],
|
||||
]],
|
||||
],
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
[
|
||||
'id' => 'start-intro',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'Hier entsteht eine neue Webpräsenz.'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '21px', 'maxWidth' => '38ch',
|
||||
'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '0px'],
|
||||
]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-rule',
|
||||
'type' => 'divider',
|
||||
'style' => ['desktop' => ['width' => '72px', 'margin' => ['top' => '40px', 'right' => 'auto', 'bottom' => '22px', 'left' => 'auto']]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-note',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'Erstellt mit KantSites'],
|
||||
'style' => ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.4px', 'margin' => ['bottom' => '0px']]],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -164,6 +205,13 @@ final class DemoContent
|
||||
return $areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
public static function areas(): array
|
||||
{
|
||||
$row = static fn (string $id, array $children): array => [
|
||||
|
||||
@@ -16,7 +16,7 @@ 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 = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home', 'arrow-right', 'arrow-left', 'arrow-up-right'];
|
||||
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
|
||||
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
|
||||
public const V7_TYPES = ['tabs', 'tabitem'];
|
||||
@@ -38,8 +38,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 = 24;
|
||||
|
||||
/**
|
||||
* @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a
|
||||
@@ -98,7 +99,7 @@ final class DocumentValidator
|
||||
// page: a deactivated plugin must never make stored content unreadable. Nothing from
|
||||
// props is rendered for such a block, so unvalidated values never reach the output.
|
||||
$plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type);
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.');
|
||||
// An accordion entry only ever renders inside an accordion; at the top level or anywhere
|
||||
// else it would produce a details element without its group.
|
||||
$this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.');
|
||||
@@ -389,6 +390,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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -292,6 +292,7 @@ final class EditorController extends AbstractController
|
||||
// ueber den Umweg einer alten Fassung.
|
||||
if (!$this->isGranted('ROLE_ADMIN')) {
|
||||
$this->code->assertScriptsUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||||
$this->code->assertHtmlUnchanged($this->pages->revisionDocument($id, $revision), $this->pages->findForEditor($id)['document']);
|
||||
}
|
||||
$this->pages->restoreRevision($id, $revision, $request->request->getInt('expectedVersion', -1));
|
||||
} catch (InvalidPage|StaleWrite $e) {
|
||||
@@ -488,6 +489,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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -14,7 +15,7 @@ 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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -159,21 +160,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
|
||||
{
|
||||
|
||||
@@ -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}';
|
||||
@@ -209,10 +212,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),
|
||||
@@ -772,6 +779,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% set selected = selected|default(null) %}
|
||||
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}">
|
||||
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}>
|
||||
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
|
||||
<div class="picker-input">
|
||||
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
|
||||
<button type="button" class="subtle picker-clear" aria-label="{{ label|default('Seite') }}: Auswahl entfernen">✕</button>
|
||||
</div>
|
||||
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? selected.id : '' }}">
|
||||
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? attribute(selected, valueKey|default('id')) : '' }}">
|
||||
<div id="{{ id }}-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
|
||||
<p class="picker-status field-help" role="status"></p>
|
||||
</div>
|
||||
@@ -3,9 +3,9 @@
|
||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
|
||||
<div class="rows">
|
||||
{% for page in pages %}
|
||||
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
||||
an attribute here would simply be dropped and the tree would look flat. #}
|
||||
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
||||
{# Newest first, not as a tree: indenting subpages here would suggest a parent that is not
|
||||
the row above. The address in the row shows where a page sits. #}
|
||||
<article class="row row-stack">
|
||||
<div class="row-line">
|
||||
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
||||
<div class="row-main">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %}
|
||||
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
|
||||
{% block body_class %}editor-body{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block body %}
|
||||
{# The API base decides what is edited. An area is shared, so it has no draft and no
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
{% block title %}Einstellungen{% endblock %}
|
||||
{% block heading %}Unternehmen und Adressen{% endblock %}
|
||||
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
@@ -129,15 +130,19 @@
|
||||
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
|
||||
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
|
||||
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
|
||||
<label for="faviconMediaId">Symbol der Website</label>
|
||||
<div class="favicon-field">
|
||||
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32">{% endif %}
|
||||
<select id="faviconMediaId" name="faviconMediaId">
|
||||
<option value="">Mitgeliefertes Zeichen</option>
|
||||
{% for image in images %}<option value="{{ image.id }}"{{ settings.faviconMediaId == image.id ? ' selected' : '' }}>{{ image.title }}</option>{% endfor %}
|
||||
</select>
|
||||
<div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div>
|
||||
<div class="favicon-field" data-favicon-field aria-labelledby="favicon-title" role="group">
|
||||
<input type="hidden" name="faviconMediaId" value="{{ settings.faviconMediaId }}">
|
||||
<input type="hidden" name="faviconCrop" value="{{ settings.faviconCrop }}">
|
||||
<div class="favicon-current">
|
||||
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconVersion ?: settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32"> <span>Eigenes Symbol</span>{% else %}<img class="favicon-preview" src="/favicon.svg" alt="" width="32" height="32"> <span>Mitgeliefertes Zeichen</span>{% endif %}
|
||||
</div>
|
||||
<div class="favicon-actions">
|
||||
<button type="button" class="secondary" data-favicon-choose>{{ settings.faviconMediaId ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
|
||||
<button type="button" class="text-button danger-text" data-favicon-remove{{ settings.faviconMediaId ? '' : ' hidden' }}>Mitgeliefertes Zeichen verwenden</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.</p>
|
||||
<p class="field-help">Das Bild kommt aus der Mediathek, dort kann es auch gleich hochgeladen werden. Danach das Quadrat auf den gewünschten Ausschnitt ziehen und die Größe einstellen. Beim Speichern entstehen daraus PNG-Dateien in 32 und 180 Pixeln.</p>
|
||||
<div class="section-head"><h2>Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
|
||||
<label class="check-label" for="scrollTop"><input id="scrollTop" name="scrollTop" type="checkbox" value="1"{{ settings.scrollTop ? ' checked' : '' }}> Knopf „nach oben" anzeigen</label>
|
||||
<p class="field-help">Springt an den Seitenanfang. Er braucht kein JavaScript: In Browsern mit Scroll-Zeitachse blendet er sich beim Scrollen ein, in den übrigen steht er von Anfang an da.</p>
|
||||
@@ -261,13 +266,11 @@
|
||||
</fieldset>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="privacyPath">Adresse der Datenschutzseite</label>
|
||||
<input id="privacyPath" name="privacyPath" maxlength="400" value="{{ settings.privacyPath }}" placeholder="datenschutz">
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'privacyPath', name: 'privacyPath', type: 'page', valueKey: 'path', label: 'Datenschutzseite', selected: legalSelection.privacyPath} only %}
|
||||
<p class="field-help">Pflicht, sobald der Hinweis an ist, und die Seite muss existieren.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="imprintPath">Adresse des Impressums</label>
|
||||
<input id="imprintPath" name="imprintPath" maxlength="400" value="{{ settings.imprintPath }}" placeholder="impressum">
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'imprintPath', name: 'imprintPath', type: 'page', valueKey: 'path', label: 'Impressum', selected: legalSelection.imprintPath} only %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter <code>/einwilligung</code> kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.</p>
|
||||
|
||||
@@ -162,6 +162,11 @@
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-image-radius">Bildecken</label>
|
||||
<input id="base-image-radius" name="base[imageRadius]" value="{{ theme.base.imageRadius }}" placeholder="12px">
|
||||
<p class="field-help">Gilt für jedes Bild auf der Website – auch in Galerien, Logoleisten und Beitragslisten. <code>0px</code> macht sie eckig. Ein einzelnes Bild lässt sich im Editor unter <strong>Design → Bild → Bildecken</strong> abweichend einstellen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{% endif %}
|
||||
{% elseif type == 'button' %}
|
||||
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %}
|
||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span class="button-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-up-right'} only %}</span></a>
|
||||
{% elseif type == 'image' %}
|
||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||
@@ -255,6 +255,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% elseif type == 'html' %}
|
||||
{# Markup des Betreibers, unveraendert. Geprueft wurde es beim Speichern (CustomCode::checkHtml),
|
||||
setzen darf es nur ein Administrator - deshalb steht hier `raw` und keine Filterung. #}
|
||||
{% if props.html|default('')|trim == '' %}
|
||||
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Eigenes HTML: unter Inhalt den Code einfügen.</p>{% endif %}
|
||||
{% else %}
|
||||
<div id="{{ block.id }}" class="block-html{{ designClasses }}">{{ props.html|raw }}</div>
|
||||
{% endif %}
|
||||
{% elseif type == 'postlist' %}
|
||||
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
||||
{% set feed = postLists[block.id]|default([]) %}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
check: 'm4 12.5 5.5 5.5L20 6',
|
||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20'
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9'
|
||||
} %}
|
||||
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
|
||||
@@ -4,13 +4,13 @@
|
||||
<nav class="post-nav" aria-label="Weitere Beiträge">
|
||||
{% if previous %}
|
||||
<a class="post-nav-link post-nav-previous" href="{{ previous.href }}">
|
||||
<span class="post-nav-label"><span aria-hidden="true">←</span> {{ previousLabel|default('Vorheriger Beitrag') }}</span>
|
||||
<span class="post-nav-label"><span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-left'} only %}</span> {{ previousLabel|default('Vorheriger Beitrag') }}</span>
|
||||
<span class="post-nav-title">{{ previous.title }}</span>
|
||||
</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
{% if next %}
|
||||
<a class="post-nav-link post-nav-next" href="{{ next.href }}">
|
||||
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag') }} <span aria-hidden="true">→</span></span>
|
||||
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag') }} <span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-right'} only %}</span></span>
|
||||
<span class="post-nav-title">{{ next.title }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{# Das Symbol der Website. Eigenes Bild aus den Einstellungen, sonst das mitgelieferte Zeichen.
|
||||
Die Kennung hängt als Version am Verweis, damit ein Wechsel im Browser auch ankommt. #}
|
||||
{% set favicon = siteSettings.load().faviconMediaId|default('') %}
|
||||
Die Kennung hängt als Version am Verweis, damit ein Wechsel im Browser auch ankommt.
|
||||
Das 192er ist für die Google-Suche: sie nimmt nur Vielfache von 48 px. #}
|
||||
{% set site = siteSettings.load() %}
|
||||
{% set favicon = site.faviconMediaId|default('') %}
|
||||
{% set version = site.faviconVersion|default('') ?: favicon|slice(0, 8) %}
|
||||
{% if favicon %}
|
||||
<link rel="icon" href="/favicon.png?v={{ favicon|slice(0, 8) }}" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v={{ favicon|slice(0, 8) }}">
|
||||
<link rel="icon" href="/favicon.png?v={{ version }}" type="image/png" sizes="32x32">
|
||||
<link rel="icon" href="/favicon-192.png?v={{ version }}" type="image/png" sizes="192x192">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v={{ version }}">
|
||||
{% else %}
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
{% endif %}
|
||||
@@ -34,6 +34,10 @@
|
||||
drei Abfragen trifft zu - und zwar auf dieselbe Variante, die auch die Regel nimmt. #}
|
||||
{% for bild in heroBackground|default([]) %}<link rel="preload" as="image" href="{{ bild.src }}" media="{{ bild.media }}" fetchpriority="high">
|
||||
{% endfor %}
|
||||
{# Und derselbe Dienst fuer das gewoehnliche Bild ueber der Falz: ohne diese Zeile faengt der
|
||||
Browser es erst an zu holen, wenn er das Stylesheet geparst hat. #}
|
||||
{% if heroImage|default(null) %}<link rel="preload" as="image" href="{{ heroImage.src }}"{% if heroImage.srcset %} imagesrcset="{{ heroImage.srcset }}" imagesizes="{{ heroImage.sizes }}"{% endif %} fetchpriority="high">
|
||||
{% endif %}
|
||||
{% if localFonts|default([]) %}
|
||||
{# Jeder Schnitt, den das Theme nennt, vorab - und zwar alle. Fehlt einer, laedt er erst,
|
||||
wenn das Stylesheet geparst ist, und die Ueberschrift springt sichtbar von der
|
||||
|
||||
+213
-3
@@ -1398,8 +1398,11 @@ final class CmsTest extends TestCase
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$html = $this->request('/')->getContent();
|
||||
|
||||
// Genau ein Bild wird vorgezogen, und zwar das erste im ersten Abschnitt.
|
||||
self::assertSame(1, substr_count($html, 'fetchpriority="high"'));
|
||||
// Genau ein Bild wird vorgezogen, und zwar das erste im ersten Abschnitt. Es wird
|
||||
// zweimal genannt: einmal als Vorabladung im Kopf, einmal als das Bild selbst.
|
||||
self::assertSame(1, substr_count($html, '<link rel="preload" as="image"'));
|
||||
self::assertMatchesRegularExpression('#<link rel="preload" as="image" href="[^"]+" imagesrcset="[^"]*480w[^"]*" imagesizes="\(max-width: 650px\) 100vw[^"]*" fetchpriority="high">#', $html);
|
||||
self::assertSame(2, substr_count($html, 'fetchpriority="high"'));
|
||||
self::assertMatchesRegularExpression('#<figure id="gross".{0,400}fetchpriority="high"#s', $html);
|
||||
self::assertMatchesRegularExpression('#<figure id="weiter-unten".{0,400}loading="lazy"#s', $html);
|
||||
// Ein Block mit fester Breite fragt nach genau dieser Breite.
|
||||
@@ -1589,6 +1592,19 @@ final class CmsTest extends TestCase
|
||||
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
|
||||
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode());
|
||||
|
||||
// Der Zuschnitt aus dem Werkzeug: links oben, ein Viertel der kurzen Seite. Der Verweis
|
||||
// bekommt eine neue Version, sonst hielte der Browser am alten Ausschnitt fest.
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id'], 'faviconCrop' => '0,0,0.25']);
|
||||
self::assertSame('0,0,0.25', $this->site->load()['faviconCrop']);
|
||||
self::assertStringContainsString('/favicon.png?v='.substr(md5($item['id'].'|0,0,0.25'), 0, 8), $this->request('/')->getContent());
|
||||
foreach (['2,0,0.5', '0,0,0.01', 'mitte'] as $falsch) {
|
||||
try {
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id'], 'faviconCrop' => $falsch]);
|
||||
self::fail('Ein ungültiger Ausschnitt muss abgelehnt werden: '.$falsch);
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ein winziges quadratisches Zeichen hat keine einzige der vier Regelbreiten - genau so
|
||||
// sieht ein echtes Favicon aus, und genau daran ist die erste Fassung gescheitert.
|
||||
$klein = tempnam(sys_get_temp_dir(), 'kant-zeichen-');
|
||||
@@ -1610,6 +1626,52 @@ final class CmsTest extends TestCase
|
||||
self::assertSame($kleines['id'], $this->site->load()['faviconMediaId']);
|
||||
}
|
||||
|
||||
public function testGoogleGetsTheOwnSiteIconUnderEveryAddress(): void
|
||||
{
|
||||
// Umut: Google zeigte bei Elektro Bernhard nach dem Wechsel weiter das KantSites-K. Das
|
||||
// eigene Symbol gab es nur in 32 px (Google will Vielfache von 48), und /favicon.svg lag
|
||||
// als Datei in public/ und lieferte immer das mitgelieferte Zeichen.
|
||||
self::assertFileDoesNotExist(__DIR__.'/../public/favicon.svg');
|
||||
$ohne = $this->request('/favicon.svg');
|
||||
self::assertSame(200, $ohne->getStatusCode());
|
||||
self::assertSame('image/svg+xml', $ohne->headers->get('Content-Type'));
|
||||
self::assertSame('/favicon.svg', $this->request('/favicon.ico')->headers->get('Location'));
|
||||
self::assertSame(404, $this->request('/favicon-192.png')->getStatusCode());
|
||||
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-symbol-');
|
||||
$image = imagecreatetruecolor(300, 300);
|
||||
imagepng($image, $file);
|
||||
imagedestroy($image);
|
||||
$item = $media->upload(new UploadedFile($file, 'zeichen.png', test: true));
|
||||
@unlink($file);
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id']]);
|
||||
$version = substr($item['id'], 0, 8);
|
||||
|
||||
self::assertStringContainsString('<link rel="icon" href="/favicon-192.png?v='.$version.'" type="image/png" sizes="192x192">', $this->request('/')->getContent());
|
||||
foreach (['/favicon-192.png' => 192, '/favicon.ico' => 48] as $adresse => $kante) {
|
||||
$antwort = $this->request($adresse);
|
||||
self::assertSame(200, $antwort->getStatusCode(), $adresse);
|
||||
self::assertSame('image/png', $antwort->headers->get('Content-Type'));
|
||||
$size = getimagesizefromstring((string) file_get_contents($this->path.'-media/favicon-'.$kante.'.png'));
|
||||
self::assertSame([$kante, $kante], [$size[0], $size[1]]);
|
||||
}
|
||||
$svg = $this->request('/favicon.svg');
|
||||
self::assertSame(302, $svg->getStatusCode());
|
||||
self::assertSame('/favicon-192.png?v='.$version, $svg->headers->get('Location'));
|
||||
|
||||
// Wer sein Symbol vor den neuen Größen gesetzt hat, bekommt sie beim ersten Abruf.
|
||||
unlink($this->path.'-media/favicon-48.png');
|
||||
unlink($this->path.'-media/favicon-192.png');
|
||||
self::assertSame(200, $this->request('/favicon-192.png')->getStatusCode());
|
||||
self::assertFileExists($this->path.'-media/favicon-48.png');
|
||||
|
||||
// Symbol entfernt: die Dateien liegen noch da, gelten aber nicht mehr.
|
||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => '']);
|
||||
self::assertSame(200, $this->request('/favicon.svg')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/favicon.png')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testUnderlinedLinksAreAChoiceAndOnlyApplyToRunningText(): void
|
||||
{
|
||||
// Umut: „links haben alle underlined“. Im Menü, auf Karten und über Bildern hat ein
|
||||
@@ -1944,7 +2006,8 @@ final class CmsTest extends TestCase
|
||||
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/icon.html.twig');
|
||||
foreach (DocumentValidator::ICONS as $icon) {
|
||||
self::assertMatchesRegularExpression(
|
||||
'/\R\s{4}'.preg_quote($icon, '/').": '[^']{4,}',?\R/",
|
||||
// Ein Name mit Bindestrich braucht in Twig Anfuehrungszeichen um den Schluessel.
|
||||
'/\R\s{4}\'?'.preg_quote($icon, '/')."'?: '[^']{4,}',?\R/",
|
||||
$template,
|
||||
sprintf('Das Symbol „%s“ steht in der Liste, hat aber keine Zeichnung.', $icon),
|
||||
);
|
||||
@@ -2983,6 +3046,12 @@ final class CmsTest extends TestCase
|
||||
yield 'descendant keeps its space' => ['& .block-text { font-size: 20px }', '#b1 .block-text{font-size:20px}'];
|
||||
yield 'child combinator' => ['& > p { margin: 0 }', '#b1 > p{margin:0}'];
|
||||
yield 'local image' => ['background: url(/bild.png)', '#b1{background:url(/bild.png)}'];
|
||||
// Zweimal derselbe Selektor ist eine Kaskade, kein Widerruf: vorher gewann die letzte
|
||||
// Regel, und was nur in der ersten stand, verschwand ohne Meldung.
|
||||
yield 'same selector twice' => ['& .card { border: 0 }
|
||||
& .card { transform: none }', '#b1 .card{border:0;transform:none}'];
|
||||
yield 'element rule after a nested one' => ['& { color: red }
|
||||
display: flex', '#b1{display:flex;color:red}'];
|
||||
}
|
||||
|
||||
#[DataProvider('ownCssCases')]
|
||||
@@ -3111,6 +3180,147 @@ final class CmsTest extends TestCase
|
||||
$code->assertScriptsUnchanged($added, $before);
|
||||
}
|
||||
|
||||
public function testImageCornersComeFromTheDesignAreaAndCanBeSquare(): void
|
||||
{
|
||||
// Die Ecken jedes Bildes standen fest im Stylesheet: sichtbar gerundet, in keinem Feld
|
||||
// zu finden, nur durch einen eigenen Wert am Block loszuwerden. Jetzt gehoeren sie dem
|
||||
// Designbereich - mit derselben Vorgabe wie vorher, damit sich nichts veraendert.
|
||||
$theme = $this->themes->load();
|
||||
self::assertSame('12px', $theme['base']['imageRadius'], 'Die Vorgabe bleibt, was vorher im Stylesheet stand.');
|
||||
|
||||
$document = DemoContent::starter('Mit Bild');
|
||||
$document['blocks'][0]['children'][] = ['id' => 'bild', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Platzhalter', 'decorative' => false, 'width' => 1000, 'height' => 600]];
|
||||
$id = $this->publishedPage('ecken', 'Ecken');
|
||||
$stand = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $this->pages->saveDraft($id, $document, $stand['version']), $this->pages->findForEditor($id)['version']);
|
||||
|
||||
self::assertStringContainsString('--k-image-radius:12px', $this->request('/ecken')->getContent());
|
||||
|
||||
// Eckig, wie es eine Vorlage ohne Rundungen braucht.
|
||||
$this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'imageRadius' => '0px']);
|
||||
self::assertStringContainsString('--k-image-radius:0px', $this->request('/ecken')->getContent());
|
||||
|
||||
// Und was keine Groessenangabe ist, wird abgewiesen - wie bei der Inhaltsbreite.
|
||||
try {
|
||||
$this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'imageRadius' => 'rund']);
|
||||
self::fail('„rund“ ist keine Groessenangabe.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
}
|
||||
|
||||
public function testSavingTextTwiceKeepsAmpersandsAndLineBreaksAsTheyAre(): void
|
||||
{
|
||||
// Umut: „Beim Startseitenbild wird & umgewandelt, aber nach dem Speichern steht &
|
||||
// da.“ Der Editor schickt HTML - auch ein Text ohne ein einziges Tag. Wurde der nur
|
||||
// escaped statt vorher dekodiert, wuchs die Entity bei jedem Speichern um eine Stufe,
|
||||
// bis sie sichtbar auf der Seite stand. Dasselbe galt fuer den Zeilenumbruch: nl2br()
|
||||
// liess das \n hinter dem <br> stehen, und der zweite Durchlauf machte zwei daraus.
|
||||
$text = new RichText();
|
||||
foreach ([
|
||||
'Freudenstadt & Umgebung' => 'Freudenstadt & Umgebung',
|
||||
'Haus & Hof' => 'Haus & Hof',
|
||||
'5 < 7 & mehr' => '5 < 7 & mehr',
|
||||
"Zeile eins\nZeile zwei" => 'Zeile eins<br>Zeile zwei',
|
||||
'<p>A & B</p>' => '<p>A & B</p>',
|
||||
'<b>woertlich</b>' => '<b>woertlich</b>',
|
||||
] as $eingabe => $erwartet) {
|
||||
$einmal = $text->sanitise($eingabe);
|
||||
self::assertSame($erwartet, $einmal, $eingabe);
|
||||
// Und jedes weitere Speichern laesst es, wie es ist.
|
||||
self::assertSame($einmal, $text->sanitise($einmal), 'zweites Speichern: '.$eingabe);
|
||||
self::assertSame($einmal, $text->sanitise($text->sanitise($einmal)), 'drittes Speichern: '.$eingabe);
|
||||
}
|
||||
|
||||
// Und durch das ganze Dokument, so wie es beim Speichern einer Seite passiert.
|
||||
$document = DemoContent::starter('Kaufmaennisches Und');
|
||||
$document['schemaVersion'] = 17;
|
||||
$document['blocks'][0]['children'][0]['props']['text'] = 'Freudenstadt & Umgebung';
|
||||
$document['blocks'][0]['children'][1]['props']['text'] = 'Immobilien & Verwaltung';
|
||||
$id = $this->publishedPage('und', 'Und');
|
||||
for ($runde = 0; $runde < 3; ++$runde) {
|
||||
$stand = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $this->pages->saveDraft($id, $document, $stand['version']), $this->pages->findForEditor($id)['version']);
|
||||
$document = $this->pages->findForEditor($id)['document'];
|
||||
}
|
||||
$html = $this->request('/und')->getContent();
|
||||
self::assertStringContainsString('Freudenstadt & Umgebung', $html);
|
||||
self::assertStringContainsString('Immobilien & Verwaltung', $html);
|
||||
self::assertStringNotContainsString('&amp;', $html, 'Nach drei Speichervorgaengen steht immer noch ein einziges Und da.');
|
||||
}
|
||||
|
||||
public function testOwnHtmlReachesThePageUnchangedAndRefusesScriptAndFrame(): void
|
||||
{
|
||||
// Der Baustein „Eigenes HTML“ (Schema v24): Markup des Betreibers, unverändert auf der
|
||||
// Seite. Zwei Tags nimmt er nicht an, weil die Sicherheitsrichtlinie sie ohnehin nicht
|
||||
// ausführt bzw. nicht lädt - dafür gibt es das Skriptfeld und den Baustein „Externer
|
||||
// Inhalt“.
|
||||
$id = $this->publishedPage('angebote', 'Angebote');
|
||||
$document = $this->pages->findForEditor($id)['document'];
|
||||
$document['schemaVersion'] = 24;
|
||||
$document['blocks'][0]['children'][] = ['id' => 'roh', 'type' => 'html', 'props' => ['html' => '<table class="preise"><tr><td>Miete</td><td>ab 8 €/m²</td></tr></table>']];
|
||||
$revision = $this->pages->saveDraft($id, $document, $this->pages->findForEditor($id)['version']);
|
||||
$this->pages->publish($id, $revision, $this->pages->findForEditor($id)['version']);
|
||||
|
||||
$html = $this->request('/angebote')->getContent();
|
||||
self::assertStringContainsString('<div id="roh" class="block-html">', $html);
|
||||
// Unverändert heißt unverändert: keine Entschärfung, keine Umschrift.
|
||||
self::assertStringContainsString('<table class="preise"><tr><td>Miete</td><td>ab 8 €/m²</td></tr></table>', $html);
|
||||
|
||||
foreach ([
|
||||
'<script>alert(1)</script>' => 'Skript',
|
||||
'<iframe src="https://example.com"></iframe>' => 'Rahmen',
|
||||
'< script >alert(1)</script>' => 'Skript mit Leerzeichen',
|
||||
] as $markup => $fall) {
|
||||
$attempt = $document;
|
||||
$attempt['blocks'][0]['children'][2]['props']['html'] = $markup;
|
||||
try {
|
||||
$this->pages->saveDraft($id, $attempt, $this->pages->findForEditor($id)['version']);
|
||||
self::fail(sprintf('%s hätte abgewiesen werden müssen.', $fall));
|
||||
} catch (InvalidDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
// Vor v24 gibt es den Baustein nicht.
|
||||
$alt = $document;
|
||||
$alt['schemaVersion'] = 23;
|
||||
try {
|
||||
$this->pages->saveDraft($id, $alt, $this->pages->findForEditor($id)['version']);
|
||||
self::fail('Eigenes HTML vor Schema v24 hätte abgewiesen werden müssen.');
|
||||
} catch (InvalidDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
public function testOwnHtmlChangesAreComparedAgainstWhatIsStored(): void
|
||||
{
|
||||
// Dieselbe Rolle wie beim eigenen Skript: ändern darf es nur ein Administrator, und
|
||||
// geprüft wird gegen den gespeicherten Stand - ein Redakteur kann die Seite also weiter
|
||||
// speichern, ohne das HTML anfassen zu können.
|
||||
$code = new CustomCode();
|
||||
$stored = DemoContent::starter('Mit HTML');
|
||||
$stored['schemaVersion'] = 24;
|
||||
$stored['blocks'][0]['children'][] = ['id' => 'roh', 'type' => 'html', 'props' => ['html' => '<p>Original</p>']];
|
||||
|
||||
$unchanged = $stored;
|
||||
$unchanged['title'] = 'Anderer Titel';
|
||||
$code->assertHtmlUnchanged($unchanged, $stored);
|
||||
|
||||
foreach (['geändert' => '<p>Fremd</p>', 'geleert' => ''] as $fall => $markup) {
|
||||
$attempt = $stored;
|
||||
$attempt['blocks'][0]['children'][2]['props']['html'] = $markup;
|
||||
try {
|
||||
$code->assertHtmlUnchanged($attempt, $stored);
|
||||
self::fail(sprintf('HTML %s: hätte abgewiesen werden müssen.', $fall));
|
||||
} catch (InvalidDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
// Und neu hinzufügen ebenso wenig.
|
||||
$ohne = DemoContent::starter('Ohne HTML');
|
||||
$ohne['schemaVersion'] = 24;
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$code->assertHtmlUnchanged($stored, $ohne);
|
||||
}
|
||||
|
||||
/** Turns on the consent question and publishes a privacy page it can point to. */
|
||||
private function askConsent(array $groups = ['external']): void
|
||||
{
|
||||
|
||||
@@ -161,7 +161,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
['publisher', 'GET', '/admin/aufgaben'], ['publisher', 'POST', '/admin/aufgaben/ausfuehren'],
|
||||
['publisher', 'GET', '/admin/benutzer'], ['publisher', 'POST', '/admin/benutzer/neu'],
|
||||
// Ein Plugin bringt Code auf die Website: Administrator.
|
||||
['publisher', 'GET', '/admin/plugins'], ['publisher', 'POST', '/admin/plugins/vergleich/an'], ['publisher', 'POST', '/admin/plugins/vergleich/einstellungen'], ['publisher', 'POST', '/admin/plugins/nachtblau/design'],
|
||||
['publisher', 'GET', '/admin/plugins'], ['publisher', 'POST', '/admin/plugins/vergleich/an'], ['publisher', 'POST', '/admin/plugins/vergleich/einstellungen'],
|
||||
['publisher', 'POST', '/admin/pages/p1/delete'],
|
||||
// Alles, was sofort auf der Website steht: Veroeffentlicher.
|
||||
['editor', 'GET', '/admin/settings'], ['editor', 'POST', '/admin/settings/redirects'], ['editor', 'POST', '/admin/settings/rechtliches'], ['editor', 'POST', '/admin/settings/not-found'],
|
||||
|
||||
@@ -98,7 +98,7 @@ final class InstallFlowTest extends TestCase
|
||||
$home = $this->request('/');
|
||||
self::assertSame(200, $home->getStatusCode());
|
||||
self::assertStringContainsString('Beispiel GmbH', $home->getContent());
|
||||
self::assertStringContainsString('Diese Seite ist der Anfang.', $home->getContent());
|
||||
self::assertStringContainsString('Hier entsteht eine neue Webpräsenz.', $home->getContent());
|
||||
// The public page is still a public page: no script, and the header carries the name.
|
||||
self::assertStringContainsString("script-src 'none'", (string) $home->headers->get('Content-Security-Policy'));
|
||||
self::assertSame(200, $this->request('/impressum')->getStatusCode());
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Command\RepairEntitiesCommand;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* `cms:repair:entities` räumt auf, was das doppelte Kodieren vor 1.20.1 hinterlassen hat.
|
||||
*
|
||||
* Geprüft wird beides: dass der Trockenlauf nichts anfasst, und dass das Schreiben genau die
|
||||
* verschachtelten Entities entschachtelt – ein einzelnes `&` ist richtig und muss bleiben.
|
||||
*/
|
||||
final class RepairEntitiesCommandTest extends TestCase
|
||||
{
|
||||
private string $path;
|
||||
private Connection $db;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-repair-');
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
}
|
||||
|
||||
/** Legt eine Seite mit einem Text an und gibt die Kennung der Fassung zurück. */
|
||||
private function seite(string $text): string
|
||||
{
|
||||
$dokument = [
|
||||
'schemaVersion' => 17,
|
||||
'title' => 'Start',
|
||||
'description' => 'Eine Seite zum Reparieren.',
|
||||
'blocks' => [['id' => 's1', 'type' => 'section', 'children' => [
|
||||
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Start', 'level' => 1]],
|
||||
['id' => 't1', 'type' => 'text', 'props' => ['text' => $text]],
|
||||
]]],
|
||||
];
|
||||
// Erst die Seite, dann ihre Fassung: die Fassung zeigt per Fremdschluessel auf die Seite.
|
||||
$revision = bin2hex(random_bytes(16));
|
||||
$this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$this->db->insert('revision', ['id' => $revision, 'page_id' => 'page-1', 'document' => json_encode($dokument, JSON_THROW_ON_ERROR), 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$this->db->update('page', ['published_revision_id' => $revision, 'draft_revision_id' => $revision], ['id' => 'page-1']);
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
private function text(string $revision): string
|
||||
{
|
||||
$dokument = json_decode((string) $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return $dokument['blocks'][0]['children'][1]['props']['text'];
|
||||
}
|
||||
|
||||
private function tester(): CommandTester
|
||||
{
|
||||
return new CommandTester(new RepairEntitiesCommand($this->db, new DocumentValidator()));
|
||||
}
|
||||
|
||||
public function testTheDryRunReportsWithoutChangingAnything(): void
|
||||
{
|
||||
$revision = $this->seite('Freudenstadt &amp; Umgebung');
|
||||
$tester = $this->tester();
|
||||
$tester->execute([]);
|
||||
|
||||
self::assertStringContainsString('1 Stelle', $tester->getDisplay());
|
||||
self::assertSame('Freudenstadt &amp; Umgebung', $this->text($revision), 'Ohne --schreiben bleibt alles stehen.');
|
||||
}
|
||||
|
||||
public function testWritingUnnestsEveryLevelAndLeavesASingleEntityAlone(): void
|
||||
{
|
||||
// Drei Stufen, wie sie auf einer Seite standen, die dreimal gespeichert wurde.
|
||||
$revision = $this->seite('Projekte &amp;amp; Lösungen, „Zitat&quot;, und ein richtiges & dazu');
|
||||
$tester = $this->tester();
|
||||
$tester->execute(['--schreiben' => true]);
|
||||
|
||||
self::assertStringContainsString('repariert', $tester->getDisplay());
|
||||
self::assertSame('Projekte & Lösungen, „Zitat", und ein richtiges & dazu', $this->text($revision));
|
||||
|
||||
// Ein zweiter Lauf findet nichts mehr - und macht aus dem richtigen & kein nacktes &.
|
||||
$zweiter = $this->tester();
|
||||
$zweiter->execute(['--schreiben' => true]);
|
||||
self::assertStringContainsString('Nichts zu reparieren', $zweiter->getDisplay());
|
||||
self::assertSame('Projekte & Lösungen, „Zitat", und ein richtiges & dazu', $this->text($revision));
|
||||
}
|
||||
|
||||
public function testAnUntouchedInstallationSaysSoAndKeepsQuiet(): void
|
||||
{
|
||||
$revision = $this->seite('Alles gut & sauber');
|
||||
$tester = $this->tester();
|
||||
$tester->execute(['--schreiben' => true]);
|
||||
|
||||
self::assertStringContainsString('Nichts zu reparieren', $tester->getDisplay());
|
||||
self::assertSame('Alles gut & sauber', $this->text($revision));
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,17 @@ test('v1 to v2 migration preserves all content and IDs, is idempotent and reject
|
||||
assert.equal(original.schemaVersion, 1);
|
||||
});
|
||||
|
||||
test('the own-HTML block starts empty, is offered in the toolbox and lifts the document to v24', () => {
|
||||
// Eingefuegt wird er leer: bis jemand Code eintraegt, steht nichts auf der Seite.
|
||||
const history = new EditorHistory(fixture());
|
||||
const gruppe = CATEGORIES.find(category => category.types.includes('html'));
|
||||
assert.equal(gruppe?.name, 'Seite und Daten');
|
||||
assert.equal(blockLabels.html, 'Eigenes HTML');
|
||||
const id = history.change(doc => insertBlock(doc, 'html', 's1'));
|
||||
assert.equal(locate(history.document.blocks, id).block.props.html, '');
|
||||
assert.equal(history.document.schemaVersion, 24);
|
||||
});
|
||||
|
||||
test('responsive inheritance, reset, migration undo, and reload preserve distinct device values', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
history.change(doc => setDesign(doc, 'h1', 'desktop', 'fontSize', 'hero'));
|
||||
|
||||
Reference in new issue
Block a user