Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fb53c1fc | ||
|
|
24f98633d0 | ||
|
|
e47f278a13 | ||
|
|
e8a9dcdf5c | ||
|
|
04927b9563 | ||
|
|
3b1dfc0948 | ||
|
|
e21e800f2c | ||
|
|
d8c8cbe7bd | ||
|
|
45d1141951 |
No files matched your search
+22
-2
@@ -25,9 +25,9 @@
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$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"
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-a1c96276da5c/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=24M -d post_max_size=26M -d memory_limit=256M -S 127.0.0.1:8092 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8091
|
||||
"port": 8092
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
@@ -108,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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -284,7 +284,7 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
@@ -381,6 +381,10 @@ Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Ha
|
||||
|
||||
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`.
|
||||
@@ -38,6 +38,9 @@ services:
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Content\DocumentLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
@@ -79,6 +82,11 @@ services:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
# Eigene kurze Zeitgrenzen statt des Download-Dienstes mit denen fuer Releasearchive.
|
||||
App\Ops\OriginCheck:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$download: null
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -128,7 +128,7 @@ Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so e
|
||||
|
||||
**Anlass:** Umut: „URL sollte aus env kommen, www oder ohne, die Seite sollte dann darauf reagieren.“ Bis dahin stand die Umleitung auskommentiert in `public/.htaccess` und musste von Hand zur Einstellung `SITE_ORIGIN` passen (LIVEGANG, Punkt 3). Zwei Stellen für eine Entscheidung laufen auseinander – und dann nennt jede Seite in Canonical, Sitemap und Feed eine Adresse, die weiterleitet.
|
||||
|
||||
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft.
|
||||
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft. Seit 1.21.8 lässt sie sich dort auch ändern, wie die „WordPress-Adresse“: Das Feld schreibt über `Environment::write` in `.env`, aber erst nachdem `OriginCheck` die Installation unter der neuen Adresse erreicht hat (`GET /.well-known/kantsites-origin` liefert einen HMAC aus `APP_SECRET`). Eine Umstellung auf www ohne DNS oder Zertifikat würde sonst jede Seite samt Verwaltung ins Leere leiten. „Trotzdem übernehmen“ erscheint erst nach einer Ablehnung. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld.
|
||||
|
||||
**Bewusst nicht:** ein fremder Host wird nicht umgeleitet. Die Vorschauadresse des Hosters, eine IP-Adresse, `localhost`, eine Subdomain – alles bleibt erreichbar, auch wenn `SITE_ORIGIN` etwas anderes nennt. Sonst sperrte eine falsch eingetragene Adresse jeden aus, samt der Verwaltung, die man bräuchte, um es zu bemerken. Nur lesende Anfragen (GET, HEAD) werden umgeleitet: ein 301 macht aus einem POST ein GET und verwirft die Eingabe. Und HTTPS erzwingt die Anwendung nicht: hinter einem Proxy sieht PHP oft nur `http`, ohne konfigurierte vertrauenswürdige Proxys wäre das eine Schleife. Die drei Zeilen dafür bleiben in `.htaccess`, wo der Webserver die Verbindung wirklich kennt.
|
||||
|
||||
@@ -1054,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.
|
||||
|
||||
|
||||
+57
-1
@@ -1,5 +1,61 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.22.1: vergrößerbare Galerie mit einem Haken (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bei Klick vergrößern bei Galerie muss man da alles einzeln machen? kann man bei Galerie das mit einem Klick für alle elemente machen?“
|
||||
|
||||
- Der Haken „Bilder vergrößerbar“ an der Galerie gilt jetzt für jedes Bild darin, das unter „Beim Klick“ nichts selbst gewählt hat. Das gilt auch für später hinzugefügte Bilder. Bisher schaltete er nur das Blättern im Popup ein, und jedes Bild musste einzeln auf „Bild vergrößern“ gestellt werden.
|
||||
- Eine eigene Wahl geht vor: „Nichts tun“ bleibt still, ein Link bleibt ein Link. „Nie eingestellt“ und „Nichts tun“ sind im Dokument unterscheidbar, weil der Editor `click` erst beim Ändern speichert. Bestehende Galerien ändern sich deshalb nur dort, wo der Haken gesetzt ist und ein Bild noch nie eingestellt wurde.
|
||||
- Der Editor zeigt bei einem solchen Bild „Bild vergrößern“ als aktuelle Wahl, nach derselben Regel wie die Vorlage (`galleryZoom` in `blocks/block.html.twig`).
|
||||
|
||||
**Geprüft:** neuer Test `CmsTest::testAZoomableGalleryMakesEveryImageWithoutItsOwnChoiceZoomable` (ohne Wahl, „Nichts tun“, Link, Galerie ohne Haken), volle Kette vor dem Release.
|
||||
|
||||
## PDFs in der Mediathek, als Linkziel und im Betrachter (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „kann man aktuell PDFs hochladen und als Button verlinken? … und ein Inline PDF Viewer wie Bild-Popup“, danach: „ja setz um, einfacher Browser-Betrachter“.
|
||||
|
||||
- **Hochladen:** Das Upload-Feld der Mediathek nimmt jetzt neben JPEG, PNG und WebP auch PDFs bis 20 MB an. Ob eine Datei ein PDF ist, entscheidet ihr Inhalt (`%PDF-` in den ersten 1024 Bytes), nicht die Endung. Die PDFs stehen in einer eigenen Tabelle `document` (Migration `20260917000100`) und nicht in `media`. Jede Bildauswahl erwartet dort Varianten mit Breite und Höhe. Die Datei liegt unverändert als `<id>.pdf` im Medienordner und geht deshalb mit jeder Sicherung mit. Die Mediathek zeigt die PDFs unter den Bildern, mit Titel und Archivieren.
|
||||
- **Ausliefern:** `/media/<id>.pdf` kommt mit `Content-Type: application/pdf`, `nosniff`, `inline` und dem Titel als Dateinamen, `X-Robots-Tag: noindex` und `frame-ancestors 'self'`: Einbetten darf es nur die eigene Website. Archivierte PDFs bleiben erreichbar, wie Bilder.
|
||||
- **Verlinken:** Jedes Linkziel (Button, Bild, Symbolkarte, CTA, Symbol) hat den Knopf „PDF aus Mediathek wählen“ mit einer Liste, Suche und Hochladen direkt im Dialog. Ins Feld kommt die Adresse `/media/<id>.pdf`, ein gewöhnlicher lokaler Pfad, also ohne neue Schemaversion.
|
||||
- **Betrachter:** `public/assets/lightbox.js` (dieselbe Datei wie der Bildbetrachter, damit eine Seite kein Skript mehr lädt) zeigt ein verlinktes PDF in einem `<dialog>` mit `<iframe>` und nutzt dafür den PDF-Betrachter des Browsers, dazu Herunterladen, „Im neuen Tab“ und Schließen. Wo der Browser keinen eingebauten Betrachter hat (`navigator.pdfViewerEnabled`) oder das Gerät ein Touchgerät ist, öffnet sich das PDF im neuen Tab: Android-Chrome hat keinen Betrachter, iOS-Safari zeigt im Rahmen nur die erste Seite. Ist „In neuem Tab öffnen“ gesetzt oder das Skript nicht geladen, bleibt es ein normaler Link. Skript und `frame-src 'self'` kommen nur auf Seiten, deren Dokumente (auch Kopf, Fuß, Sektionen und Fließtext) auf ein PDF der Mediathek zeigen.
|
||||
- **Grenzen:** Hochgeladene PDFs sind über ihre Adresse öffentlich. Kein PDF.js, also sieht der Betrachter je nach Browser anders aus. Chrome zeigt im Kopf des Betrachters die Kennung aus der Adresse, nicht den Titel. PDFs werden nicht gelöscht, nur archiviert.
|
||||
|
||||
**Geprüft:** zwei neue Tests (`CmsTest::testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer`, `EditorSecurityTest::testPdfsGoThroughTheMediaUploadAndTheEditorPicker`: Ablehnung von Nicht-PDFs, Kopfzeilen, 404, Skript und Richtlinie nur mit PDF-Link, Formular-Upload, API mit Token, Archiv). Im Browser mit der Pilotkopie: Klick auf den Button öffnet das zweiseitige PDF im Popup, Schließen gibt den Fokus an den Button zurück, bei 375 px mit Touch öffnet es einen neuen Tab. Der Auswahldialog im Editor ist nur per Syntaxprüfung geprüft, nicht im Browser. Dafür wäre eine Anmeldung nötig.
|
||||
|
||||
## Siebte Installation: Landingpage „KANTARCI Immobilien – Mietanfrage“ (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „neues Projekt wir brauchen eine einfache landingpage nach gleichem prinzip. einfache landingpage impressum und datenschutz notwendig? baus ein“ – dazu das Paket `KANTARCI-Immobilien-Landingpage.zip` (Next.js/Vinext für Cloudflare mit D1-Datenbank: Kopf, dunkler Hero, Werteleiste, Mietanfrage, Fuß, ein Banner als einziges Bild). Später: „Formular gefällt mir noch nicht kannst du ein multipage formular machen ggfls mit icon checkbox“ und die Anbieterdaten (Geschäftsführer Ridvan Kantarci, Ooser Hauptstr. 51, 76532 Baden-Baden, info@kantarci-immo.de).
|
||||
|
||||
**Neu.** Datenbank `var/kantarci-immobilien.db`, Medien `var/kantarci-immobilien-media`, Bildquellen `var/kantarci-immobilien-assets` (das Banner, daraus zugeschnitten Logo, Hausmotiv und Seitensymbol, dazu `options/` mit 14 Bildkarten), Startkonfigurationen `kantarci-immobilien` (Port 8098, dev) und `kantarci-immobilien-prod` (Port 8099). `sh scripts/reset-kantarci-immobilien.sh` schneidet die Bilder zu (`scripts/build-kantarci-assets.php`, GD), zeichnet die Bildkarten (`node scripts/build-kantarci-options.mjs`, Chromium), migriert und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf, Fuß, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` auf. Schriften Arial und Georgia (System).
|
||||
|
||||
- **Impressum und Datenschutz sind Pflicht:** geschäftlicher Auftritt (§ 5 DDG) und ein Formular mit personenbezogenen Daten (Art. 13 DSGVO). Offen und im Text als „NOCH ZU PRÜFEN“ markiert: Handelsregister, Telefonnummer, USt-IdNr., Erlaubnis nach § 34c GewO (nur bei Vermittlung/Verwaltung für Dritte), Urheber des Hausfotos, Hosting- und Mail-Auftragsverarbeiter, Löschfrist.
|
||||
- **Mietanfrage in drei Schritten** mit Zusammenfassung: Haushalt (Bildkarten Allein, Zu zweit, Familie mit Kindern, Wohngemeinschaft; Personenzahl nur bei den beiden letzten), Wohnwünsche (Zimmer als Bildkarten, Ausstattung als Mehrfachauswahl mit sechs Bildkarten, Einzug, Warmmiete, Objekt), Kontaktdaten. Der Einzug ist eine Auswahl statt eines Datums – der Formularbaukasten kennt kein Datumsfeld.
|
||||
- **E-Mail:** Die Vorlage nennt `immo@kantarci-immobilien.de`, die Domain existiert aber nicht (kein DNS, 18.09.2026). Überall steht jetzt `info@kantarci-immo.de` (MX vorhanden), auch als Empfänger der Anfrage.
|
||||
- **CMS-Lücken beim Aufbau:** eigenes CSS kennt kein `@media` – Raster, die am Telefon einspaltig werden sollen, laufen über `repeat(auto-fill,minmax(…))`. Verschachtelte Selektoren erlauben keine Umlaute, also auch kein `[data-label="Gewünschter Einzug"]` (Feld heißt deshalb „Einzugstermin“). Die Zusammenfassung ist selbst ein `.form-step`; wer die Schritte zweispaltig setzt, muss `.form-summary-body` über beide Spalten legen. `<small>` in einem Textbaustein wird entfernt. Die Fehlermeldung des Formulars duzt („Bitte prüfe …“), die Seite siezt.
|
||||
|
||||
**Geprüft:** Aufbau per Reset-Skript fehlerfrei; Startseite, Impressum, Datenschutz 200, unbekannte Adresse 404; im Browser (1280 px und 375 px, kein seitliches Scrollen) alle drei Schritte, Bedingung Personenzahl, Zusammenfassung und Absenden – die Anfrage liegt vollständig in `form_submission`. Mailversand ist lokal nicht eingerichtet. Lighthouse und axe noch nicht gelaufen.
|
||||
|
||||
## Adresse der Website in der Verwaltung (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: „ja bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird und übernommen wird“ – mit oder ohne www wie die „WordPress-Adresse“, statt nur über `cms:site:origin` oder die `.env` von Hand.
|
||||
|
||||
- **System → Diese Installation → Adresse** hat ein Feld „Adresse der Website“ und einen Link, der direkt auf die andere Schreibweise (mit/ohne www) umstellt. Gespeichert wird `SITE_ORIGIN` in `.env` über `Environment::write`. Das gilt ab der nächsten Anfrage, weil `%env()%` zur Laufzeit gelesen wird. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld, und ein POST wird abgewiesen.
|
||||
- **Schutz vor dem Aussperren:** Vor dem Speichern fragt die Installation sich selbst unter der neuen Adresse an (`OriginCheck`, `GET /.well-known/kantsites-origin`). Zurückkommen muss ein HMAC aus `APP_SECRET`, den nur diese Installation kennt. Ohne DNS-Eintrag oder Zertifikat für www wird nichts geändert – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen. Erst nach einer Ablehnung erscheint „Trotzdem übernehmen“ (etwa wenn der Server sich selbst nicht erreicht, oder bei http im lokalen Betrieb). Die Prüfadresse ist auch im Wartungszustand erreichbar.
|
||||
- Wechselt die Verwaltung dadurch den Host, ist dort eine neue Anmeldung nötig. Das Sitzungscookie gilt nur für die Schreibweise, unter der es entstand. Die Meldung sagt das.
|
||||
|
||||
**Geprüft:** zwei neue Tests in `SystemScreenTest` (Server-Umgebung ohne Feld, Ablehnung einer nicht erreichbaren Adresse ohne Schreiben in `.env`, falsche Eingaben, Prüfadresse; `OriginCheck` mit fremder und eigener Kennung). Im Browser mit der Pilotkopie bei 1280 und 375 px (kein seitliches Scrollen) Feld, Ablehnung und „Trotzdem übernehmen“ angesehen.
|
||||
|
||||
## Favicon auch für die Google-Suche (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: Google zeigt bei Elektro Bernhard weiter das KantSites-K, obwohl ein eigenes Symbol gesetzt ist – „mach das im Kern sobald man Favicon setzt alles ersetzen“.
|
||||
|
||||
- **Ursache:** Das eigene Symbol gab es nur in 32 und 180 px, Google nimmt nur Vielfache von 48 px. Und `/favicon.svg` lag als Datei in `public/`, der Webserver lieferte also immer das K aus – dort hatte Google es beim ersten Besuch gefunden.
|
||||
- **Neu:** `FAVICON_SIZES` sind 32, 48, 180 und 192. Der Kopf verweist zusätzlich auf `/favicon-192.png` (`sizes="192x192"`). `/favicon.ico` liefert das 48er-PNG. `/favicon.svg` beantwortet jetzt die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen (liegt jetzt unter `public/assets/favicon.svg`), mit eigenem ein 302 auf `/favicon-192.png`. Fehlen bei älteren Installationen die neuen Größen, entstehen sie beim ersten Abruf aus der gespeicherten Einstellung. Wird das Symbol entfernt, gelten die liegengebliebenen PNGs nicht mehr.
|
||||
- **Update:** Das Archiv-Update kopiert `public/` nur hinzu; `ENTFALLEN_IN_PUBLIC` löscht deshalb die alte `public/favicon.svg`, sonst bliebe sie auf dem Server und überdeckte die Route.
|
||||
- **Danach bei Google:** In der Search Console die Startseite unter „URL prüfen“ neu zur Indexierung einreichen. Google holt Favicons selten neu ab, das kann trotzdem Tage bis Wochen dauern.
|
||||
|
||||
**Geprüft:** neuer Test `testGoogleGetsTheOwnSiteIconUnderEveryAddress` und der bisherige Favicon-Test grün, Update-Tests unverändert (eine vorbestehende Warnung), php-cs-fixer sauber; lokal (ohne eigenes Symbol) `/favicon.svg` 200, `/favicon.ico` → `/favicon.svg`.
|
||||
|
||||
## Favicon mit Zuschnitt, Pflichtseiten per Suche, neueste zuerst (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Was ein Dropdown? nicht dein ernst? Sofort fixen über Mediathek auswählbar machen zurecht schneidbar machen und auch uploadbar dort machen!“ – dazu „Genauso Adresse Datenschutz und Impressum über search felder auswählbar machen“ und „Beiträge und Seiten sortieren absteigend also neueste zuerst“.
|
||||
@@ -1037,7 +1093,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Mediathek derzeit für statische Bilder und PDFs (seit 22.09.2026): keine Videos/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
|
||||
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260917000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Dokumente: PDFs in der Mediathek, zum Verlinken und fuer den Betrachter.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Eine eigene Tabelle statt einer Art-Spalte in media: jede Stelle, die ein Bild sucht,
|
||||
// erwartet Varianten mit Breite und Hoehe. Ein PDF dort waere ein Bild ohne Bild - an
|
||||
// einem Dutzend Stellen. Getrennt kann keine Bildauswahl je ein Dokument finden.
|
||||
$document = $schema->createTable('document');
|
||||
$document->addColumn('id', 'string', ['length' => 32]);
|
||||
$document->addColumn('title', 'string', ['length' => 160]);
|
||||
$document->addColumn('bytes', 'integer');
|
||||
$document->addColumn('archived', 'boolean', ['default' => false]);
|
||||
$document->addColumn('version', 'integer', ['default' => 1]);
|
||||
$document->addColumn('created_at', 'datetime_immutable');
|
||||
$document->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('document');
|
||||
}
|
||||
}
|
||||
@@ -684,6 +684,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts dt { color: var(--muted) }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||
/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */
|
||||
.origin-form { margin-top: 4px; max-width: 560px }
|
||||
.origin-form label:first-of-type { margin-top: 10px }
|
||||
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
|
||||
.card-body .origin-row .secondary { margin: 0 }
|
||||
.origin-form .check-label { margin-top: 10px }
|
||||
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
|
||||
@@ -967,3 +975,5 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
+19
-9
@@ -1,5 +1,5 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -330,7 +330,14 @@ function renderContent(block, panel) {
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
if (key === 'href') {
|
||||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => { input.value = item.src; applyValue(); history.mergeKey = null; } }));
|
||||
container.append(pdf);
|
||||
field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
}
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
@@ -373,7 +380,7 @@ function renderContent(block, panel) {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'stat') {
|
||||
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
|
||||
@@ -389,7 +396,7 @@ function renderContent(block, panel) {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'contact') {
|
||||
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
|
||||
@@ -410,7 +417,7 @@ function renderContent(block, panel) {
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'social') {
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
|
||||
@@ -527,16 +534,19 @@ function renderContent(block, panel) {
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
||||
if (block.type === 'image') {
|
||||
renderImageWidth(block, panel);
|
||||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||
panel.append(imagePreview.element);
|
||||
const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options';
|
||||
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
|
||||
// Ohne eigene Wahl folgt das Bild seiner Galerie - dieselbe Regel wie in der Vorlage.
|
||||
const holder = locate(history.document.blocks, block.id)?.parent;
|
||||
const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true;
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine'));
|
||||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
|
||||
showLinkOptions(clickAction); panel.append(linkOptions);
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
@@ -551,7 +561,7 @@ function renderContent(block, panel) {
|
||||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||
}
|
||||
if (block.type === 'gallery') {
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' });
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Gilt für alle Bilder der Galerie: ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Ein Bild mit eigener Einstellung unter „Beim Klick“ behält sie.' });
|
||||
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
|
||||
File renamed without changes.
@@ -99,3 +99,86 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/*
|
||||
* Der PDF-Betrachter - in derselben Datei wie der Bildbetrachter, damit eine Seite mit beidem
|
||||
* nicht zwei Skripte laedt.
|
||||
*
|
||||
* Ohne dieses Skript ist ein Link auf ein PDF ein gewöhnlicher Link: der Browser öffnet die
|
||||
* Datei. Das Skript zeigt sie stattdessen in einem Rahmen über der Seite, mit dem Betrachter,
|
||||
* den der Browser ohnehin mitbringt - kein eigener Nachbau, der ein Megabyte wiegt.
|
||||
*
|
||||
* Wo der Browser keinen solchen Betrachter hat oder ihn im Rahmen nur halb zeigt, bleibt es beim
|
||||
* neuen Tab: Chrome auf Android hat keinen, und Safari auf dem iPhone zeigt im Rahmen nur die
|
||||
* erste Seite. Ein Link mit „In neuem Tab öffnen“ bleibt ohnehin, was er ist.
|
||||
*/
|
||||
(() => {
|
||||
const PDF = /^\/media\/[a-f0-9]{32}\.pdf$/;
|
||||
const links = [...document.querySelectorAll('a[href]')].filter(link => {
|
||||
const url = new URL(link.href, location.href);
|
||||
|
||||
return url.origin === location.origin && PDF.test(url.pathname) && '_blank' !== link.target;
|
||||
});
|
||||
if (0 === links.length) {
|
||||
return;
|
||||
}
|
||||
const eingebettet = false !== navigator.pdfViewerEnabled && !matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
let dialog = null;
|
||||
let rahmen = null;
|
||||
let titel = null;
|
||||
let laden = null;
|
||||
|
||||
const baue = () => {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'pdf-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'pdf-dialog-title');
|
||||
dialog.innerHTML = `
|
||||
<div class="pdf-bar">
|
||||
<p class="pdf-title" id="pdf-dialog-title"></p>
|
||||
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
||||
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
|
||||
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
||||
`;
|
||||
document.body.append(dialog);
|
||||
rahmen = dialog.querySelector('.pdf-frame');
|
||||
titel = dialog.querySelector('.pdf-title');
|
||||
laden = [dialog.querySelector('.pdf-download'), dialog.querySelector('.pdf-open')];
|
||||
dialog.querySelector('.pdf-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
// Der Rahmen wird geleert, sonst läuft ein eingebettetes Video oder Audio im PDF weiter
|
||||
// und das nächste Öffnen zeigt kurz das vorige Dokument.
|
||||
dialog.addEventListener('close', () => rahmen.removeAttribute('src'));
|
||||
};
|
||||
|
||||
for (const link of links) {
|
||||
link.addEventListener('click', event => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!eingebettet) {
|
||||
window.open(link.href, '_blank', 'noopener');
|
||||
|
||||
return;
|
||||
}
|
||||
if (null === dialog) {
|
||||
baue();
|
||||
}
|
||||
const name = (link.getAttribute('aria-label') || link.textContent || '').trim();
|
||||
titel.textContent = name || 'Dokument';
|
||||
rahmen.title = name ? `PDF: ${name}` : 'PDF-Dokument';
|
||||
for (const aktion of laden) {
|
||||
aktion.href = link.href;
|
||||
}
|
||||
rahmen.src = link.href;
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -28,3 +28,13 @@
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
/* PDFs: eine Liste statt eines Rasters - es gibt nichts zu sehen außer dem Titel. */
|
||||
.document-dialog{max-width:720px}
|
||||
.document-list{display:grid;gap:8px;max-height:360px;overflow:auto;padding:4px}
|
||||
.document-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;border:2px solid var(--line);border-radius:9px;padding:10px 12px;background:white;color:var(--ink);text-align:left}
|
||||
.document-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.document-card strong{font-size:13px;overflow-wrap:anywhere}
|
||||
.document-card span{font-size:11px;color:var(--muted)}
|
||||
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
|
||||
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
.href-document{margin:6px 0 4px}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dragging images onto the upload field, and saying which files are about to go up.
|
||||
* Dragging images and PDFs onto the upload field, and saying which files are about to go up.
|
||||
*
|
||||
* Everything here is an addition. Without this script the field is still a file input with a
|
||||
* label: clicking it opens the file picker and the form submits the same way. The script never
|
||||
@@ -9,19 +9,19 @@ const zone = document.querySelector('[data-dropzone]');
|
||||
const input = zone?.querySelector('input[type="file"]');
|
||||
const chosen = document.querySelector('[data-chosen]');
|
||||
if (zone && input && chosen) {
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
|
||||
const describe = (files, ignored = 0) => {
|
||||
if (!files.length) {
|
||||
chosen.textContent = ignored
|
||||
? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.`
|
||||
? `Keine passende Datei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG, WebP und PDF.`
|
||||
: 'Noch keine Datei gewählt.';
|
||||
return;
|
||||
}
|
||||
const names = [...files].map(file => file.name).join(', ');
|
||||
const size = [...files].reduce((total, file) => total + file.size, 0);
|
||||
chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}`
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : '');
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG, WebP und PDF.` : '');
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => describe(input.files));
|
||||
|
||||
@@ -140,4 +140,70 @@ export function openMedia({ multiple = false, onChoose, needsAlt = true } = {})
|
||||
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF aus der Mediathek wählen - oder eines hochladen und gleich wählen.
|
||||
*
|
||||
* Ein eigener, schmaler Dialog statt einer Betriebsart des Bilderdialogs: ein PDF hat keine
|
||||
* Vorschau, keinen Alternativtext und keine Mehrfachauswahl. Gewählt wird die Adresse, die dann
|
||||
* im Linkziel steht.
|
||||
*/
|
||||
export function openDocuments({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog document-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'document-title');
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">DOKUMENTE</p><h2 id="document-title">PDF wählen</h2></div><button class="secondary" id="document-close">Schließen</button></div>
|
||||
<p class="panel-hint">PDF bis 20 MB. Es wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar – nichts Vertrauliches hochladen. Ohne „In neuem Tab öffnen“ zeigt die Website es in einem Betrachter über der Seite.</p>
|
||||
<div class="media-tools"><div><label for="document-search">PDFs suchen</label><input id="document-search" type="search" placeholder="Titel"></div><div><label for="document-upload">PDF hochladen</label><input id="document-upload" type="file" accept="application/pdf,.pdf" disabled></div></div>
|
||||
<p id="document-status" role="status" aria-live="polite">Wird geladen …</p><p id="document-error" class="media-error" role="alert" hidden></p>
|
||||
<div id="document-list" class="document-list" role="group" aria-label="PDF auswählen"></div>
|
||||
<div class="media-footer"><span></span><button id="document-insert" class="primary" disabled>PDF übernehmen</button></div>`;
|
||||
document.body.append(dialog);
|
||||
const $ = selector => dialog.querySelector(selector);
|
||||
let items = [], chosen = null, token;
|
||||
const error = message => { $('#document-error').textContent = message; $('#document-error').hidden = !message; };
|
||||
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
|
||||
const base = document.querySelector('meta[name="admin-base"]').content + '/api/dokumente';
|
||||
function render() {
|
||||
const query = $('#document-search').value.toLocaleLowerCase('de');
|
||||
const visible = items.filter(item => !item.archived && item.title.toLocaleLowerCase('de').includes(query));
|
||||
$('#document-list').replaceChildren();
|
||||
for (const item of visible) {
|
||||
const card = element('button', '', 'document-card'); card.type = 'button';
|
||||
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
|
||||
card.append(element('span', 'PDF', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
|
||||
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
|
||||
card.addEventListener('dblclick', () => { chosen = item; $('#document-insert').click(); });
|
||||
$('#document-list').append(card);
|
||||
}
|
||||
if (!visible.length) $('#document-list').append(element('p', items.length ? 'Kein PDF mit diesem Titel.' : 'Noch keine PDFs. Lade oben eines hoch.', 'muted'));
|
||||
$('#document-status').textContent = `${items.filter(item => !item.archived).length} PDFs${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
|
||||
$('#document-insert').disabled = !chosen;
|
||||
}
|
||||
$('#document-search').addEventListener('input', render);
|
||||
$('#document-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
$('#document-upload').addEventListener('change', async () => {
|
||||
const file = $('#document-upload').files[0]; if (!file) return;
|
||||
error('');
|
||||
if (file.size > 20 * 1024 * 1024) { error(`${file.name}: größer als 20 MB`); $('#document-upload').value = ''; return; }
|
||||
$('#document-status').textContent = `Wird hochgeladen: ${file.name}`;
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
|
||||
items.unshift(data.item); chosen = data.item;
|
||||
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||
$('#document-upload').value = ''; render();
|
||||
});
|
||||
$('#document-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
|
||||
dialog.showModal();
|
||||
fetch(base, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('PDFs konnten nicht geladen werden. Bitte neu anmelden.');
|
||||
// Hochladen erst mit Token - sonst weist der Server eine zu früh gewählte Datei ab.
|
||||
const data = await response.json(); items = data.items; token = data.csrfToken; $('#document-upload').disabled = false; render();
|
||||
}).catch(err => { $('#document-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||
@@ -557,6 +557,19 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
|
||||
.zoom-prev{left:8px}.zoom-next{right:8px}
|
||||
}
|
||||
/* Der PDF-Betrachter: fast bildschirmfüllend, mit einer schmalen Leiste darüber. Den Rest macht
|
||||
der Betrachter des Browsers - Blättern, Zoom, Drucken. */
|
||||
.pdf-dialog{border:0;padding:0;width:min(96vw,1200px);height:94vh;max-width:none;max-height:none;background:#1b212c;color:#fff;border-radius:12px;overflow:hidden}
|
||||
.pdf-dialog[open]{display:flex;flex-direction:column}
|
||||
.pdf-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
.pdf-bar{display:flex;align-items:center;gap:10px;padding:10px 10px 10px 18px}
|
||||
.pdf-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#fff}
|
||||
.pdf-action{color:#fff;font-size:13px;text-decoration:none;padding:9px 14px;border-radius:999px;background:rgba(255,255,255,.12);white-space:nowrap}
|
||||
.pdf-action:hover{background:rgba(255,255,255,.24)}
|
||||
.pdf-close{display:grid;place-items:center;width:40px;height:40px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:24px;line-height:1;cursor:pointer}
|
||||
.pdf-close:hover{background:rgba(255,255,255,.26)}
|
||||
.pdf-action:focus-visible,.pdf-close:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||
.pdf-frame{flex:1;width:100%;border:0;background:#525659}
|
||||
|
||||
.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none}
|
||||
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
@@ -360,6 +360,22 @@ try {
|
||||
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
|
||||
return Boolean(previous?.classList.contains('button'));
|
||||
}, headingId);
|
||||
// Ein PDF als Linkziel: im Dialog hochladen, übernehmen, die Adresse steht im Feld.
|
||||
await page.getByRole('button', { name: 'PDF aus Mediathek wählen', exact: true }).click();
|
||||
const documentDialog = page.locator('dialog.document-dialog');
|
||||
await documentDialog.waitFor();
|
||||
await documentDialog.locator('#document-upload:enabled').waitFor({ state: 'attached' });
|
||||
await documentDialog.locator('#document-upload').setInputFiles({ name: 'Preisliste.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF\n') });
|
||||
await documentDialog.locator('.document-card[aria-pressed="true"]').waitFor();
|
||||
assert.equal(await documentDialog.locator('#document-error').isHidden(), true, 'the PDF upload reports no error');
|
||||
const pdfAxe = await new AxeBuilder({ page }).include('dialog.document-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(pdfAxe.violations.map(v => v.id), [], 'the PDF dialog has no accessibility violations');
|
||||
await documentDialog.getByRole('button', { name: 'PDF übernehmen', exact: true }).click();
|
||||
await documentDialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/);
|
||||
await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? ''));
|
||||
checks.push({ name: 'a PDF from the library as a link target', passed: true });
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Wirft die KANTARCI-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/kantarci-immobilien.db"
|
||||
export MEDIA_DIRECTORY="$root/var/kantarci-immobilien-media"
|
||||
rm -f "$root/var/kantarci-immobilien.db"
|
||||
rm -rf "$root/var/kantarci-immobilien-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/kantarci-immobilien-assets}"
|
||||
# Logo, Hausmotiv und Symbol sind Zuschnitte aus dem gelieferten Banner, die Bildkarten
|
||||
# der Mietanfrage zeichnet Chromium (Playwright).
|
||||
"$php" scripts/build-kantarci-assets.php "$assets/kantarci-banner.jpeg" "$assets"
|
||||
node scripts/build-kantarci-options.mjs "$assets/options"
|
||||
# Die Vorlage nutzt Arial und Georgia; es gibt keine Schrift zu holen.
|
||||
"$php" -d memory_limit=512M scripts/seed-kantarci-immobilien.php "$assets"
|
||||
@@ -0,0 +1,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,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* PDFs neben den Bildern der Mediathek.
|
||||
*
|
||||
* Ein Bild wird beim Hochladen neu berechnet und nur in dieser Fassung ausgeliefert - was auch
|
||||
* immer in der Datei steckte, ist danach weg. Bei einem PDF geht das nicht: es wird so
|
||||
* ausgeliefert, wie es kam. Deshalb die Pruefung am Anfang der Datei statt an der Endung, und
|
||||
* beim Ausliefern Kopfzeilen, die den Browser nichts anderes darin sehen lassen als ein PDF.
|
||||
*
|
||||
* Die Dateien liegen im Medienordner, als <id>.pdf - so gehen sie mit jeder Sicherung mit.
|
||||
*/
|
||||
final class DocumentLibrary
|
||||
{
|
||||
/** Ein Prospekt oder eine Preisliste, kein Katalog mit tausend Fotos. */
|
||||
public const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/** Die Adresse eines Dokuments. So steht sie auch in einem Link-Feld. */
|
||||
public const PATH = '#^/media/([a-f0-9]{32})\.pdf$#D';
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> neueste zuerst */
|
||||
public function list(bool $withArchived = true): array
|
||||
{
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM document'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC',
|
||||
));
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||
return null;
|
||||
}
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM document WHERE id = ?', [$id]);
|
||||
|
||||
return $row ? $this->present($row) : null;
|
||||
}
|
||||
|
||||
public function upload(UploadedFile $file): array
|
||||
{
|
||||
if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) {
|
||||
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 20 MB pro PDF; das PHP-Hosting kann ein kleineres Limit setzen.');
|
||||
}
|
||||
// Ein PDF beginnt mit %PDF- - in den ersten 1024 Bytes, so lesen es auch die Betrachter.
|
||||
// Die Endung oder der Typ, den der Browser mitschickt, sagen darueber nichts.
|
||||
$head = (string) file_get_contents($file->getPathname(), false, null, 0, 1024);
|
||||
if (!str_contains($head, '%PDF-')) {
|
||||
throw new \InvalidArgumentException('Das ist kein PDF. Bitte eine PDF-Datei wählen.');
|
||||
}
|
||||
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$path = $this->path($id);
|
||||
if (!@copy($file->getPathname(), $path) || !is_file($path)) {
|
||||
throw new \RuntimeException('Das Dokument konnte nicht gespeichert werden.');
|
||||
}
|
||||
try {
|
||||
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Dokument', 0, 160, 'UTF-8');
|
||||
$row = ['id' => $id, 'title' => $title, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||
$this->db->insert('document', $row);
|
||||
} catch (\Throwable $error) {
|
||||
unlink($path);
|
||||
throw $error;
|
||||
}
|
||||
|
||||
return $this->present($row);
|
||||
}
|
||||
|
||||
public function update(string $id, array $data): array
|
||||
{
|
||||
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||
throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.');
|
||||
}
|
||||
$count = $this->db->executeStatement('UPDATE document SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]);
|
||||
if (1 !== $count) {
|
||||
throw new StaleWrite();
|
||||
}
|
||||
|
||||
return $this->find($id) ?? throw new StaleWrite();
|
||||
}
|
||||
|
||||
/** Die Datei eines Dokuments, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */
|
||||
public function file(string $id): ?string
|
||||
{
|
||||
if (null === $this->find($id)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->path($id);
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
private function path(string $id): string
|
||||
{
|
||||
return $this->directory.'/'.$id.'.pdf';
|
||||
}
|
||||
|
||||
private function present(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => $row['id'],
|
||||
'title' => $row['title'],
|
||||
'bytes' => (int) $row['bytes'],
|
||||
'archived' => (bool) $row['archived'],
|
||||
'version' => (int) $row['version'],
|
||||
'created_at' => $row['created_at'],
|
||||
'src' => '/media/'.$row['id'].'.pdf',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -215,8 +215,12 @@ 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.
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
final class MediaController extends AbstractController
|
||||
{
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -26,7 +29,7 @@ final class MediaController extends AbstractController
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query];
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||
@@ -52,16 +55,18 @@ final class MediaController extends AbstractController
|
||||
$done = 0;
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$this->media->upload($file);
|
||||
// Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der
|
||||
// Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt.
|
||||
$this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file);
|
||||
++$done;
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Bild(er) davor wurden hochgeladen.', $done) : ''));
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : ''));
|
||||
} catch (\RuntimeException) {
|
||||
return $this->back($request, 'Das Bild konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.');
|
||||
return $this->back($request, sprintf('%s konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.', $file->getClientOriginalName()));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
|
||||
return $this->back($request, sprintf('%d Datei(en) hochgeladen.', $done), 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
@@ -90,6 +95,38 @@ final class MediaController extends AbstractController
|
||||
return $this->back($request, 'Die Bildangaben wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/dokument/{id}', name: 'admin_document_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdateDocument(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$this->documents->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
try {
|
||||
$this->documents->update($id, [
|
||||
'title' => $request->request->getString('title'),
|
||||
'archived' => $request->request->getBoolean('archived'),
|
||||
'version' => $request->request->getInt('version'),
|
||||
]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->back($request, 'Das Dokument wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
private function isPdf(UploadedFile $file): bool
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
|
||||
return 'application/pdf' === $mime || (!str_starts_with((string) $mime, 'image/') && 'pdf' === strtolower($file->getClientOriginalExtension()));
|
||||
}
|
||||
|
||||
/** Back to the page the form was on, so a change does not jump to the first page. */
|
||||
private function back(Request $request, string $message, string $type = 'error'): Response
|
||||
{
|
||||
@@ -111,6 +148,53 @@ final class MediaController extends AbstractController
|
||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
/** Die PDFs fuer die Auswahl im Link-Feld. Wenige genug, um sie auf einmal zu schicken. */
|
||||
#[Route('/%kant.admin_path%/api/dokumente', methods: ['GET'])]
|
||||
public function listDocuments(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
return $this->json(['items' => $this->documents->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/dokumente/upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/dokumente/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function writeDocument(Request $request, ?string $id = null): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
|
||||
}
|
||||
try {
|
||||
if (null === $id) {
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->documents->upload($file)], 201);
|
||||
}
|
||||
if (!$this->documents->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
if (strlen($request->getContent()) > 4096) {
|
||||
throw new \InvalidArgumentException('Angaben zum Dokument sind zu groß.');
|
||||
}
|
||||
$data = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($data)) {
|
||||
throw new \InvalidArgumentException('Ungültige Angaben zum Dokument.');
|
||||
}
|
||||
|
||||
return $this->json(['item' => $this->documents->update($id, $data)]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->json(['error' => 'Das Dokument wurde inzwischen geändert. Die Auswahl bitte neu öffnen.'], 409);
|
||||
} catch (\InvalidArgumentException|\JsonException $error) {
|
||||
return $this->json(['error' => $error->getMessage()], 422);
|
||||
} catch (\RuntimeException) {
|
||||
return $this->json(['error' => 'Das Dokument konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
@@ -159,21 +243,79 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Symbol der Website, in den beiden Größen, die Browser abfragen.
|
||||
* Das Symbol der Website, in den Größen, die Browser und Suchmaschinen abfragen.
|
||||
*
|
||||
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
|
||||
* favicon.svg, auf das die Seiten weiterhin verweisen.
|
||||
* Zeichen unter /favicon.svg. /favicon.ico fragen Crawler auf gut Glück ab; es bekommt das
|
||||
* 48er-PNG, das liest jeder Browser auch unter diesem Namen.
|
||||
*/
|
||||
#[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])]
|
||||
#[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])]
|
||||
public function favicon(Request $request): Response
|
||||
#[Route('/favicon.png', name: 'favicon_png', defaults: ['size' => 32], methods: ['GET'])]
|
||||
#[Route('/favicon-192.png', name: 'favicon_192', defaults: ['size' => 192], methods: ['GET'])]
|
||||
#[Route('/favicon.ico', name: 'favicon_ico', defaults: ['size' => 48], methods: ['GET'])]
|
||||
#[Route('/apple-touch-icon.png', name: 'favicon_touch', defaults: ['size' => 180], methods: ['GET'])]
|
||||
public function favicon(int $size): Response
|
||||
{
|
||||
$size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32;
|
||||
$path = $this->media->faviconFile($size) ?? throw $this->createNotFoundException();
|
||||
$path = $this->faviconFile($size);
|
||||
if (null === $path) {
|
||||
if (48 === $size) {
|
||||
return $this->redirect('/favicon.svg');
|
||||
}
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das mitgelieferte Zeichen - aber nur, solange kein eigenes Symbol gesetzt ist.
|
||||
*
|
||||
* Früher lag es als Datei in public/ und kam damit immer, auch nach dem Wechsel. Google hatte
|
||||
* es beim ersten Besuch gefunden und zeigte es weiter. Jetzt führt die Adresse zum eigenen
|
||||
* Symbol, vorübergehend (302), damit ein Zurückwechseln im Browser wieder ankommt.
|
||||
*/
|
||||
#[Route('/favicon.svg', name: 'favicon_svg', methods: ['GET'])]
|
||||
public function faviconSvg(): Response
|
||||
{
|
||||
if (null !== $this->faviconFile(192)) {
|
||||
$response = $this->redirect('/favicon-192.png?v='.$this->siteSettings->load()['faviconVersion'], 302);
|
||||
$response->headers->set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
return new BinaryFileResponse($this->getParameter('kernel.project_dir').'/public/assets/favicon.svg', headers: ['Content-Type' => 'image/svg+xml', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=3600']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Symboldatei, oder null ohne eigenes Symbol.
|
||||
*
|
||||
* Die Größen 48 und 192 kamen später dazu. Wer sein Symbol vorher gesetzt hat, hat nur 32
|
||||
* und 180 - die fehlenden entstehen beim ersten Abruf aus derselben Einstellung, statt dass
|
||||
* erst jemand die Einstellungen neu speichern muss.
|
||||
*/
|
||||
private function faviconFile(int $size): ?string
|
||||
{
|
||||
// Zuerst die Einstellung: wird das Symbol entfernt, bleiben die Dateien liegen, gelten
|
||||
// aber nicht mehr.
|
||||
$settings = $this->siteSettings->load();
|
||||
$favicon = (string) ($settings['faviconMediaId'] ?? '');
|
||||
if ('' === $favicon) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->media->faviconFile($size);
|
||||
if (null !== $path || !in_array($size, MediaLibrary::FAVICON_SIZES, true) || null === $this->media->find($favicon)) {
|
||||
return $path;
|
||||
}
|
||||
$crop = (string) ($settings['faviconCrop'] ?? '');
|
||||
try {
|
||||
$this->media->renderFavicon($favicon, '' === $crop ? null : array_map('floatval', explode(',', $crop)));
|
||||
} catch (\RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->media->faviconFile($size);
|
||||
}
|
||||
|
||||
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
|
||||
public function serve(string $id, int $width): Response
|
||||
{
|
||||
@@ -181,4 +323,29 @@ final class MediaController extends AbstractController
|
||||
|
||||
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF, so wie es hochgeladen wurde.
|
||||
*
|
||||
* inline, damit der Browser es anzeigt statt es abzulegen - mit dem Titel als Dateinamen fuer
|
||||
* den Fall, dass jemand es speichert. Einbetten darf es nur die eigene Website: der
|
||||
* Betrachter zeigt es in einem Rahmen, eine fremde Seite soll das nicht koennen.
|
||||
*/
|
||||
#[Route('/media/{id}.pdf', name: 'document_serve', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function serveDocument(string $id): Response
|
||||
{
|
||||
$path = $this->documents->file($id) ?? throw $this->createNotFoundException();
|
||||
$name = trim(preg_replace('/[\\\\\/\x00-\x1F\x7F"]+/u', ' ', $this->documents->find($id)['title'])).'.pdf';
|
||||
$fallback = trim(preg_replace('/[^A-Za-z0-9 ._-]+/', '-', (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name)), '- ');
|
||||
$response = new BinaryFileResponse($path, headers: [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
'Content-Security-Policy' => "frame-ancestors 'self'",
|
||||
'X-Robots-Tag' => 'noindex',
|
||||
]);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $name, str_ends_with($fallback, '.pdf') && '.pdf' !== $fallback ? $fallback : 'dokument.pdf');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ final class PageController extends AbstractController
|
||||
// Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt.
|
||||
$pluginAssets = $this->plugins->assetsFor($documents);
|
||||
$needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc));
|
||||
// Ein Link auf ein PDF der Mediathek - in einem Button, einem Bild oder im Fliesstext.
|
||||
// Gesucht wird im gespeicherten Dokument als Text: so findet sich auch ein Link, den
|
||||
// jemand in einen formatierten Absatz geschrieben hat.
|
||||
$needsDocument = (bool) array_filter($documents, static fn (array $doc): bool => 1 === preg_match('#/media/[a-f0-9]{32}\.pdf#', json_encode($doc, JSON_UNESCAPED_SLASHES) ?: ''));
|
||||
// Only the header changes with the scroll position; a scrolled value anywhere in it asks
|
||||
// for the small script that marks the page as scrolled.
|
||||
// Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar.
|
||||
@@ -198,6 +202,9 @@ final class PageController extends AbstractController
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $needsZoom,
|
||||
// Der PDF-Betrachter, nach demselben Muster und im selben Skript: ohne es ein
|
||||
// gewoehnlicher Link.
|
||||
'needsDocument' => $needsDocument,
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
@@ -228,7 +235,7 @@ final class PageController extends AbstractController
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form'));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document']));
|
||||
$wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
@@ -238,7 +245,7 @@ final class PageController extends AbstractController
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, $frames);
|
||||
return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,8 +259,9 @@ final class PageController extends AbstractController
|
||||
* @param list<string> $frameHosts Hosts of embeds that are actually rendered on this response.
|
||||
* Nothing is added to the policy for an embed still waiting
|
||||
* for consent.
|
||||
* @param bool $frameSelf whether the page may frame its own PDFs (the viewer)
|
||||
*/
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response
|
||||
private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response
|
||||
{
|
||||
$styleSources = ["'self'"];
|
||||
if ('' !== $styles) {
|
||||
@@ -280,7 +288,9 @@ final class PageController extends AbstractController
|
||||
implode(' ', $scriptSources),
|
||||
implode(' ', $styleSources),
|
||||
implode(' ', $fontSources),
|
||||
[] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)),
|
||||
// Der PDF-Betrachter zeigt eine Datei dieser Website in einem Rahmen - nur dort, wo
|
||||
// eine Seite auf ein PDF verlinkt, und nur von hier.
|
||||
[] === $frameHosts && !$frameSelf ? "'none'" : implode(' ', [...($frameSelf ? ["'self'"] : []), ...array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)]),
|
||||
));
|
||||
|
||||
return $response;
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Command\SiteOriginCommand;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\Environment;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -21,7 +24,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir, private string $siteOrigin, private OriginCheck $originCheck)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -55,6 +58,7 @@ final class SystemController extends AbstractController
|
||||
// ein ganz anderer Host (Vorschauadresse des Hosters, IP) bleibt erreichbar und
|
||||
// bekommt hier den Hinweis, dass die Website woanders eingetragen ist.
|
||||
'origin' => $this->originFacts($request),
|
||||
'originRejected' => $request->getSession()->remove('origin_rejected'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -258,6 +262,62 @@ final class SystemController extends AbstractController
|
||||
: 'Der Wartungszustand ist aufgehoben.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Adresse der Website, wie „WordPress-Adresse“ in WordPress: sie landet in .env als
|
||||
* SITE_ORIGIN und gilt ab der nächsten Anfrage.
|
||||
*
|
||||
* Vorher fragt die Installation sich selbst unter der neuen Adresse an. Eine Umstellung auf
|
||||
* www ohne DNS-Eintrag leitete sonst jede Seite ins Leere, die Verwaltung eingeschlossen.
|
||||
* Wer weiß, was er tut (Server erreicht sich selbst nicht, http im lokalen Betrieb), kann die
|
||||
* Prüfung ausdrücklich übergehen.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/adresse', name: 'admin_system_origin', methods: ['POST'])]
|
||||
public function origin(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if ('.env' !== $this->environmentSource('SITE_ORIGIN')) {
|
||||
return $this->back('Die Adresse ist in der Server-Umgebung gesetzt und hat dort Vorrang. Sie lässt sich nur beim Hoster ändern.');
|
||||
}
|
||||
try {
|
||||
$neu = SiteOriginCommand::normalize($request->request->getString('origin'));
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($error->getMessage());
|
||||
}
|
||||
if ($neu === rtrim(trim($this->siteOrigin), '/')) {
|
||||
return $this->back('Die Adresse ist unverändert.', 'success');
|
||||
}
|
||||
if (!$request->request->getBoolean('force') && null !== $problem = $this->originCheck->problem($neu)) {
|
||||
$request->getSession()->set('origin_rejected', $neu);
|
||||
|
||||
return $this->back($problem.' Nichts geändert.');
|
||||
}
|
||||
try {
|
||||
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
|
||||
} catch (\RuntimeException $error) {
|
||||
return $this->back($error->getMessage());
|
||||
}
|
||||
|
||||
// Die Anmeldung gilt nur für die Schreibweise, unter der sie entstand. Wechselt die
|
||||
// Verwaltung dadurch den Host, ist dort eine neue Anmeldung fällig - das sagt die Meldung,
|
||||
// solange sie noch auf dem alten Host steht.
|
||||
return $this->back(sprintf(
|
||||
'Die Website ist jetzt eingetragen als %s. Canonical, Sitemap und Feed nennen diese Adresse, die Schreibweise %s www leitet dorthin um.%s',
|
||||
$neu,
|
||||
str_starts_with((string) parse_url($neu, PHP_URL_HOST), 'www.') ? 'ohne' : 'mit',
|
||||
strtolower($request->getHost()) !== strtolower((string) parse_url($neu, PHP_URL_HOST)) ? ' Unter der neuen Adresse ist eine neue Anmeldung nötig.' : '',
|
||||
), 'success');
|
||||
}
|
||||
|
||||
/** Die Gegenstelle zur Prüfung oben: öffentlich, und sie verrät nichts als diese Kennung. */
|
||||
#[Route(OriginCheck::PATH, name: 'site_origin_probe', methods: ['GET'], priority: 10)]
|
||||
public function originProbe(): Response
|
||||
{
|
||||
return new Response($this->originCheck->token(), 200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Robots-Tag' => 'noindex']);
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
@@ -35,7 +35,7 @@ final class MaintenanceSubscriber implements EventSubscriberInterface
|
||||
return;
|
||||
}
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon)#', $path)) {
|
||||
if (preg_match('#^/('.preg_quote($this->adminPath, '#').'|assets/|media/|favicon|\.well-known/kantsites-origin)#', $path)) {
|
||||
return;
|
||||
}
|
||||
$since = @file_get_contents($this->projectDir.'/'.Updater::MAINTENANCE_FILE);
|
||||
|
||||
@@ -29,6 +29,16 @@ final class ArchiveUpdate
|
||||
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
|
||||
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
|
||||
|
||||
/**
|
||||
* Was frühere Fassungen in public/ mitbrachten und jetzt die Anwendung beantwortet.
|
||||
*
|
||||
* Das Zusammenführen kopiert nur hinzu. Bliebe favicon.svg liegen, lieferte der Webserver
|
||||
* sie weiter selbst aus - das mitgelieferte Zeichen, auch wenn ein eigenes Symbol gesetzt ist.
|
||||
* Gelöscht wird in finish(): Das Kopieren erledigt noch der Code der alten Fassung, der
|
||||
* diese Liste nicht kennt.
|
||||
*/
|
||||
private const ENTFALLEN_IN_PUBLIC = ['favicon.svg'];
|
||||
|
||||
/**
|
||||
* Die Marke zwischen den beiden Schritten.
|
||||
*
|
||||
@@ -306,6 +316,9 @@ final class ArchiveUpdate
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) {
|
||||
@unlink($this->root.'/public/'.$eintrag);
|
||||
}
|
||||
$schritte[] = ['step' => 'Aufräumen', 'ok' => true, 'detail' => $this->aufraeumen()];
|
||||
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/ (die zwei jüngsten bleiben).',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
/**
|
||||
* Prüft, ob eine neue Adresse der Website wirklich bei dieser Installation ankommt.
|
||||
*
|
||||
* Die Adresse entscheidet, wohin die Schreibweise mit oder ohne www umgeleitet wird - für die
|
||||
* Website und für die Verwaltung. Stellt jemand auf www um, und www hat keinen DNS-Eintrag oder
|
||||
* kein Zertifikat, leitet danach jede Seite ins Leere, und in die Verwaltung kommt man nur noch
|
||||
* über FTP. Deshalb wird vor dem Speichern angefragt: Unter der neuen Adresse muss eine Kennung
|
||||
* zurückkommen, die nur diese Installation kennt - abgeleitet aus APP_SECRET, ohne es zu verraten.
|
||||
*/
|
||||
class OriginCheck
|
||||
{
|
||||
public const PATH = '/.well-known/kantsites-origin';
|
||||
|
||||
private Download $download;
|
||||
|
||||
public function __construct(private string $secret, ?Download $download = null)
|
||||
{
|
||||
// Kurz: die Anfrage geht an den eigenen Server und hält die Verwaltung so lange auf.
|
||||
$this->download = $download ?? new Download(5, 10);
|
||||
}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
return hash_hmac('sha256', 'site-origin', $this->secret);
|
||||
}
|
||||
|
||||
/** Null, wenn die Adresse hier ankommt - sonst der Grund in einem Satz. */
|
||||
public function problem(string $origin): ?string
|
||||
{
|
||||
if (!str_starts_with($origin, 'https://')) {
|
||||
return 'Geprüft werden nur HTTPS-Adressen. Eine Adresse mit http:// lässt sich nur mit „Trotzdem übernehmen“ speichern.';
|
||||
}
|
||||
$antwort = $this->download->get($origin.self::PATH);
|
||||
if (null === $antwort) {
|
||||
$grund = $this->download->reason();
|
||||
|
||||
return 'Unter '.$origin.' antwortet diese Website nicht'.('' !== $grund ? ': '.rtrim($grund, '.').'.' : '.').' DNS-Eintrag und Zertifikat für diese Domain prüfen.';
|
||||
}
|
||||
if (!hash_equals($this->token(), trim($antwort))) {
|
||||
return 'Unter '.$origin.' antwortet eine andere Website. Zeigt die Domain auf diesen Server?';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||
{% block lead %}Alle Bilder und PDFs, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus. PDFs bleiben, wie sie sind, und lassen sich in jedem Linkziel wählen.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
@@ -19,13 +19,13 @@
|
||||
<div class="dropzone" data-dropzone>
|
||||
<label class="dropzone-label" for="files">
|
||||
<span class="dropzone-icon" aria-hidden="true">▩</span>
|
||||
<strong>Bilder hierher ziehen</strong>
|
||||
<strong>Bilder oder PDFs hierher ziehen</strong>
|
||||
<span class="dropzone-hint">oder klicken, um Dateien zu wählen</span>
|
||||
</label>
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp" multiple class="dropzone-input">
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp,application/pdf,.pdf" multiple class="dropzone-input">
|
||||
</div>
|
||||
<p class="dropzone-chosen" data-chosen role="status">Noch keine Datei gewählt.</p>
|
||||
<p class="field-help">JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.</p>
|
||||
<p class="field-help">JPEG, PNG oder WebP bis 8 MB je Bild, PDF bis 20 MB. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen. Hochgeladene Dateien sind über ihre Adresse öffentlich – nichts Vertrauliches hochladen.</p>
|
||||
<button class="primary" type="submit">Hochladen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -38,4 +38,51 @@
|
||||
</form>
|
||||
{% include 'admin/search-feedback.html.twig' %}
|
||||
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
|
||||
|
||||
{# Die PDFs stehen unter den Bildern und ohne Suche: es sind wenige, und ein Titel reicht, um
|
||||
eines wiederzufinden. Der Schalter für das Archiv oben gilt auch hier. #}
|
||||
<section class="stack" aria-labelledby="documents-heading">
|
||||
<div class="section-head">
|
||||
<h2 id="documents-heading">Dokumente <span class="count">{{ documents|length }}</span></h2>
|
||||
</div>
|
||||
{% if documents is empty %}
|
||||
<div class="empty">
|
||||
<h3>Keine PDFs in der Mediathek.</h3>
|
||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben ein PDF hoch. Danach lässt es sich in jedem Linkziel wählen – etwa für einen Button „Preisliste“.' : 'Ein Redakteur kann PDFs hochladen.' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="rows">
|
||||
{% for item in documents %}
|
||||
<article class="row row-stack">
|
||||
<div class="row-line">
|
||||
<span class="row-icon row-icon-text" aria-hidden="true">PDF</span>
|
||||
<div class="row-main">
|
||||
<h3><a href="{{ item.src }}" target="_blank" rel="noopener">{{ item.title }}</a></h3>
|
||||
<p class="muted">{{ (item.bytes / 1048576)|number_format(1, ',') }} MB · <code>{{ item.src }}</code>{% if item.archived %} · archiviert{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_document_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="document-title-{{ item.id }}">Titel</label>
|
||||
<input id="document-title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<p class="field-help">Wird zum Dateinamen, wenn jemand das PDF speichert, und steht über dem Betrachter.</p>
|
||||
<label class="check-label" for="document-archived-{{ item.id }}"><input id="document-archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte PDFs erscheinen nicht mehr in der Auswahl, bleiben aber erreichbar – ein Link darauf geht nicht ins Leere.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -40,9 +40,28 @@
|
||||
{% if origin.passt %}
|
||||
<span class="muted">– Canonical, Sitemap und Feed nennen sie; die Schreibweise {{ origin.adresse matches '{^https?://www\.}' ? 'ohne' : 'mit' }} www leitet hierher um.</span>
|
||||
{% else %}
|
||||
<br><span class="check-bad"><strong>Diese Verwaltung läuft gerade unter <code>{{ origin.host }}</code>.</strong> Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr: <code>php bin/console cms:site:origin https://…</code> oder <code>SITE_ORIGIN</code> in <code>.env</code> ändern.</span>
|
||||
<br><span class="check-bad"><strong>Diese Verwaltung läuft gerade unter <code>{{ origin.host }}</code>.</strong> Canonical, Sitemap und Feed nennen trotzdem die eingetragene Adresse. Stimmt die nicht mehr, unten ändern.</span>
|
||||
{% endif %}
|
||||
{% if origin.quelle == '.env' %}
|
||||
{# Wie „WordPress-Adresse“: eine Stelle für mit oder ohne www. Vor dem Speichern fragt
|
||||
die Installation sich selbst unter der neuen Adresse an. #}
|
||||
{% set host = origin.adresse|split('://')|last|split(':')|first %}
|
||||
{% set twin = host starts with 'www.' ? host|slice(4) : 'www.' ~ host %}
|
||||
<form class="origin-form" action="{{ path('admin_system_origin') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<label for="origin">Adresse der Website</label>
|
||||
<div class="origin-row">
|
||||
<input id="origin" name="origin" type="url" inputmode="url" maxlength="200" required placeholder="https://www.meine-firma.de" value="{{ originRejected ?: origin.adresse }}" aria-describedby="origin-help">
|
||||
<button class="secondary" type="submit">Adresse speichern</button>
|
||||
</div>
|
||||
{% if originRejected %}
|
||||
<label class="check-label" for="origin-force"><input id="origin-force" type="checkbox" name="force" value="1"> Trotzdem übernehmen – ich habe DNS und Zertifikat geprüft</label>
|
||||
{% endif %}
|
||||
<p class="field-help" id="origin-help">Nur Schema und Domain. Vor dem Speichern wird geprüft, ob die Website unter der neuen Adresse antwortet – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen.{% if origin.adresse and host matches '/^[a-z0-9-]+(\.[a-z0-9-]+)+$/' and not (host matches '/^[0-9.]+$/') %} Umstellen auf <button class="link-button" type="submit" name="origin" value="{{ origin.adresse|split('://')|first }}://{{ twin }}" formnovalidate>{{ twin }}</button>.{% endif %}</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<br><span class="muted">Eingestellt in der Server-Umgebung; sie hat Vorrang vor <code>.env</code> und lässt sich nur beim Hoster ändern.</span>
|
||||
{% endif %}
|
||||
<br><span class="muted">Eingestellt in <code>{{ origin.quelle }}</code>; Server-Umgebungsvariablen haben Vorrang.</span>
|
||||
</dd>
|
||||
{% if state.worktree %}
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
für Browser, die „auto“ noch nicht kennen (heute Safari). #}
|
||||
{% set annahme = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px' %}
|
||||
{% set imageSizes = festeBreite ends with 'px' ? festeBreite : (eager or inHeader|default(false) ? annahme : 'auto, ' ~ annahme) %}
|
||||
{% set klick = props.click|default(props.href|default('') ? 'link' : 'keine') %}
|
||||
{# Ohne eigene Wahl folgt ein Bild seiner Galerie: ist sie vergrößerbar, ist es das Bild auch.
|
||||
Eine eigene Wahl - auch „Nichts tun“ - geht vor, ebenso ein Link. #}
|
||||
{% set klick = props.click|default(props.href|default('') ? 'link' : (galleryZoom|default(false) ? 'vergroessern' : 'keine')) %}
|
||||
{% set imageLinked = klick == 'link' and props.href|default('') %}
|
||||
{% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %}
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">
|
||||
@@ -284,7 +286,7 @@
|
||||
</nav>
|
||||
{% elseif type == 'gallery' %}
|
||||
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie"{% if props.lightbox|default(false) %} data-zoom-group{% endif %}>
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, galleryZoom: props.lightbox|default(false), editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
|
||||
</div>
|
||||
{% elseif kant_plugin_block(type) %}
|
||||
{# Ein Baustein eines aktiven Plugins. Die Vorlage bekommt nur, was der Kern geprüft hat:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{# 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. #}
|
||||
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={{ 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">
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
{% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
|
||||
{% if needsZoom|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
{% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
|
||||
{% if not editing|default(false) %}{% for src in pluginScripts|default([]) %}<script src="{{ src }}" defer></script>{% endfor %}{% endif %}
|
||||
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
|
||||
show the layout, not run code that could change the page under the editor's hands. #}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Tests;
|
||||
use App\Content\Consent;
|
||||
use App\Content\CustomCode;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
@@ -1153,6 +1154,91 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testAZoomableGalleryMakesEveryImageWithoutItsOwnChoiceZoomable(): void
|
||||
{
|
||||
// Umut: „Bei Galerie muss man da alles einzeln machen? kann man das mit einem Klick für
|
||||
// alle Elemente machen?" Der Haken an der Galerie gilt jetzt für jedes Bild darin, das
|
||||
// selbst nichts gewählt hat. Eine eigene Wahl und ein Link gehen vor.
|
||||
$bild = static fn (string $id, array $props): array => ['id' => $id, 'type' => 'image', 'props' => [
|
||||
'src' => '/media/'.str_repeat('a', 32).'/480', 'alt' => 'Motiv '.$id, 'decorative' => false, 'width' => 480, 'height' => 270, ...$props,
|
||||
]];
|
||||
$document = DemoContent::starter('Startseite');
|
||||
$document['schemaVersion'] = 19;
|
||||
$document['blocks'][] = ['id' => 'galerie', 'type' => 'gallery', 'props' => ['lightbox' => true], 'children' => [
|
||||
$bild('frei1', []),
|
||||
$bild('frei2', []),
|
||||
$bild('still', ['click' => 'keine']),
|
||||
$bild('verlinkt', ['click' => 'link', 'href' => '/kontakt']),
|
||||
]];
|
||||
$document['blocks'][] = ['id' => 'ohne', 'type' => 'gallery', 'props' => [], 'children' => [$bild('ruhig', [])]];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('#<figure id="frei1"[^>]*>\s*<a class="image-link image-zoom"#', $html);
|
||||
self::assertMatchesRegularExpression('#<figure id="frei2"[^>]*>\s*<a class="image-link image-zoom"#', $html);
|
||||
self::assertMatchesRegularExpression('#<figure id="still"[^>]*><img#', $html);
|
||||
self::assertMatchesRegularExpression('#<figure id="verlinkt"[^>]*>\s*<a class="image-link" href="/kontakt"#', $html);
|
||||
// Eine Galerie ohne Haken bleibt, wie sie war.
|
||||
self::assertMatchesRegularExpression('#<figure id="ruhig"[^>]*><img#', $html);
|
||||
self::assertSame(2, substr_count($html, 'image-zoom'));
|
||||
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
|
||||
}
|
||||
|
||||
public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void
|
||||
{
|
||||
// Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das
|
||||
// Bild-Popup." Das PDF bleibt, wie es kam; geprüft wird, dass es eines ist.
|
||||
$documents = new DocumentLibrary($this->db, $this->path.'-media');
|
||||
$file = tempnam(sys_get_temp_dir(), 'kant-pdf-');
|
||||
try {
|
||||
file_put_contents($file, '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
||||
try {
|
||||
$documents->upload(new UploadedFile($file, 'preisliste.pdf', test: true));
|
||||
self::fail('Eine Datei, die kein PDF ist, darf nicht als PDF durchgehen.');
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document'));
|
||||
|
||||
file_put_contents($file, "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
$item = $documents->upload(new UploadedFile($file, 'Preisliste 2026.pdf', test: true));
|
||||
} finally {
|
||||
@unlink($file);
|
||||
}
|
||||
self::assertSame('Preisliste 2026', $item['title']);
|
||||
self::assertSame('/media/'.$item['id'].'.pdf', $item['src']);
|
||||
|
||||
// Ausgeliefert als PDF, zum Anzeigen, mit dem Titel als Dateinamen, und einbetten darf es
|
||||
// nur die eigene Website.
|
||||
$antwort = $this->request($item['src']);
|
||||
self::assertSame(200, $antwort->getStatusCode());
|
||||
self::assertSame('application/pdf', $antwort->headers->get('Content-Type'));
|
||||
self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options'));
|
||||
self::assertSame("frame-ancestors 'self'", $antwort->headers->get('Content-Security-Policy'));
|
||||
self::assertStringStartsWith('inline;', (string) $antwort->headers->get('Content-Disposition'));
|
||||
self::assertStringContainsString('Preisliste 2026.pdf', (string) $antwort->headers->get('Content-Disposition'));
|
||||
self::assertSame(404, $this->request('/media/'.str_repeat('a', 32).'.pdf')->getStatusCode());
|
||||
|
||||
// Ein Button zeigt auf das PDF - ein gewöhnlicher lokaler Pfad, ohne neues Schema.
|
||||
$document = DemoContent::starter('Startseite');
|
||||
$document['blocks'][] = ['id' => 'preise', 'type' => 'button', 'props' => ['text' => 'Preisliste', 'href' => $item['src']]];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$seite = $this->request('/');
|
||||
$html = $seite->getContent();
|
||||
self::assertStringContainsString('href="'.$item['src'].'"', $html);
|
||||
self::assertStringContainsString('<script src="/assets/lightbox.js?v=', $html);
|
||||
$csp = (string) $seite->headers->get('Content-Security-Policy');
|
||||
self::assertStringContainsString("frame-src 'self'", $csp);
|
||||
self::assertStringContainsString("script-src 'self'", $csp);
|
||||
|
||||
// Ohne Link auf ein PDF bleibt es beim alten: kein Skript, kein Rahmen.
|
||||
$ohne = DemoContent::starter('Startseite');
|
||||
$version = $this->pages->findForEditor('page-1')['version'];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $ohne, $version), $version + 1);
|
||||
$zweite = $this->request('/');
|
||||
self::assertStringNotContainsString('lightbox.js', $zweite->getContent());
|
||||
self::assertStringContainsString("frame-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testLegalLinksAppearOnlyWhereAnAuthorPlacesThem(): void
|
||||
{
|
||||
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
|
||||
@@ -1626,6 +1712,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
|
||||
|
||||
@@ -738,6 +738,47 @@ final class EditorSecurityTest extends WebTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testPdfsGoThroughTheMediaUploadAndTheEditorPicker(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/media');
|
||||
$token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value');
|
||||
$path = tempnam(sys_get_temp_dir(), 'kant-form-pdf-');
|
||||
try {
|
||||
file_put_contents($path, "%PDF-1.7\n%%EOF\n");
|
||||
// Dasselbe Feld wie für Bilder: der Inhalt entscheidet, wohin die Datei geht.
|
||||
$client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [new UploadedFile($path, 'Flyer.pdf', test: true)]]);
|
||||
self::assertResponseRedirects('/admin/media');
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||
self::assertSame('Flyer', $this->db->fetchOne('SELECT title FROM document'));
|
||||
$crawler = $client->followRedirect();
|
||||
self::assertStringContainsString('1 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text());
|
||||
self::assertCount(1, $crawler->filter('section[aria-labelledby="documents-heading"] .row'));
|
||||
|
||||
// Die Auswahl im Editor: Liste mit Token, Hochladen nur mit Token.
|
||||
$client->request('GET', '/admin/api/dokumente');
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertSame('Flyer', $data['items'][0]['title']);
|
||||
$client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'zweites.pdf', test: true)]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
file_put_contents($path, '<?php echo 1;');
|
||||
$client->request('POST', '/admin/api/dokumente/upload', files: ['file' => new UploadedFile($path, 'getarnt.pdf', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $data['csrfToken']]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document'));
|
||||
|
||||
// Archivieren nimmt es aus der Auswahl-Übersicht, die Datei bleibt erreichbar.
|
||||
$id = $data['items'][0]['id'];
|
||||
$client->request('POST', '/admin/api/dokumente/'.$id, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']], content: json_encode(['title' => 'Flyer 2026', 'archived' => true, 'version' => 1]));
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(0, $client->request('GET', '/admin/media')->filter('section[aria-labelledby="documents-heading"] .row'));
|
||||
$client->request('GET', '/media/'.$id.'.pdf');
|
||||
self::assertResponseIsSuccessful();
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMediaPreviewReadsSmallAndArchivedImagesWithoutListingTheLibrary(): void
|
||||
{
|
||||
$id = str_repeat('a', 32);
|
||||
@@ -785,7 +826,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$crawler = $client->followRedirect();
|
||||
self::assertCount(2, $crawler->filter('.library-tile'));
|
||||
self::assertCount(1, $crawler->filter('.notice.ok'));
|
||||
self::assertStringContainsString('2 Bild(er) hochgeladen', $crawler->filter('.notice.ok')->text());
|
||||
self::assertStringContainsString('2 Datei(en) hochgeladen', $crawler->filter('.notice.ok')->text());
|
||||
|
||||
$id = (string) $this->db->fetchOne('SELECT id FROM media LIMIT 1');
|
||||
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Bergblick', 'alt' => 'Grüne Berglandschaft', 'archived' => '1', 'version' => 1]);
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\OriginCheck;
|
||||
use App\Ops\Updater;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
@@ -133,7 +134,7 @@ final class SystemScreenTest extends WebTestCase
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('https://cms.test', $html);
|
||||
self::assertStringContainsString('läuft gerade unter <code>localhost</code>', $html);
|
||||
self::assertStringContainsString('cms:site:origin', $html, 'Der Weg, es zu aendern, steht dabei.');
|
||||
self::assertStringContainsString('Stimmt die nicht mehr, unten ändern.', $html);
|
||||
|
||||
// Unter der eingetragenen Adresse selbst: kein Hinweis, dafuer die Richtung der Umleitung.
|
||||
$client->request('GET', 'https://cms.test/admin/login');
|
||||
@@ -149,6 +150,85 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertSame('https://cms.test/admin/system', $client->getResponse()->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testTheAddressIsSetInTheAdministrationButOnlyWhereItReallyArrives(): void
|
||||
{
|
||||
// Umut: „bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird“ -
|
||||
// wie die WordPress-Adresse. Im Test kommt SITE_ORIGIN aus der Server-Umgebung; dort hat
|
||||
// sie Vorrang, also gibt es kein Feld, und auch ein POST ändert nichts.
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('lässt sich nur beim Hoster ändern', $html);
|
||||
self::assertStringNotContainsString('name="origin"', $html);
|
||||
$token = (string) $client->getCrawler()->filter('form[action$="/system/wartung"] input[name=_csrf_token]')->attr('value');
|
||||
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => 'https://www.cms.test']);
|
||||
$client->followRedirect();
|
||||
self::assertStringContainsString('in der Server-Umgebung gesetzt', (string) $client->getResponse()->getContent());
|
||||
|
||||
// Steht sie in .env, gibt es das Feld samt Umschalter auf die andere Schreibweise.
|
||||
$geladen = $_SERVER['SYMFONY_DOTENV_VARS'] ?? null;
|
||||
$_SERVER['SYMFONY_DOTENV_VARS'] = 'SITE_ORIGIN';
|
||||
try {
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('name="origin" type="url"', $html);
|
||||
self::assertStringContainsString('value="https://www.cms.test" formnovalidate>www.cms.test</button>', $html);
|
||||
|
||||
// Eine Adresse, unter der diese Website nicht antwortet, wird nicht gespeichert - sonst
|
||||
// leitete die andere Schreibweise ins Leere. Das Feld behält sie, und erst jetzt
|
||||
// erscheint „Trotzdem übernehmen“.
|
||||
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => 'https://kein-server.invalid/']);
|
||||
$client->followRedirect();
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('Unter https://kein-server.invalid antwortet diese Website nicht', $html);
|
||||
self::assertStringContainsString('Nichts geändert.', $html);
|
||||
self::assertStringContainsString('value="https://kein-server.invalid"', $html);
|
||||
self::assertStringContainsString('name="force"', $html);
|
||||
foreach (['https://cms.test/start' => 'ohne Pfad', 'ftp://cms.test' => 'beginnt mit https://'] as $falsch => $meldung) {
|
||||
$client->request('POST', '/admin/system/adresse', ['_csrf_token' => $token, 'origin' => $falsch]);
|
||||
$client->followRedirect();
|
||||
self::assertStringContainsString($meldung, (string) $client->getResponse()->getContent());
|
||||
}
|
||||
} finally {
|
||||
if (null === $geladen) {
|
||||
unset($_SERVER['SYMFONY_DOTENV_VARS']);
|
||||
} else {
|
||||
$_SERVER['SYMFONY_DOTENV_VARS'] = $geladen;
|
||||
}
|
||||
}
|
||||
self::assertStringContainsString('SITE_ORIGIN', (string) file_get_contents(dirname(__DIR__).'/.env'));
|
||||
self::assertStringNotContainsString('kein-server', (string) file_get_contents(dirname(__DIR__).'/.env'));
|
||||
|
||||
// Die Gegenstelle der Prüfung: öffentlich, ohne Anmeldung, und nur die Kennung.
|
||||
$client->request('GET', '/.well-known/kantsites-origin');
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
self::assertSame(hash_hmac('sha256', 'site-origin', 'isolated-test-secret'), $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testTheAddressCheckWantsThisInstallationsOwnAnswer(): void
|
||||
{
|
||||
$antwort = null;
|
||||
$download = new class($antwort) extends Download {
|
||||
public function __construct(private ?string &$antwort)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function get(string $url): ?string
|
||||
{
|
||||
return $this->antwort;
|
||||
}
|
||||
};
|
||||
$check = new OriginCheck('geheim', $download);
|
||||
self::assertStringContainsString('antwortet diese Website nicht', (string) $check->problem('https://www.example.de'));
|
||||
$antwort = hash_hmac('sha256', 'site-origin', 'anderes-geheimnis');
|
||||
self::assertStringContainsString('antwortet eine andere Website', (string) $check->problem('https://www.example.de'));
|
||||
$antwort = $check->token().'
|
||||
';
|
||||
self::assertNull($check->problem('https://www.example.de'));
|
||||
self::assertStringContainsString('nur HTTPS', (string) $check->problem('http://www.example.de'));
|
||||
}
|
||||
|
||||
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
|
||||
{
|
||||
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
|
||||
|
||||
@@ -21,6 +21,7 @@ use DoctrineMigrations\Version20260913000100;
|
||||
use DoctrineMigrations\Version20260914000100;
|
||||
use DoctrineMigrations\Version20260915000100;
|
||||
use DoctrineMigrations\Version20260916000100;
|
||||
use DoctrineMigrations\Version20260917000100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,7 @@ final class TestSchema
|
||||
Version20260914000100::class,
|
||||
Version20260915000100::class,
|
||||
Version20260916000100::class,
|
||||
Version20260917000100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user