Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
668287f640 | ||
|
|
5e5d380fd3 | ||
|
|
6fb6d30e8d | ||
|
|
ace6c50743 | ||
|
|
82a74277cb | ||
|
|
5d55a65c22 | ||
|
|
24c1d9c871 | ||
|
|
a976c146de | ||
|
|
3ea28a03fc | ||
|
|
711af312af | ||
|
|
31fb53c1fc | ||
|
|
24f98633d0 | ||
|
|
e47f278a13 | ||
|
|
e8a9dcdf5c | ||
|
|
04927b9563 | ||
|
|
3b1dfc0948 |
No files matched your search
+23
-3
@@ -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",
|
||||
@@ -128,6 +128,26 @@
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8099'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8099 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8099
|
||||
},
|
||||
{
|
||||
"name": "kantarci-markt",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8100'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8100 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8100
|
||||
},
|
||||
{
|
||||
"name": "kantarci-markt-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8101
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -385,6 +385,10 @@ Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH,
|
||||
|
||||
Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien GmbH, Baden-Baden) liegt in `var/kantarci-immobilien.db` mit Medien in `var/kantarci-immobilien-media`; die Startkonfiguration `kantarci-immobilien` in `.claude/launch.json` nutzt Port 8098, `kantarci-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8099 zum Messen. `sh scripts/reset-kantarci-immobilien.sh [Bilderordner]` (Standard `var/kantarci-immobilien-assets` mit `kantarci-banner.jpeg` aus dem gelieferten Paket) schneidet Logo, Hausmotiv und Seitensymbol aus dem Banner (`scripts/build-kantarci-assets.php`), zeichnet die Bildkarten der Mietanfrage (`node scripts/build-kantarci-options.mjs`), wirft die Installation weg und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` (drei Schritte mit Bildkarten und Zusammenfassung) auf. Schriften sind Arial und Georgia, es gibt nichts zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Achte lokale Referenzwebsite: Kantarci Markt (Vorab-Seite zur Neueröffnung)
|
||||
|
||||
Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
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.
|
||||
|
||||
@@ -711,6 +711,8 @@ Umut wollte an jedem Element eigenes CSS **und** eigenes Skript setzen können.
|
||||
|
||||
Zwei echte Löcher kamen beim Testen heraus. `& .block-text` wurde zu `#id.block-text`, weil das führende Leerzeichen wegtrimmt wurde – aus dem Nachfahren wurde derselbe Knoten, die Regel traf das falsche Element. Und `url(//fremd.example/x.png)` kam durch, weil die Prüfung jedes `/` erlaubte: eine protokollrelative Adresse hätte über das Schema der Seite von Dritten geladen. Beides ist behoben und durch Tests abgedeckt.
|
||||
|
||||
**Nachtrag 23.09.2026: `content` mit Text.** Werte durften bisher keine Anführungszeichen enthalten, damit gab es kein `::before` oder `::after` – beim Nachbau von Kantarci Markt fehlten dadurch der Verlauf über einem Foto, ein Goldstrich und ein Buchstabe als Wasserzeichen. Jetzt nimmt `content` einen Text in einfachen oder doppelten Anführungszeichen an, höchstens 40 Zeichen aus Buchstaben, Ziffern, Leerzeichen und `.,:!?·•–-`. Kein `<`, kein Backslash, kein zweites Anführungszeichen: Der Wert kann weder den String noch das `<style>` verlassen. Jede andere Eigenschaft bleibt ohne Anführungszeichen (also auch kein `font-family:'…'`). Folge: Dekoration, die Vorlesesoftware nicht vorlesen soll, gehört in ein Pseudo-Element statt in einen Textbaustein.
|
||||
|
||||
## ADR 029 – Verwaltung: ein Rahmen statt sieben Kopfzeilen (12.09.2026)
|
||||
|
||||
Umuts Auftrag war „backend komplett neugestalten, einfacher schöner Bedienbarkeit komplett einfach“, mit ausdrücklicher Gestaltungsfreiheit. Das Problem war weniger die Optik als die Orientierung: jede Verwaltungsseite trug ihre eigene Kopfzeile und ihre eigene, unvollständige Liste von Links auf die anderen Seiten – die Seitenübersicht verlinkte sieben Bereiche, das Menü fünf, die Fassungen drei. Wo man war, stand nirgends.
|
||||
|
||||
+102
-1
@@ -1,5 +1,96 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.23.0: achte Installation „Kantarci Markt – Neueröffnung“, content im eigenen CSS, Schriften ergänzt statt überschrieben (23.09.2026)
|
||||
|
||||
**Anlass:** Umut: „erstellst du bitte bald verfügbar site nach dem prinzip kannst gerne dazu implementieren“ – dazu das Paket `Kantarci-Markt-Landingpage.zip` (statische Seite: Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit Marktfoto, Tomatenkarte und Stempel, Versprechen auf Waldgrün, Abschluss „Frische. Qualität. Faire Preise.“, Fuß).
|
||||
|
||||
**Neu.** Datenbank `var/kantarci-markt.db`, Medien `var/kantarci-markt-media`, Bildquellen `var/kantarci-markt-assets`, Startkonfigurationen `kantarci-markt` (Port 8100, dev) und `kantarci-markt-prod` (Port 8101). `sh scripts/reset-kantarci-markt.sh` baut alles neu: `build-kantarci-markt-assets.mjs` (Logo zugeschnitten auf 600 px, Seitensymbol und Stempel als PNG über Chromium), `seed-kantarci-markt.php` (Startseite, Impressum, Datenschutz, 404, Kopf, Fuß, Einstellungen), dann DM Sans auf den eigenen Server. Alles aus gewöhnlichen Bausteinen, kein Plugin.
|
||||
|
||||
- **Abweichungen:** Manrope entfällt (DM Sans an seiner Stelle, eine Schrift weniger). Die Bildzeile „Vorfreude auf frische Vielfalt.“ steht höher als in der Vorlage, weil Stempel und Tomatenkarte sie dort verdeckten. Das große „K“ im Abschluss ist ein Pseudo-Element, kein Text. Keine LocalBusiness-Daten, solange der Markt nicht geöffnet hat.
|
||||
- **Impressum und Datenschutz** sind Pflicht und angelegt. Offen, im Text als „NOCH ZU PRÜFEN“: Betreiber/Rechtsform, Hausnummer, Vertretung, Register, Telefon, USt-IdNr., ob `info@kantarci-markt.de` existiert (die Domain hat Mailserver), Quelle der beiden Fotos, Hosting.
|
||||
|
||||
**Im Kern dazu gebaut:**
|
||||
|
||||
- **Eigenes CSS: `content` mit kurzem Text** (`CustomCode`), damit `::before`/`::after` möglich sind. Eng begrenzt, siehe Nachtrag zu ADR 028.
|
||||
- **`fonts.css` wird ergänzt statt überschrieben** (`FontLibrary::combine`): Ein Abruf ersetzt nur die Regeln der geholten Familien. Vorher hätte das Holen von DM Sans Lato und Cormorant der anderen lokalen Installationen aus der gemeinsamen Datei gelöscht.
|
||||
- **Variable Schriften einmal statt je Schnitt:** DM Sans liefert Google als eine Datei für alle Schnitte. Der Kern verlinkte und lud sie dreimal vor (111 KB statt 37 KB), das kostete am Telefon den LCP. Jetzt eine `@font-face` mit Gewichtsbereich `400 700`, und `preload()` nennt nur Dateien, die `fonts.css` tatsächlich verlinkt.
|
||||
- Bekannt, nicht geändert: Zeilen sind im Kern immer durchsichtig (`.block-row{background:transparent!important}`). Die grüne Karte „Wir öffnen bald“ ist deshalb eine Spalte.
|
||||
|
||||
**Geprüft:** Lighthouse im Produktionsmodus (Port 8101, je drei Läufe, Median): Startseite mobil 97 / Desktop 100, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. Vorher 87 mobil wegen CLS 0,19 (Logo im Kopf bekam seine Breite erst nach dem Laden) und der dreifachen Schrift. Bildschirmfotos 1440 px und 375 px ohne waagerechtes Scrollen und ohne Konsolenfehler, Impressum und 404 angesehen. Neue Tests in `CmsTest` (`content` erlaubt/abgewiesen, `combine`, Preload bei variabler Schrift); volle PHPUnit-Suite (596) und `npm test` (36) grün. Keine Felddaten, kein INP.
|
||||
|
||||
**Nächster Schritt:** Die offenen Angaben im Impressum von Umut bekommen; auf Wunsch ein Formular „Zur Eröffnung benachrichtigen“ (braucht dann Einwilligungstext und Datenschutzabsatz).
|
||||
|
||||
## Fassung 1.22.5: alte Browserchecks wieder grün, Kopfbereich von kant-it.de wieder bearbeitbar (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „räum den Strukturbaum-Fehler auch auf und Release 1.22.5“.
|
||||
|
||||
- **Strukturbaum:** `test:admin`, `test:buttons` und `test:navigation-ux` klickten direkt in den Strukturbaum. Seit 1.11 zeigt die Seitenleiste aber eine Ansicht auf einmal, Standard „Elemente“, und der Baum war versteckt. Jetzt wählen die Checks den Reiter „Struktur“, dann den Baustein und „Bearbeiten“, wie `editor-check.mjs`. Der Editor selbst war richtig.
|
||||
- **Echter Fehler, dabei gefunden:** Der Kopfbereich in `var/kantit.db` (und damit vermutlich auf kant-it.de) trägt die Hamburger-Umschaltung (`collapse`) bei Schema v19, erlaubt ist sie erst ab v22. Die Website zeigt ihn, aber der Editor bekam für die Vorschau „Unbekannte Hamburger-Umschaltung oder Dokumentschema vor v22“, und Speichern wäre ebenso abgelehnt worden: Der Bereich war nicht bearbeitbar. Jetzt hebt `repairSchema()` (editor-model.js) ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen. Das ist dieselbe Hebung, die der Editor beim Einstellen ohnehin macht. Die Umstellung auf formatierten Text (v12/v17) kommt nie von selbst, weil sie die Bedeutung ändert. Beim nächsten Speichern ist das Dokument korrigiert. Alle anderen lokalen Installationen hatten keine solchen Dokumente (geprüft mit dem Validator über alle Seiten und Bereiche).
|
||||
- **Weitere veraltete Stellen in `test:admin`:** Der Knopf „Bewegung testen“ heißt seit 1.11 „Bewegung in der Vorschau abspielen“ (`#preview-motion`), auch ein Hilfetext im Editor nannte noch den alten Namen. Der Slider-Teil erwartete, dass „Bewegung reduzieren“ die Automatik anhält. Seit 1.11 gilt das nur mit „Systemvorgabe beachten“. Der Check prüft jetzt das Verhalten, das die Einstellung der Website verspricht.
|
||||
|
||||
**Geprüft:** `test:admin` (vollständig, auch der öffentliche Teil), `test:buttons`, `test:navigation-ux` und `test:editor` grün. Neuer Einheitstest für `repairSchema` in `tests/editor-model.test.mjs`.
|
||||
|
||||
## Fassung 1.22.4: Ziehknopf im Menü neben der Zusammenfassung (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „ja räum das auf und Release 1.22.4“ – die bekannte axe-Meldung `nested-interactive` im Menü-Editor.
|
||||
|
||||
- Der Ziehknopf eines Menüeintrags stand in der `<summary>`, also ein Knopf in einem Knopf. Screenreader lesen so etwas unzuverlässig vor, und die Tastatur erreicht es nicht sauber. Jetzt steht er als Geschwister vor `<details>`, per CSS über die linke Ecke der Zusammenfassung gelegt (`menu-entry.html.twig`, `createRow` in `menu-editor.js`). Aussehen und Bedienung bleiben gleich: Ziehen mit Maus, Stift und Finger, Pfeiltasten auf dem Knopf.
|
||||
- Der Hinweis zu fehlenden Sprungzielen im Menü und die Hilfe zum Feld „Sprungziel“ im Editor nennen jetzt die richtige Stelle (Sektion, Zeile oder Spalte) und den Weg über die Auswahl im Menü.
|
||||
- `test:admin`: Die Fehlermeldung nach einem abgewiesenen Speichern wurde direkt nach der Serverantwort abgefragt, sie erscheint aber erst, nachdem die Antwort gelesen ist. Jetzt wartet der Test auf sie. Der Check scheitert weiterhin an der älteren Stelle beim Klick in den Strukturbaum des Editors.
|
||||
|
||||
**Geprüft:** Die Editor-Prüfung prüft die ganze Menüseite mit axe, ohne Ausnahme. Sie stellt sicher, dass in keiner Zusammenfassung ein Knopf steht, und verschiebt einen Eintrag per Pfeiltaste (Fokus bleibt auf dem Knopf). Bildschirmfoto `reports/editor/menu-editor.png` angesehen.
|
||||
|
||||
## Fassung 1.22.3: die übrigen abgetippten Adressen (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „die offenen Punkte auch umsetzen und Release 1.22.3“ – die drei Reste aus 1.22.2.
|
||||
|
||||
- **Sprungziel im Menü:** Eine Auswahl aus den Ankern der verknüpften Seite, beschriftet mit der ersten Überschrift des Bausteins, etwa „So erreichen Sie uns (#kontakt)“. Die Liste liefert `GET /admin/api/sprungziele/{id}` (Rolle wie die Menüs). Wechselt die Seite, lädt die Liste neu. Ein gespeicherter Anker, den es nicht mehr gibt, bleibt als „(auf der Seite nicht gefunden)“ sichtbar. Ohne Skript bleibt das Textfeld.
|
||||
- **Linkfelder in der Plugin-Verwaltung und die Adresse eines eigenen Menülinks:** Das Textfeld bleibt, weil eine fremde Adresse getippt wird. Daneben stehen „Seite wählen“ und „PDF wählen“ (`link-field.js`, Markup `data-link-field`/`data-link-input`).
|
||||
- **Kategorien und Schlagwörter eines Beitrags:** Die vorhandenen Namen stehen als Knöpfe unter dem Feld. Ein Klick nimmt einen Namen auf oder heraus (`term-chips.js`), neue Namen tippt man weiter ein. So entstehen nicht „Projekt“ und „Projekte“ nebeneinander.
|
||||
|
||||
**Bekannt, nicht neu:** axe meldet im Menü-Editor `nested-interactive`, weil der Ziehknopf in der Zusammenfassung jedes Eintrags steckt. Die Editor-Prüfung prüft dort nur die neuen Felder.
|
||||
|
||||
**Geprüft:** neue Tests `EditorSecurityTest::testMenuAnchorsAndPostTermsAreOfferedForChoosing` und `…testPluginSettingsForLinksAndImagesAreChosenNotTyped`. Die Editor-Prüfung wählt im Menü eine Seite als eigenen Link, prüft die Sprungziel-Auswahl eines neuen Seitenlinks, legt einen Beitrag mit Kategorie an und nimmt die Kategorie per Klick auf und wieder heraus (jeweils mit axe).
|
||||
|
||||
## Fassung 1.22.2: Auswahl statt abgetippter Kennungen und Adressen (22.09.2026)
|
||||
|
||||
**Anlass:** Umut zu den Beitragseinstellungen: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild“, dann: „Andere bereiche prüfen ob es sowas noch gibt!“
|
||||
|
||||
Die ganze Verwaltung und der Editor wurden nach Feldern durchsucht, in die man eine Kennung, eine Adresse oder einen technischen Namen tippen musste, der auf etwas anderes zeigt. Umgebaut:
|
||||
|
||||
- **Vorschaubild beim Teilen und Beitragsbild** (Seiten und Beiträge → Einstellungen): Bildfeld mit Vorschau, „Bild wählen oder hochladen“ im Mediathek-Dialog, „Entfernen“ (`admin/media-field.html.twig`, `media-field.js`, Twig-Funktion `kant_media`). Die Kennung steht nur noch im versteckten Feld.
|
||||
- **Plugin-Einstellungen der Art Bild:** dasselbe Bildfeld statt „Kennung aus der Mediathek“.
|
||||
- **Editor, Formular-Baustein, Sektions-Baustein, Beitragsliste (Kategorie, Schlagwort):** Auswahllisten statt Adressen. Die Listen kommen mit `/api/design` (`choices`). Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt als „(nicht mehr vorhanden)“ sichtbar.
|
||||
- **Linkziele im Editor** (Button, Bild, Symbolkarte, CTA, Symbol, Link-Felder von Plugins) und **Links im Textbaustein:** „Seite wählen“ mit Seitensuche (`openPageDialog` in `page-picker.js`) neben „PDF aus Mediathek wählen“. Freie Adressen bleiben möglich. Der Schalter „In neuem Tab öffnen“ erscheint bei Plugin-Feldern nicht mehr, deren Schema kennt ihn nicht.
|
||||
- **Weiterleitungen, neue Adresse:** Seitensuche. Ein anderer Pfad (etwa `kategorie/projekte`) lässt sich weiter eintippen (`data-free` im Seitenwähler).
|
||||
- **Formular-Editor:** „Antwortadresse aus Feld“ wählt aus den E-Mail-Feldern, „Nur zeigen, wenn“ aus den Auswahlfeldern weiter oben, mit Kästchen für die Antworten. „Bild je Option“ öffnet den Mediathek-Dialog mit Vorschau statt einer Aufklappliste. Ohne Skript bleiben die Textfelder, die gespeichert werden.
|
||||
|
||||
**Bewusst getippt bleibt:** was an der Stelle erst entsteht (Adresse einer neuen Seite, eines Formulars, einer Sektion, Farb- und Schriftschlüssel, technischer Feldname, Anker), die alte Adresse einer Weiterleitung, Einbettungsadressen, Schlüssel für Turnstile. Noch offen: Link-Einstellungen von Plugins in der Plugin-Verwaltung (Textfeld), Sprungziel im Menü, Vorschläge vorhandener Kategorien und Schlagwörter in den Beitragseinstellungen.
|
||||
|
||||
**Geprüft:** neue Tests `EditorSecurityTest::testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId` und `…testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses`. Die Editor-Prüfung (`npm run test:editor`) wählt jetzt im Browser ein Vorschaubild und speichert es. Sie wählt eine Seite als Linkziel (mit axe) und prüft, dass Formular, Kategorie und Schlagwort Auswahlfelder sind. Im Formular-Editor wählt sie Bedingung, Antwortadresse und ein Optionsbild, legt das Formular an und liest es wieder (mit axe).
|
||||
|
||||
## Fassung 1.22.1: vergrößerbare Galerie mit einem Haken (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bei Klick vergrößern bei Galerie muss man da alles einzeln machen? kann man bei Galerie das mit einem Klick für alle elemente machen?“
|
||||
|
||||
- Der Haken „Bilder vergrößerbar“ an der Galerie gilt jetzt für jedes Bild darin, das unter „Beim Klick“ nichts selbst gewählt hat. Das gilt auch für später hinzugefügte Bilder. Bisher schaltete er nur das Blättern im Popup ein, und jedes Bild musste einzeln auf „Bild vergrößern“ gestellt werden.
|
||||
- Eine eigene Wahl geht vor: „Nichts tun“ bleibt still, ein Link bleibt ein Link. „Nie eingestellt“ und „Nichts tun“ sind im Dokument unterscheidbar, weil der Editor `click` erst beim Ändern speichert. Bestehende Galerien ändern sich deshalb nur dort, wo der Haken gesetzt ist und ein Bild noch nie eingestellt wurde.
|
||||
- Der Editor zeigt bei einem solchen Bild „Bild vergrößern“ als aktuelle Wahl, nach derselben Regel wie die Vorlage (`galleryZoom` in `blocks/block.html.twig`).
|
||||
|
||||
**Geprüft:** neuer Test `CmsTest::testAZoomableGalleryMakesEveryImageWithoutItsOwnChoiceZoomable` (ohne Wahl, „Nichts tun“, Link, Galerie ohne Haken), volle Kette vor dem Release.
|
||||
|
||||
## PDFs in der Mediathek, als Linkziel und im Betrachter (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „kann man aktuell PDFs hochladen und als Button verlinken? … und ein Inline PDF Viewer wie Bild-Popup“, danach: „ja setz um, einfacher Browser-Betrachter“.
|
||||
|
||||
- **Hochladen:** Das Upload-Feld der Mediathek nimmt jetzt neben JPEG, PNG und WebP auch PDFs bis 20 MB an. Ob eine Datei ein PDF ist, entscheidet ihr Inhalt (`%PDF-` in den ersten 1024 Bytes), nicht die Endung. Die PDFs stehen in einer eigenen Tabelle `document` (Migration `20260917000100`) und nicht in `media`. Jede Bildauswahl erwartet dort Varianten mit Breite und Höhe. Die Datei liegt unverändert als `<id>.pdf` im Medienordner und geht deshalb mit jeder Sicherung mit. Die Mediathek zeigt die PDFs unter den Bildern, mit Titel und Archivieren.
|
||||
- **Ausliefern:** `/media/<id>.pdf` kommt mit `Content-Type: application/pdf`, `nosniff`, `inline` und dem Titel als Dateinamen, `X-Robots-Tag: noindex` und `frame-ancestors 'self'`: Einbetten darf es nur die eigene Website. Archivierte PDFs bleiben erreichbar, wie Bilder.
|
||||
- **Verlinken:** Jedes Linkziel (Button, Bild, Symbolkarte, CTA, Symbol) hat den Knopf „PDF aus Mediathek wählen“ mit einer Liste, Suche und Hochladen direkt im Dialog. Ins Feld kommt die Adresse `/media/<id>.pdf`, ein gewöhnlicher lokaler Pfad, also ohne neue Schemaversion.
|
||||
- **Betrachter:** `public/assets/lightbox.js` (dieselbe Datei wie der Bildbetrachter, damit eine Seite kein Skript mehr lädt) zeigt ein verlinktes PDF in einem `<dialog>` mit `<iframe>` und nutzt dafür den PDF-Betrachter des Browsers, dazu Herunterladen, „Im neuen Tab“ und Schließen. Wo der Browser keinen eingebauten Betrachter hat (`navigator.pdfViewerEnabled`) oder das Gerät ein Touchgerät ist, öffnet sich das PDF im neuen Tab: Android-Chrome hat keinen Betrachter, iOS-Safari zeigt im Rahmen nur die erste Seite. Ist „In neuem Tab öffnen“ gesetzt oder das Skript nicht geladen, bleibt es ein normaler Link. Skript und `frame-src 'self'` kommen nur auf Seiten, deren Dokumente (auch Kopf, Fuß, Sektionen und Fließtext) auf ein PDF der Mediathek zeigen.
|
||||
- **Grenzen:** Hochgeladene PDFs sind über ihre Adresse öffentlich. Kein PDF.js, also sieht der Betrachter je nach Browser anders aus. Chrome zeigt im Kopf des Betrachters die Kennung aus der Adresse, nicht den Titel. PDFs werden nicht gelöscht, nur archiviert.
|
||||
|
||||
**Geprüft:** zwei neue Tests (`CmsTest::testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer`, `EditorSecurityTest::testPdfsGoThroughTheMediaUploadAndTheEditorPicker`: Ablehnung von Nicht-PDFs, Kopfzeilen, 404, Skript und Richtlinie nur mit PDF-Link, Formular-Upload, API mit Token, Archiv). Im Browser mit der Pilotkopie: Klick auf den Button öffnet das zweiseitige PDF im Popup, Schließen gibt den Fokus an den Button zurück, bei 375 px mit Touch öffnet es einen neuen Tab. Der Auswahldialog im Editor ist nur per Syntaxprüfung geprüft, nicht im Browser. Dafür wäre eine Anmeldung nötig.
|
||||
|
||||
## Siebte Installation: Landingpage „KANTARCI Immobilien – Mietanfrage“ (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „neues Projekt wir brauchen eine einfache landingpage nach gleichem prinzip. einfache landingpage impressum und datenschutz notwendig? baus ein“ – dazu das Paket `KANTARCI-Immobilien-Landingpage.zip` (Next.js/Vinext für Cloudflare mit D1-Datenbank: Kopf, dunkler Hero, Werteleiste, Mietanfrage, Fuß, ein Banner als einziges Bild). Später: „Formular gefällt mir noch nicht kannst du ein multipage formular machen ggfls mit icon checkbox“ und die Anbieterdaten (Geschäftsführer Ridvan Kantarci, Ooser Hauptstr. 51, 76532 Baden-Baden, info@kantarci-immo.de).
|
||||
@@ -13,6 +104,16 @@
|
||||
|
||||
**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“.
|
||||
@@ -1061,7 +1162,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');
|
||||
}
|
||||
}
|
||||
+39
-2
@@ -684,6 +684,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts dt { color: var(--muted) }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||
/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */
|
||||
.origin-form { margin-top: 4px; max-width: 560px }
|
||||
.origin-form label:first-of-type { margin-top: 10px }
|
||||
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
|
||||
.card-body .origin-row .secondary { margin: 0 }
|
||||
.origin-form .check-label { margin-top: 10px }
|
||||
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
|
||||
@@ -890,8 +898,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-card-condition .field-help { grid-column: 1 / -1 }
|
||||
.field-card-images { grid-column: 1 / -1 }
|
||||
.field-image-pickers { display: grid; gap: 6px; margin-top: 6px }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker select { margin: 0 }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker-thumb { width: 64px; height: 48px; object-fit: cover; border-radius: 6px; background: #eef1eb }
|
||||
.field-image-picker-thumb[hidden] { display: block; visibility: hidden }
|
||||
.field-image-picker-name { color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
@media (max-width: 767px) { .field-image-picker { grid-template-columns: 64px minmax(0, 1fr); } .field-image-picker-option { grid-column: 1 / -1; font-weight: 650 } }
|
||||
.field-condition-picker { display: grid; gap: 6px; grid-column: 1 / -1 }
|
||||
.field-condition-values { border: 0; padding: 0; margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 16px }
|
||||
.field-condition-values legend { font-size: 13px; font-weight: 650; margin-bottom: 4px; padding: 0 }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
@@ -967,3 +981,26 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
/* Ein Bild der Mediathek als Formularfeld: Vorschau, Name, Wählen und Entfernen. */
|
||||
.media-field-label { display: block; font-weight: 650; font-size: 13px; margin-bottom: 6px }
|
||||
.media-field { min-width: 0 }
|
||||
.media-field-current { display: flex; align-items: center; gap: 10px; min-height: 64px; padding: 6px; border: 1px solid var(--line); border-radius: 9px; background: #fff }
|
||||
.media-field-thumb { width: 96px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #eef1eb }
|
||||
.media-field-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
.media-field-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px }
|
||||
/* Ein Linkfeld: die Adresse zum Tippen, darunter Seite oder PDF zum Wählen. */
|
||||
.link-field-actions { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px }
|
||||
.link-field-actions .text-button { padding: 4px 0; font-size: 13px }
|
||||
/* Vorhandene Kategorien und Schlagwörter zum Anklicken. */
|
||||
.term-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px }
|
||||
.term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer }
|
||||
.term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 }
|
||||
.term-chip[aria-pressed="true"]::before { content: '✓ '; }
|
||||
/* Der Ziehknopf eines Menüeintrags liegt über der linken Ecke der Zusammenfassung, gehört aber
|
||||
nicht zu ihr - sonst stünde ein Knopf in einem Knopf. */
|
||||
.menu-entry { position: relative }
|
||||
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
|
||||
.menu-entry summary { padding-left: 58px; min-height: 60px }
|
||||
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
|
||||
@@ -152,6 +152,35 @@ export function createBlock(type) {
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hebt ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen.
|
||||
*
|
||||
* Normalerweise tut das der Editor in dem Moment, in dem jemand etwas einstellt. Ein Dokument,
|
||||
* das ein Skript geschrieben hat, kann aber eine Eigenschaft tragen, die seine Fassung noch nicht
|
||||
* kennt - der Kopfbereich von kant-it.de hatte die Hamburger-Umschaltung bei Fassung 19. Die
|
||||
* Website zeigt so etwas, der Server lehnt Vorschau und Speichern aber ab, und der Bereich war im
|
||||
* Editor nicht mehr zu bearbeiten. Nur Fassungen, die etwas hinzufügen, werden hier gehoben; die
|
||||
* Umstellung auf formatierten Text (12, 17) ändert die Bedeutung und bleibt eine bewusste Wahl.
|
||||
*/
|
||||
export function repairSchema(document) {
|
||||
const walk = blocks => {
|
||||
for (const block of blocks || []) {
|
||||
upgradeForType(document, block.type);
|
||||
const props = block.props || {};
|
||||
if ('caption' in props) upgradeDocument(document, 3);
|
||||
if ('click' in props || 'lightbox' in props) upgradeDocument(document, 19);
|
||||
if ('newTab' in props) upgradeDocument(document, 20);
|
||||
if (Object.values(block.style || {}).some(values => values?.backgroundImage === 'none')) upgradeDocument(document, 20);
|
||||
if (block.anchor) upgradeDocument(document, 21);
|
||||
if ('collapse' in props) upgradeDocument(document, 22);
|
||||
if ('faq' in props) upgradeDocument(document, 23);
|
||||
walk(block.children);
|
||||
}
|
||||
};
|
||||
walk(document.blocks);
|
||||
return document;
|
||||
}
|
||||
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
|
||||
+52
-18
@@ -1,5 +1,6 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -8,7 +9,7 @@ import { preview, rowPreview } from './library-previews.js';
|
||||
import { decorateContainers } from './preview-containers.js';
|
||||
import { initMotion } from './motion.js';
|
||||
import { initSliders } from './slider.js';
|
||||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS } from './editor-model.js';
|
||||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
|
||||
|
||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
|
||||
@@ -330,7 +331,20 @@ function renderContent(block, panel) {
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
if (key === 'href' || options.link) {
|
||||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||||
// Eine eigene Seite sucht man, statt ihren Pfad zu kennen.
|
||||
const choose = value => { input.value = value; applyValue(); history.mergeKey = null; };
|
||||
const pages = document.createElement('button'); pages.type = 'button'; pages.className = 'secondary href-document'; pages.textContent = 'Seite wählen';
|
||||
pages.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||||
const row = document.createElement('div'); row.className = 'href-pickers'; row.append(pages, pdf);
|
||||
container.append(row);
|
||||
// Den Schalter kennen nur die Linkbausteine des Kerns; ein Plugin legt seine Felder selbst fest.
|
||||
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
}
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
@@ -339,6 +353,21 @@ function renderContent(block, panel) {
|
||||
}
|
||||
const plugin = pluginDefinition(block);
|
||||
if (plugin) { renderPluginFields(block, panel, plugin, field); return; }
|
||||
/*
|
||||
* Eine Auswahl statt einer abgetippten Adresse, für alles, was ein Baustein nur verknüpft.
|
||||
* Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt sichtbar statt still zu
|
||||
* verschwinden. Ohne die Liste (sie kommt mit dem Design-Schema) bleibt es ein Textfeld.
|
||||
*/
|
||||
const pick = (kind, current, empty = null) => {
|
||||
const list = designSchema?.choices?.[kind];
|
||||
if (!list) return {};
|
||||
const choices = {};
|
||||
if (empty !== null) choices[''] = empty;
|
||||
else if (!current) choices[''] = 'Bitte wählen …';
|
||||
Object.assign(choices, list);
|
||||
if (current && !(current in list)) choices[current] = `${current} (nicht mehr vorhanden)`;
|
||||
return { choices };
|
||||
};
|
||||
if (block.type === 'text') {
|
||||
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
|
||||
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
|
||||
@@ -373,7 +402,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 +418,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.' });
|
||||
@@ -403,14 +432,14 @@ function renderContent(block, panel) {
|
||||
field('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' });
|
||||
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
|
||||
}
|
||||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||||
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
||||
if (block.type === 'icon') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
// number, so the value is stored as a number: the server wants an int, not "48".
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – 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.' });
|
||||
@@ -483,7 +512,7 @@ function renderContent(block, panel) {
|
||||
: 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.';
|
||||
panel.append(label, input, note);
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'form') field('Formular', 'form', block.props.form, { ...pick('forms', block.props.form), help: 'Felder, Empfänger und Einwilligungstext werden unter „Formulare“ gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
|
||||
@@ -501,7 +530,7 @@ function renderContent(block, panel) {
|
||||
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
|
||||
panel.append(hint, entries, add);
|
||||
}
|
||||
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt eingeben. Buttons und Menülinks mit #kontakt springen dann hierher; von anderen Seiten mit /seitenadresse#kontakt.' });
|
||||
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt. Im Menü lässt es sich dann als Sprungziel dieser Seite auswählen, ein Button springt mit #kontakt hierher.' });
|
||||
if (block.type === 'accordionitem') {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
|
||||
@@ -519,24 +548,27 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'postlist') {
|
||||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { ...pick('categories', block.props.category, 'Alle Kategorien') });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { ...pick('tags', block.props.tag, 'Alle Schlagwörter'), help: 'Kategorien und Schlagwörter entstehen in den Einstellungen eines Beitrags.' });
|
||||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – 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 +583,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);
|
||||
@@ -736,7 +768,7 @@ function renderLegacyEffects(block, panel) {
|
||||
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
|
||||
panel.append(labelEl, select);
|
||||
}
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit dem Abspielknopf über der Vorschau lässt sich das prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -764,7 +796,7 @@ function renderPluginFields(block, panel, plugin, field) {
|
||||
if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); }
|
||||
panel.append(wrap);
|
||||
}
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength });
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength, plugin: true, link: schema.kind === 'url' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,7 +1096,9 @@ async function load() {
|
||||
designTokens = await response.json();
|
||||
}
|
||||
if (!designSchema) await loadDesignSchema();
|
||||
history = new EditorHistory(data.document);
|
||||
// Ein Dokument mit Eigenschaften einer neueren Fassung wird beim Laden gehoben, sonst lehnt
|
||||
// der Server schon die Vorschau ab (siehe repairSchema).
|
||||
history = new EditorHistory(repairSchema(data.document));
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
|
||||
// bekommt dafür eine eigene Gruppe.
|
||||
|
||||
@@ -23,6 +23,11 @@ die Verwendung des reservierten Namens für eine geänderte Fassung.
|
||||
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
|
||||
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
|
||||
|
||||
**DM Sans** (Fließtext der Seite „Kantarci Markt") steht unter der SIL Open Font License 1.1, Text
|
||||
in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/googlefonts/dm-fonts).
|
||||
DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei,
|
||||
`fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700.
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -14,3 +14,7 @@
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
+121
-15
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
const root = document.querySelector('[data-form-schema]');
|
||||
if (root) {
|
||||
@@ -115,8 +116,10 @@ if (root) {
|
||||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||||
preview.after(note);
|
||||
|
||||
// Statt Kennungen zu tippen: je Option eine Liste der Mediathek. Das Textfeld bleibt das
|
||||
// gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein.
|
||||
// Statt Kennungen zu tippen: je Option ein Bild aus der Mediathek, gewählt im Dialog mit
|
||||
// Vorschau und Hochladen. Das Textfeld bleibt das gespeicherte Original und wird aus der
|
||||
// Auswahl geschrieben; ohne Skript tippt man hinein.
|
||||
const bildVon = new Map(bilder.map(bild => [bild.id, bild]));
|
||||
const renderImagePickers = card => {
|
||||
const area = card.querySelector('[data-field-images-input]');
|
||||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||||
@@ -129,23 +132,124 @@ if (root) {
|
||||
const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim())));
|
||||
box.dataset.signature = options.join('|');
|
||||
box.replaceChildren();
|
||||
const write = () => {
|
||||
const map = {};
|
||||
for (const row of box.querySelectorAll('[data-option]')) if (row.dataset.media) map[row.dataset.option] = row.dataset.media;
|
||||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||||
renderPreview();
|
||||
};
|
||||
for (const option of options) {
|
||||
const row = el('label', 'field-image-picker');
|
||||
row.append(el('span', 'field-image-picker-option', option));
|
||||
const select = document.createElement('select');
|
||||
select.append(new Option('Kein Bild', ''));
|
||||
for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id));
|
||||
select.dataset.option = option;
|
||||
select.addEventListener('change', () => {
|
||||
const map = {};
|
||||
for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value;
|
||||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||||
renderPreview();
|
||||
});
|
||||
row.append(select); box.append(row);
|
||||
const row = el('div', 'field-image-picker'); row.dataset.option = option;
|
||||
const thumb = document.createElement('img'); thumb.className = 'field-image-picker-thumb'; thumb.alt = ''; thumb.width = 64; thumb.height = 48;
|
||||
const name = el('span', 'field-image-picker-name');
|
||||
const choose = el('button', 'secondary', ''); choose.type = 'button';
|
||||
const remove = el('button', 'text-button danger-text', 'Entfernen'); remove.type = 'button';
|
||||
remove.setAttribute('aria-label', `Bild für „${option}“ entfernen`);
|
||||
const show = id => {
|
||||
row.dataset.media = id || '';
|
||||
const bild = id ? bildVon.get(id) : null;
|
||||
thumb.hidden = !bild; if (bild) thumb.src = bild.src; else thumb.removeAttribute('src');
|
||||
name.textContent = id ? (bild?.title || 'Bild gewählt') : 'Kein Bild';
|
||||
choose.textContent = id ? 'Anderes Bild' : 'Bild wählen';
|
||||
choose.setAttribute('aria-label', `${id ? 'Anderes Bild' : 'Bild'} für „${option}“ wählen`);
|
||||
remove.hidden = !id;
|
||||
};
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => {
|
||||
if (!item) return;
|
||||
bildVon.set(item.id, { id: item.id, title: item.title, src: item.variants[0].src });
|
||||
show(item.id); write();
|
||||
} }));
|
||||
remove.addEventListener('click', () => { show(''); write(); choose.focus(); });
|
||||
show(current[option] || '');
|
||||
row.append(el('span', 'field-image-picker-option', option), thumb, name, choose, remove); box.append(row);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Auswahl statt Feldnamen: die Bedingung eines Feldes nennt ein Auswahlfeld weiter oben und
|
||||
* dessen Optionen, die Antwortadresse ein E-Mail-Feld. Die Textfelder bleiben das
|
||||
* gespeicherte Original, versteckt, und werden aus der Auswahl geschrieben.
|
||||
*/
|
||||
const fieldsBefore = card => {
|
||||
const list = [];
|
||||
for (const other of cards()) {
|
||||
if (other === card) break;
|
||||
const name = other.querySelector('[data-field-name]').value.trim();
|
||||
const type = other.querySelector('[data-field-type]').value;
|
||||
if (name && ['select', 'radio', 'checkboxes'].includes(type)) {
|
||||
list.push({ name, label: other.querySelector('[data-field-label]').value.trim() || name,
|
||||
options: other.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean) });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
};
|
||||
const renderCondition = card => {
|
||||
const fieldInput = card.querySelector('[data-field-showif]');
|
||||
const valuesInput = card.querySelector('[data-field-showvalues]');
|
||||
const candidates = fieldsBefore(card);
|
||||
const signature = JSON.stringify([candidates, fieldInput.value, valuesInput.value]);
|
||||
let box = card.querySelector('[data-condition-picker]');
|
||||
if (!box) {
|
||||
box = el('div', 'field-condition-picker'); box.dataset.conditionPicker = '';
|
||||
fieldInput.closest('div').hidden = true; valuesInput.closest('div').hidden = true;
|
||||
fieldInput.closest('[data-field-condition]').prepend(box);
|
||||
const help = box.parentElement.querySelector(':scope > .field-help');
|
||||
if (help) help.textContent = 'Zur Wahl stehen Auswahlfelder weiter oben. Bei einem Schritt gilt die Bedingung für alle seine Felder.';
|
||||
}
|
||||
if (box.dataset.signature === signature) return;
|
||||
box.dataset.signature = signature;
|
||||
const focused = box.contains(document.activeElement) ? document.activeElement.dataset.key : null;
|
||||
box.replaceChildren();
|
||||
const id = `${fieldInput.id}-select`;
|
||||
const label = el('label', '', 'Nur zeigen, wenn'); label.htmlFor = id;
|
||||
const select = document.createElement('select'); select.id = id; select.dataset.key = 'field';
|
||||
select.append(new Option('Immer zeigen', ''));
|
||||
for (const candidate of candidates) select.append(new Option(candidate.label, candidate.name));
|
||||
if (fieldInput.value && !candidates.some(c => c.name === fieldInput.value)) select.append(new Option(`${fieldInput.value} (nicht mehr weiter oben)`, fieldInput.value));
|
||||
select.value = fieldInput.value;
|
||||
select.addEventListener('change', () => { fieldInput.value = select.value; valuesInput.value = ''; refresh(); });
|
||||
box.append(label, select);
|
||||
const chosen = candidates.find(c => c.name === fieldInput.value);
|
||||
if (chosen) {
|
||||
const set = document.createElement('fieldset'); set.className = 'field-condition-values';
|
||||
set.append(el('legend', '', '… eine dieser Antworten gewählt ist'));
|
||||
const current = valuesInput.value.split(',').map(v => v.trim()).filter(Boolean);
|
||||
chosen.options.forEach((option, index) => {
|
||||
const check = el('label', 'check-label');
|
||||
const box2 = document.createElement('input'); box2.type = 'checkbox'; box2.value = option; box2.checked = current.includes(option);
|
||||
box2.dataset.key = `value-${index}`;
|
||||
box2.addEventListener('change', () => {
|
||||
valuesInput.value = [...set.querySelectorAll('input:checked')].map(input => input.value).join(', ');
|
||||
refresh();
|
||||
});
|
||||
check.append(box2, document.createTextNode(` ${option}`)); set.append(check);
|
||||
});
|
||||
box.append(set);
|
||||
}
|
||||
if (focused) box.querySelector(`[data-key="${focused}"]`)?.focus();
|
||||
};
|
||||
const reply = form.querySelector('[name="replyToField"]');
|
||||
const renderReply = () => {
|
||||
if (!reply) return;
|
||||
const emails = cards().map(card => ({ name: card.querySelector('[data-field-name]').value.trim(), label: card.querySelector('[data-field-label]').value.trim(), type: card.querySelector('[data-field-type]').value }))
|
||||
.filter(field => field.name && field.type === 'email');
|
||||
let select = form.querySelector('[data-reply-picker]');
|
||||
if (!select) {
|
||||
select = document.createElement('select'); select.dataset.replyPicker = ''; select.id = reply.id;
|
||||
reply.id += '-value'; reply.type = 'hidden'; reply.after(select);
|
||||
select.addEventListener('change', () => { reply.value = select.value; });
|
||||
}
|
||||
const signature = JSON.stringify([emails, reply.value]);
|
||||
if (select.dataset.signature === signature) return;
|
||||
select.dataset.signature = signature;
|
||||
select.replaceChildren(new Option('Keine – Antworten gehen an den Absender der E-Mail', ''));
|
||||
for (const field of emails) select.append(new Option(field.label || field.name, field.name));
|
||||
if (reply.value && !emails.some(field => field.name === reply.value)) select.append(new Option(`${reply.value} (kein E-Mail-Feld mehr)`, reply.value));
|
||||
select.value = reply.value;
|
||||
const help = reply.parentElement.querySelector('.field-help');
|
||||
if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.';
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
@@ -153,6 +257,7 @@ if (root) {
|
||||
card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type);
|
||||
card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type);
|
||||
if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card);
|
||||
renderCondition(card);
|
||||
card.querySelector('[data-field-number]').hidden = type !== 'number';
|
||||
const remove = card.querySelector('[data-remove-field]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
|
||||
@@ -162,6 +267,7 @@ if (root) {
|
||||
else if (type === 'step') { required.checked = false; required.disabled = true; }
|
||||
else required.disabled = false;
|
||||
});
|
||||
renderReply();
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
|
||||
@@ -99,3 +99,86 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/*
|
||||
* Der PDF-Betrachter - in derselben Datei wie der Bildbetrachter, damit eine Seite mit beidem
|
||||
* nicht zwei Skripte laedt.
|
||||
*
|
||||
* Ohne dieses Skript ist ein Link auf ein PDF ein gewöhnlicher Link: der Browser öffnet die
|
||||
* Datei. Das Skript zeigt sie stattdessen in einem Rahmen über der Seite, mit dem Betrachter,
|
||||
* den der Browser ohnehin mitbringt - kein eigener Nachbau, der ein Megabyte wiegt.
|
||||
*
|
||||
* Wo der Browser keinen solchen Betrachter hat oder ihn im Rahmen nur halb zeigt, bleibt es beim
|
||||
* neuen Tab: Chrome auf Android hat keinen, und Safari auf dem iPhone zeigt im Rahmen nur die
|
||||
* erste Seite. Ein Link mit „In neuem Tab öffnen“ bleibt ohnehin, was er ist.
|
||||
*/
|
||||
(() => {
|
||||
const PDF = /^\/media\/[a-f0-9]{32}\.pdf$/;
|
||||
const links = [...document.querySelectorAll('a[href]')].filter(link => {
|
||||
const url = new URL(link.href, location.href);
|
||||
|
||||
return url.origin === location.origin && PDF.test(url.pathname) && '_blank' !== link.target;
|
||||
});
|
||||
if (0 === links.length) {
|
||||
return;
|
||||
}
|
||||
const eingebettet = false !== navigator.pdfViewerEnabled && !matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
let dialog = null;
|
||||
let rahmen = null;
|
||||
let titel = null;
|
||||
let laden = null;
|
||||
|
||||
const baue = () => {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'pdf-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'pdf-dialog-title');
|
||||
dialog.innerHTML = `
|
||||
<div class="pdf-bar">
|
||||
<p class="pdf-title" id="pdf-dialog-title"></p>
|
||||
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
||||
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
|
||||
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
||||
`;
|
||||
document.body.append(dialog);
|
||||
rahmen = dialog.querySelector('.pdf-frame');
|
||||
titel = dialog.querySelector('.pdf-title');
|
||||
laden = [dialog.querySelector('.pdf-download'), dialog.querySelector('.pdf-open')];
|
||||
dialog.querySelector('.pdf-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
// Der Rahmen wird geleert, sonst läuft ein eingebettetes Video oder Audio im PDF weiter
|
||||
// und das nächste Öffnen zeigt kurz das vorige Dokument.
|
||||
dialog.addEventListener('close', () => rahmen.removeAttribute('src'));
|
||||
};
|
||||
|
||||
for (const link of links) {
|
||||
link.addEventListener('click', event => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!eingebettet) {
|
||||
window.open(link.href, '_blank', 'noopener');
|
||||
|
||||
return;
|
||||
}
|
||||
if (null === dialog) {
|
||||
baue();
|
||||
}
|
||||
const name = (link.getAttribute('aria-label') || link.textContent || '').trim();
|
||||
titel.textContent = name || 'Dokument';
|
||||
rahmen.title = name ? `PDF: ${name}` : 'PDF-Dokument';
|
||||
for (const aktion of laden) {
|
||||
aktion.href = link.href;
|
||||
}
|
||||
rahmen.src = link.href;
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { openDocuments } from './media.js';
|
||||
|
||||
/*
|
||||
* Ein Linkfeld in der Verwaltung: die Adresse bleibt ein Textfeld - eine fremde Seite tippt man
|
||||
* ein -, daneben „Seite wählen“ und „PDF wählen“. Ohne Skript bleibt das Textfeld allein.
|
||||
*
|
||||
* Markup: <div data-link-field> mit einem <input data-link-input> darin. Felder, die später
|
||||
* erscheinen (neue Menüeinträge), bekommen ihre Knöpfe beim ersten Fokus.
|
||||
*/
|
||||
const enhance = field => {
|
||||
if (field.dataset.linkReady) return;
|
||||
const input = field.querySelector('[data-link-input]');
|
||||
if (!input) return;
|
||||
field.dataset.linkReady = '1';
|
||||
const row = document.createElement('div'); row.className = 'link-field-actions';
|
||||
const choose = value => { input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); input.focus(); };
|
||||
const page = document.createElement('button'); page.type = 'button'; page.className = 'text-button'; page.textContent = 'Seite wählen';
|
||||
page.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'text-button'; pdf.textContent = 'PDF wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||||
row.append(page, pdf); input.after(row);
|
||||
};
|
||||
const all = () => document.querySelectorAll('[data-link-field]').forEach(enhance);
|
||||
all();
|
||||
new MutationObserver(all).observe(document.body, { childList: true, subtree: true });
|
||||
@@ -28,3 +28,17 @@
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
/* PDFs: eine Liste statt eines Rasters - es gibt nichts zu sehen außer dem Titel. */
|
||||
.document-dialog{max-width:720px}
|
||||
.document-list{display:grid;gap:8px;max-height:360px;overflow:auto;padding:4px}
|
||||
.document-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;border:2px solid var(--line);border-radius:9px;padding:10px 12px;background:white;color:var(--ink);text-align:left}
|
||||
.document-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.document-card strong{font-size:13px;overflow-wrap:anywhere}
|
||||
.document-card span{font-size:11px;color:var(--muted)}
|
||||
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
|
||||
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
.href-pickers{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 4px}
|
||||
.href-document{margin:0}
|
||||
/* Seite als Linkziel wählen: schmal, die Trefferliste darf überstehen. */
|
||||
.page-dialog{max-width:560px;min-height:420px;overflow:visible}
|
||||
.page-dialog .content-picker{margin-top:16px}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dragging images onto the upload field, and saying which files are about to go up.
|
||||
* Dragging images and PDFs onto the upload field, and saying which files are about to go up.
|
||||
*
|
||||
* Everything here is an addition. Without this script the field is still a file input with a
|
||||
* label: clicking it opens the file picker and the form submits the same way. The script never
|
||||
@@ -9,19 +9,19 @@ const zone = document.querySelector('[data-dropzone]');
|
||||
const input = zone?.querySelector('input[type="file"]');
|
||||
const chosen = document.querySelector('[data-chosen]');
|
||||
if (zone && input && chosen) {
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
|
||||
const describe = (files, ignored = 0) => {
|
||||
if (!files.length) {
|
||||
chosen.textContent = ignored
|
||||
? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.`
|
||||
? `Keine passende Datei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG, WebP und PDF.`
|
||||
: 'Noch keine Datei gewählt.';
|
||||
return;
|
||||
}
|
||||
const names = [...files].map(file => file.name).join(', ');
|
||||
const size = [...files].reduce((total, file) => total + file.size, 0);
|
||||
chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}`
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : '');
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG, WebP und PDF.` : '');
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => describe(input.files));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* Bildfelder in Formularen der Verwaltung: Vorschaubild beim Teilen, Beitragsbild.
|
||||
*
|
||||
* Über den Dokumentknoten statt je Feld, weil die Suche in der Inhaltsliste die Ergebnisse
|
||||
* austauscht - ein Feld, das danach erscheint, funktioniert trotzdem.
|
||||
*/
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-media-choose], [data-media-remove]');
|
||||
const field = button?.closest('[data-media-field]');
|
||||
if (!field) return;
|
||||
const input = field.querySelector('input[type="hidden"]');
|
||||
const thumb = field.querySelector('.media-field-thumb');
|
||||
const name = field.querySelector('.media-field-name');
|
||||
const choose = field.querySelector('[data-media-choose]');
|
||||
const remove = field.querySelector('[data-media-remove]');
|
||||
const show = item => {
|
||||
input.value = item?.id ?? '';
|
||||
thumb.hidden = !item;
|
||||
if (item) thumb.src = item.variants[0].src; else thumb.removeAttribute('src');
|
||||
name.textContent = item ? item.title : 'Kein Bild gewählt';
|
||||
choose.textContent = item ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !item;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
if (button.matches('[data-media-remove]')) { show(null); choose.focus(); return; }
|
||||
// Kein Alternativtext nötig: das Bild erscheint als Vorschau beim Teilen oder in einer Karte,
|
||||
// deren Titel schon sagt, worum es geht.
|
||||
openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) show(item); } });
|
||||
});
|
||||
@@ -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());
|
||||
@@ -1,4 +1,5 @@
|
||||
import { setupPicker } from './page-picker.js';
|
||||
import './link-field.js';
|
||||
|
||||
const form = document.querySelector('#menu-editor');
|
||||
const list = form.querySelector('#menu-items');
|
||||
@@ -74,8 +75,9 @@ function createRow(data) {
|
||||
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
|
||||
const details = el('details'); const summary = el('summary');
|
||||
const { handle, chevron } = chrome();
|
||||
summary.append(handle, el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details);
|
||||
// Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr - kein Knopf in einem Knopf.
|
||||
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details);
|
||||
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
|
||||
if (data.type === 'page') {
|
||||
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
|
||||
@@ -86,7 +88,8 @@ function createRow(data) {
|
||||
picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = '';
|
||||
body.append(picker); setupPicker(picker);
|
||||
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
||||
anchorChoice(row);
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); }
|
||||
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
|
||||
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
|
||||
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
|
||||
@@ -101,6 +104,58 @@ function createRow(data) {
|
||||
row.append(actions); return row;
|
||||
}
|
||||
|
||||
/*
|
||||
* Das Sprungziel wird aus den Ankern der verknüpften Seite gewählt. Das Textfeld bleibt, was
|
||||
* gespeichert wird - versteckt, und ohne Skript sichtbar zum Eintippen.
|
||||
*/
|
||||
const adminBase = document.querySelector('meta[name="admin-base"]').content;
|
||||
function anchorChoice(row) {
|
||||
const input = row.querySelector('input[data-key="anchor"]');
|
||||
const pageValue = row.querySelector('.picker-value');
|
||||
if (!input || !pageValue || input.dataset.enhanced) return;
|
||||
input.dataset.enhanced = '1';
|
||||
const label = row.querySelector(`label[for="${input.id}"]`);
|
||||
const select = el('select'); select.id = input.id; input.id += '-value'; input.type = 'hidden';
|
||||
if (label) { label.htmlFor = select.id; label.textContent = 'Sprungziel'; }
|
||||
input.after(select);
|
||||
const help = el('p', 'field-help', ''); select.after(help);
|
||||
let request = 0;
|
||||
const load = async () => {
|
||||
const mine = ++request, current = input.value;
|
||||
select.replaceChildren(new Option('Seitenanfang', ''));
|
||||
if (current) select.append(new Option(`#${current}`, current));
|
||||
select.value = current;
|
||||
help.textContent = '';
|
||||
if (!pageValue.value) { select.disabled = true; return; }
|
||||
select.disabled = false;
|
||||
try {
|
||||
const response = await fetch(`${adminBase}/api/sprungziele/${encodeURIComponent(pageValue.value)}`, { credentials: 'same-origin' });
|
||||
if (!response.ok || response.redirected) throw new Error();
|
||||
const { items } = await response.json();
|
||||
if (mine !== request) return;
|
||||
select.replaceChildren(new Option('Seitenanfang', ''));
|
||||
for (const item of items) select.append(new Option(item.label, item.anchor));
|
||||
if (current && !items.some(item => item.anchor === current)) select.append(new Option(`#${current} (auf der Seite nicht gefunden)`, current));
|
||||
select.value = current;
|
||||
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor lässt sich einer Sektion, Zeile oder Spalte ein Sprungziel geben.';
|
||||
} catch {
|
||||
if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.';
|
||||
}
|
||||
};
|
||||
select.addEventListener('change', () => { input.value = select.value; input.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
pageValue.addEventListener('change', () => { input.value = ''; load(); });
|
||||
load();
|
||||
}
|
||||
list.querySelectorAll('.menu-entry').forEach(anchorChoice);
|
||||
function linkButtons(input) {
|
||||
// Die Adresse eines eigenen Links: Seite oder PDF wählen, eine fremde Adresse tippen.
|
||||
if (input.closest('[data-link-field]')) return;
|
||||
input.dataset.linkInput = '';
|
||||
const box = el('div', 'link-field'); box.dataset.linkField = '';
|
||||
input.before(box); box.append(input);
|
||||
}
|
||||
list.querySelectorAll('input[data-key="url"]').forEach(linkButtons);
|
||||
|
||||
function restore(value) {
|
||||
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
|
||||
}
|
||||
|
||||
@@ -52,7 +52,11 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
};
|
||||
input.addEventListener('focus', () => search(''));
|
||||
input.addEventListener('input', () => {
|
||||
value.value = ''; input.setCustomValidity(input.value && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
// Ein freies Feld (etwa das Ziel einer Weiterleitung) nimmt auch einen Pfad, der keine Seite
|
||||
// ist; gewählt wird trotzdem am bequemsten aus der Suche.
|
||||
const free = picker.dataset.free === '1';
|
||||
value.value = free ? input.value.trim().replace(/^\/+/, '') : '';
|
||||
input.setCustomValidity(input.value && !free && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
value.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
|
||||
});
|
||||
@@ -66,3 +70,30 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
}
|
||||
|
||||
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
|
||||
/**
|
||||
* Eine Seite oder einen Beitrag für ein Linkziel wählen, in einem kleinen Dialog mit derselben
|
||||
* Suche wie in den Menüs. onChoose bekommt den Treffer mit Titel und Pfad.
|
||||
*/
|
||||
export function openPageDialog({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'media-dialog page-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'page-dialog-title');
|
||||
const source = document.querySelector('meta[name="admin-base"]').content + '/api/content-choices';
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">LINKZIEL</p><h2 id="page-dialog-title">Seite wählen</h2></div><button type="button" class="secondary" data-close>Schließen</button></div>
|
||||
<div class="content-picker">
|
||||
<label for="page-dialog-search">Seite oder Beitrag suchen</label>
|
||||
<div class="picker-input"><input id="page-dialog-search" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="page-dialog-results" placeholder="Titel oder Adresse …"><button type="button" class="subtle picker-clear" aria-label="Suche leeren">✕</button></div>
|
||||
<input type="hidden" class="picker-value">
|
||||
<div id="page-dialog-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
|
||||
<p class="picker-status field-help" role="status"></p>
|
||||
</div>`;
|
||||
const picker = dialog.querySelector('.content-picker');
|
||||
picker.dataset.source = source; picker.classList.add('menu-add');
|
||||
document.body.append(dialog);
|
||||
setupPicker(picker, item => { if (item) { onChoose?.(item); dialog.close(); } });
|
||||
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
dialog.showModal();
|
||||
dialog.querySelector('#page-dialog-search').focus();
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
* the alternative would be a selection engine of our own. The formatting it produces is normalised
|
||||
* on the server anyway, so a browser quirk cannot store something unexpected.
|
||||
*/
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { openDocuments } from './media.js';
|
||||
|
||||
/** Mirrors RichText::ALLOWED. Only used for the hint text and for cleaning up on paste. */
|
||||
export const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'mark', 'sup', 'sub', 'ul', 'ol', 'li', 'a'];
|
||||
@@ -116,7 +118,12 @@ export function createRichText(value, apply, { label = 'Text', plain = false } =
|
||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
||||
});
|
||||
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
// Eine eigene Seite oder ein PDF wählt man, statt die Adresse zu kennen.
|
||||
const fill = value => { linkInput.value = value; linkInput.focus(); };
|
||||
linkRow.append(linkInput,
|
||||
button('Seite …', 'Seite oder Beitrag als Linkziel wählen', () => openPageDialog({ onChoose: item => fill(item.path === 'home' ? '/' : `/${item.path}`) }), 'text-button'),
|
||||
button('PDF …', 'PDF aus der Mediathek als Linkziel wählen', () => openDocuments({ onChoose: item => fill(item.src) }), 'text-button'),
|
||||
newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
@@ -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,42 @@
|
||||
/*
|
||||
* Kategorien und Schlagwörter eines Beitrags: das Textfeld mit der Liste durch Komma bleibt, was
|
||||
* gespeichert wird. Darunter stehen die vorhandenen Namen zum Anklicken - ein Klick nimmt einen
|
||||
* Namen auf oder heraus. Neue Namen tippt man weiter ein.
|
||||
*
|
||||
* Über den Dokumentknoten, weil die Suche in der Inhaltsliste die Ergebnisse austauscht.
|
||||
*/
|
||||
const split = value => value.split(',').map(name => name.trim()).filter(Boolean);
|
||||
const same = (a, b) => a.localeCompare(b, 'de', { sensitivity: 'base' }) === 0;
|
||||
|
||||
const enhance = input => {
|
||||
if (input.dataset.termReady) return;
|
||||
let names = [];
|
||||
try { names = JSON.parse(input.dataset.termInput || '[]'); } catch { names = []; }
|
||||
input.dataset.termReady = '1';
|
||||
if (!names.length) return;
|
||||
const box = document.createElement('div'); box.className = 'term-chips'; box.setAttribute('role', 'group');
|
||||
const label = document.querySelector(`label[for="${input.id}"]`)?.textContent || 'Vorhandene Namen';
|
||||
box.setAttribute('aria-label', `${label}: vorhandene zum Übernehmen`);
|
||||
const sync = () => {
|
||||
const chosen = split(input.value);
|
||||
for (const chip of box.children) chip.setAttribute('aria-pressed', String(chosen.some(name => same(name, chip.dataset.name))));
|
||||
};
|
||||
for (const name of names) {
|
||||
const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'term-chip'; chip.dataset.name = name; chip.textContent = name;
|
||||
chip.addEventListener('click', () => {
|
||||
const chosen = split(input.value);
|
||||
const next = chosen.some(entry => same(entry, name)) ? chosen.filter(entry => !same(entry, name)) : [...chosen, name];
|
||||
input.value = next.join(', ');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
sync();
|
||||
});
|
||||
box.append(chip);
|
||||
}
|
||||
input.addEventListener('input', sync);
|
||||
const help = input.parentElement.querySelector('.field-help');
|
||||
(help ?? input).after(box);
|
||||
sync();
|
||||
};
|
||||
const all = () => document.querySelectorAll('[data-term-input]').forEach(enhance);
|
||||
all();
|
||||
new MutationObserver(all).observe(document.body, { childList: true, subtree: true });
|
||||
@@ -76,9 +76,15 @@ try {
|
||||
await page.mouse.move(1, 1); await page.waitForTimeout(7700);
|
||||
assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
assert.equal(await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).isDisabled(), true);
|
||||
// Seit 1.11 entscheidet die Website (Einstellungen → Bewegung): Standard ist „immer abspielen“,
|
||||
// nur mit „Systemvorgabe beachten“ (Klasse motion-respect) hält „Bewegung reduzieren“ die
|
||||
// Automatik an. Geprüft wird das Verhalten, das die Einstellung verspricht.
|
||||
const respects = await page.evaluate(() => document.documentElement.classList.contains('motion-respect'));
|
||||
const play = controls.locator('.slider-play');
|
||||
assert.equal(await play.isDisabled(), respects, 'the play button follows the motion setting');
|
||||
const reduced = await geometry(); await page.waitForTimeout(7100);
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
|
||||
if (respects) assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay when the site respects it');
|
||||
else assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'autoplay keeps running when the site plays motion always');
|
||||
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'manual navigation works with reduced motion');
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
@@ -143,7 +149,7 @@ try {
|
||||
await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click();
|
||||
assert.equal((await menuSave()).status(), 422);
|
||||
assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)');
|
||||
assert.equal(await page.locator('#menu-error').isVisible(), true);
|
||||
await page.locator('#menu-error').waitFor({ state: 'visible' });
|
||||
await entries.last().locator('summary').click();
|
||||
await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt');
|
||||
assert.equal((await menuSave()).status(), 200);
|
||||
@@ -155,7 +161,7 @@ try {
|
||||
await page.getByLabel('Name des Menüs').fill('QA lokaler Stand');
|
||||
assert.equal((await menuSave()).status(), 409);
|
||||
assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand');
|
||||
assert.equal(await page.locator('#menu-error').isVisible(), true);
|
||||
await page.locator('#menu-error').waitFor({ state: 'visible' });
|
||||
checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true });
|
||||
|
||||
await page.goto(origin + '/admin/forms');
|
||||
@@ -172,7 +178,10 @@ try {
|
||||
await page.getByLabel('Name', { exact: true }).fill('QA Gemeinsame Sektion');
|
||||
await page.getByRole('button', { name: 'Anlegen und bearbeiten', exact: true }).click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
// Die Sektion im Reiter „Struktur“ wählen, dann den Text aus „Elemente“ hineinsetzen.
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator('#structure .tree-item').first().click();
|
||||
await page.getByRole('tab', { name: 'Elemente', exact: true }).click();
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await page.getByLabel('Text', { exact: true }).fill('Dieser Inhalt wird als gemeinsame Sektion gespeichert.');
|
||||
await page.getByLabel('Text', { exact: true }).press('Tab');
|
||||
@@ -197,7 +206,7 @@ try {
|
||||
|
||||
await page.goto(origin + '/admin/pages/' + fixture.homeId);
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
|
||||
await page.locator('#preview-motion').click();
|
||||
const preview = page.frameLocator('#preview'), previewSlider = preview.locator('#hero-slider');
|
||||
// Measure in the outer page: Playwright's in-frame animation stability check can stall in
|
||||
// a no-script sandbox while a Web Animation runs. Use a real pointer on verified geometry.
|
||||
@@ -210,7 +219,10 @@ try {
|
||||
await page.waitForTimeout(800);
|
||||
assert.ok(await previewSlider.evaluate(slider => slider.scrollLeft > 20));
|
||||
assert.equal(await page.locator('#preview').getAttribute('sandbox'), 'allow-same-origin');
|
||||
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
|
||||
// Derselbe Knopf stoppt wieder; er sagt es in seiner Beschriftung und in aria-pressed.
|
||||
assert.equal(await page.locator('#preview-motion').getAttribute('aria-label'), 'Bewegung in der Vorschau stoppen');
|
||||
await page.locator('#preview-motion').click();
|
||||
assert.equal(await page.locator('#preview-motion').getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(await preview.locator('.slider-controls').count(), 0);
|
||||
checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Bereitet die Bilder der Seite „Kantarci Markt – Neueröffnung“ für die Mediathek vor.
|
||||
*
|
||||
* Die Mediathek nimmt kein SVG. Das Seitensymbol und der runde Stempel „Frische • Qualität •
|
||||
* Vertrauen“ der Vorlage sind aber SVG (der Stempel mit Text auf einem Kreisbogen, das kann kein
|
||||
* Baustein). Chromium rastert beide zu PNG. Das Logo der Vorlage (1536 × 1024, 1,3 MB) wird auf
|
||||
* seinen sichtbaren Teil zugeschnitten und auf 600 px Breite verkleinert (doppelte Anzeigebreite),
|
||||
* angezeigt wird es mit höchstens 300 px.
|
||||
*
|
||||
* node scripts/build-kantarci-markt-assets.mjs [ordner]
|
||||
* → logo.png, symbol.png, stempel.png im Ordner (Standard var/kantarci-markt-assets)
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ordner = process.argv[2] || 'var/kantarci-markt-assets';
|
||||
const FORST = '#123b26', GOLD = '#a88332', GOLD_HELL = '#d2b978';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const seite = await browser.newPage();
|
||||
|
||||
// Logo: sichtbaren Bereich über den Alphakanal bestimmen, zuschneiden, verkleinern.
|
||||
const logo = (await readFile(`${ordner}/kantarci-markt-logo.png`)).toString('base64');
|
||||
const logoPng = await seite.evaluate(async (daten) => {
|
||||
const bild = new Image();
|
||||
bild.src = `data:image/png;base64,${daten}`;
|
||||
await bild.decode();
|
||||
const quelle = new OffscreenCanvas(bild.width, bild.height);
|
||||
const q = quelle.getContext('2d');
|
||||
q.drawImage(bild, 0, 0);
|
||||
const { data } = q.getImageData(0, 0, bild.width, bild.height);
|
||||
let [x0, y0, x1, y1] = [bild.width, bild.height, 0, 0];
|
||||
for (let y = 0; y < bild.height; y++) {
|
||||
for (let x = 0; x < bild.width; x++) {
|
||||
// Der Schein um die Waage läuft sehr flach aus; erst ab etwa 8 % Deckkraft zählt er.
|
||||
if (data[(y * bild.width + x) * 4 + 3] > 20) {
|
||||
x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rand = 8;
|
||||
x0 = Math.max(0, x0 - rand); y0 = Math.max(0, y0 - rand);
|
||||
x1 = Math.min(bild.width - 1, x1 + rand); y1 = Math.min(bild.height - 1, y1 + rand);
|
||||
const breite = x1 - x0 + 1, hoehe = y1 - y0 + 1;
|
||||
const ziel = Math.min(600, breite);
|
||||
const leinwand = new OffscreenCanvas(ziel, Math.round(hoehe * ziel / breite));
|
||||
const z = leinwand.getContext('2d');
|
||||
z.imageSmoothingQuality = 'high';
|
||||
z.drawImage(bild, x0, y0, breite, hoehe, 0, 0, leinwand.width, leinwand.height);
|
||||
const blob = await leinwand.convertToBlob({ type: 'image/png' });
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
let binaer = '';
|
||||
for (const b of bytes) binaer += String.fromCharCode(b);
|
||||
return { png: btoa(binaer), breite: leinwand.width, hoehe: leinwand.height, ausschnitt: [x0, y0, breite, hoehe] };
|
||||
}, logo);
|
||||
await writeFile(`${ordner}/logo.png`, Buffer.from(logoPng.png, 'base64'));
|
||||
console.log(`logo.png ${logoPng.breite} × ${logoPng.hoehe} (Ausschnitt ${logoPng.ausschnitt.join(', ')})`);
|
||||
|
||||
const rastern = async (svg, groesse, datei) => {
|
||||
await seite.setViewportSize({ width: groesse, height: groesse });
|
||||
await seite.setContent(`<!doctype html><style>html,body{margin:0;background:transparent}svg{display:block}</style>${svg}`);
|
||||
await seite.screenshot({ path: `${ordner}/${datei}`, omitBackground: true });
|
||||
console.log(`${datei} ${groesse} × ${groesse}`);
|
||||
};
|
||||
|
||||
// Seitensymbol: das K mit Blatt aus dem Favicon der Vorlage.
|
||||
await rastern(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256">
|
||||
<rect width="64" height="64" rx="14" fill="#062f24"/>
|
||||
<path d="M19 43V18h7v10l10-10h9L33 30l13 13h-9L26 32v11z" fill="${GOLD}"/>
|
||||
<path d="M41 10c-7 2-10 6-9 13 6-1 10-5 9-13Z" fill="${GOLD}"/></svg>`, 256, 'symbol.png');
|
||||
|
||||
// Stempel: goldene Scheibe, Umschrift auf dem Kreis, Blatt in der Mitte. Doppelte Größe für
|
||||
// scharfe Kanten auf hochauflösenden Bildschirmen (angezeigt mit 122 px).
|
||||
await rastern(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122 122" width="244" height="244">
|
||||
<circle cx="61" cy="61" r="61" fill="${GOLD_HELL}"/>
|
||||
<g transform="translate(10 10) scale(1.1333)">
|
||||
<defs><path id="kreis" d="M45,45 m-31,0 a31,31 0 1,1 62,0 a31,31 0 1,1 -62,0"/></defs>
|
||||
<text font-family="Arial, sans-serif" font-weight="700" font-size="8" letter-spacing="1.4" fill="${FORST}"><textPath href="#kreis">FRISCHE • QUALITÄT • VERTRAUEN • </textPath></text>
|
||||
<path d="M45 52c-2-16 6-25 22-26-1 16-8 24-22 26Zm0 0c-8-11-17-13-27-7 7 12 16 14 27 7Z" fill="${FORST}"/>
|
||||
</g></svg>`, 244, 'stempel.png');
|
||||
|
||||
await browser.close();
|
||||
@@ -30,6 +30,13 @@ try {
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
// Ein Baustein wird im Reiter „Struktur“ gewählt und mit „Bearbeiten“ geöffnet - die
|
||||
// Seitenleiste zeigt seit 1.11 nur eine Ansicht auf einmal.
|
||||
const selectBlock = async id => {
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator(`#structure [data-block-id="${id}"]`).click();
|
||||
await page.locator(`#structure [data-edit-block="${id}"]`).click();
|
||||
};
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
@@ -53,7 +60,7 @@ try {
|
||||
checks.push('global typography, visual presets, normal/hover preview and actual form save including empty palette rows');
|
||||
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
await page.locator('#structure [data-block-id="qa-button"]').click();
|
||||
await selectBlock('qa-button');
|
||||
await page.getByRole('button', { name: 'Button-Vorlage Abgerundet', exact: true }).click();
|
||||
const waitVariant = async (id, radius, background) => page.waitForFunction(({id,radius,background}) => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, el = doc?.getElementById(id);
|
||||
@@ -75,7 +82,7 @@ try {
|
||||
await page.locator('[data-viewport="desktop"]').click(); await waitVariant('qa-button', '8px', false);
|
||||
await page.locator('.button-designer').screenshot({ path: `${report}/editor-controls.png` });
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await page.locator('#structure [data-block-id="qa-button"]').click(); await waitVariant('qa-button', '8px', false);
|
||||
await page.reload(); await selectBlock('qa-button'); await waitVariant('qa-button', '8px', false);
|
||||
const stored = (await (await context.request.get(api)).json()).document;
|
||||
assert.equal(stored.schemaVersion, 21);
|
||||
assert.deepEqual(stored.blocks[0].children[1].props, { text: 'Mehr erfahren', href: '/kontakt' });
|
||||
|
||||
@@ -360,8 +360,43 @@ try {
|
||||
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
|
||||
return Boolean(previous?.classList.contains('button'));
|
||||
}, headingId);
|
||||
// Ein PDF als Linkziel: im Dialog hochladen, übernehmen, die Adresse steht im Feld.
|
||||
await page.getByRole('button', { name: 'PDF aus Mediathek wählen', exact: true }).click();
|
||||
const documentDialog = page.locator('dialog.document-dialog');
|
||||
await documentDialog.waitFor();
|
||||
await documentDialog.locator('#document-upload:enabled').waitFor({ state: 'attached' });
|
||||
await documentDialog.locator('#document-upload').setInputFiles({ name: 'Preisliste.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF\n') });
|
||||
await documentDialog.locator('.document-card[aria-pressed="true"]').waitFor();
|
||||
assert.equal(await documentDialog.locator('#document-error').isHidden(), true, 'the PDF upload reports no error');
|
||||
const pdfAxe = await new AxeBuilder({ page }).include('dialog.document-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(pdfAxe.violations.map(v => v.id), [], 'the PDF dialog has no accessibility violations');
|
||||
await documentDialog.getByRole('button', { name: 'PDF übernehmen', exact: true }).click();
|
||||
await documentDialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/);
|
||||
await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? ''));
|
||||
checks.push({ name: 'a PDF from the library as a link target', passed: true });
|
||||
// Eine eigene Seite als Linkziel: gesucht und gewählt, nicht als Pfad abgetippt.
|
||||
await page.getByRole('button', { name: 'Seite wählen', exact: true }).click();
|
||||
const pageDialog = page.locator('dialog.page-dialog');
|
||||
await pageDialog.locator('[role="option"]').first().waitFor();
|
||||
const pageAxe = await new AxeBuilder({ page }).include('dialog.page-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(pageAxe.violations.map(v => v.id), [], 'the page dialog has no accessibility violations');
|
||||
await pageDialog.locator('[role="option"]').first().click();
|
||||
await pageDialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/[a-z0-9\-/]*$/);
|
||||
checks.push({ name: 'a page as a link target, picked by search', passed: true });
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
// Verknüpfte Dinge kommen aus Listen: Formular, Kategorie und Schlagwort sind Auswahlfelder.
|
||||
for (const [type, id] of [['form', 'property-form'], ['postlist', 'property-category'], ['postlist', 'property-tag']]) {
|
||||
await addFromLibrary(`button[data-add="${type}"]`);
|
||||
assert.equal(await page.locator(`#${id}`).evaluate(node => node.tagName), 'SELECT', `${id} is a choice, not a typed address`);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
}
|
||||
checks.push({ name: 'forms, categories and tags are picked from lists', passed: true });
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
|
||||
// --- Der Werkzeugkasten kennt den Zusammenhang ---------------------------------------------
|
||||
@@ -1092,6 +1127,118 @@ try {
|
||||
await axe('mobile media library');
|
||||
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
|
||||
// Das Vorschaubild einer Seite: aus der Mediathek gewählt, nicht als Kennung abgetippt.
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(origin + '/admin');
|
||||
const sheet = page.locator('details.sheet').first();
|
||||
await sheet.locator('summary').click();
|
||||
const ogField = sheet.locator('[data-media-field]').filter({ has: page.locator('input[name="ogMediaId"]') });
|
||||
await ogField.getByRole('button', { name: 'Bild wählen oder hochladen', exact: true }).click();
|
||||
await page.locator('#media-dialog [data-media-id]').first().click();
|
||||
await page.locator('#media-insert').click();
|
||||
await page.locator('#media-dialog').waitFor({ state: 'detached' });
|
||||
const chosenOg = await ogField.locator('input[name="ogMediaId"]').inputValue();
|
||||
assert.match(chosenOg, /^[a-f0-9]{32}$/);
|
||||
assert.equal(await ogField.locator('.media-field-thumb').isVisible(), true, 'the chosen image is shown');
|
||||
await axe('content settings with an image field');
|
||||
await ogField.locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await page.waitForLoadState('load');
|
||||
const savedOg = page.locator('input[name="ogMediaId"]').first();
|
||||
assert.equal(await savedOg.inputValue(), chosenOg, 'the image survives saving');
|
||||
checks.push({ name: 'image fields in the content settings pick from the library', passed: true });
|
||||
// Formular-Editor: Bedingung, Antwortadresse und Bild je Option werden gewählt, nicht getippt.
|
||||
await page.goto(origin + '/admin/forms');
|
||||
const neu = page.locator('#neues-formular');
|
||||
if (!(await neu.evaluate(details => details.open))) await neu.locator('summary').click();
|
||||
await neu.locator('[name="name"]').fill('Anfrage Prüfung');
|
||||
await neu.locator('[name="slug"]').fill('anfrage-pruefung');
|
||||
await neu.locator('[name="recipient"]').fill('info@example.com');
|
||||
const karte = index => neu.locator('[data-field-list] [data-field]').nth(index);
|
||||
await karte(0).locator('[data-field-label]').fill('E-Mail');
|
||||
await karte(0).locator('[data-field-type]').selectOption('email');
|
||||
await neu.locator('[data-add-field]').click();
|
||||
await karte(1).locator('[data-field-label]').fill('Art');
|
||||
await karte(1).locator('[data-field-type]').selectOption('radio');
|
||||
await karte(1).locator('[data-field-options-input]').fill('Wohnung, Haus');
|
||||
await neu.locator('[data-add-field]').click();
|
||||
await karte(2).locator('[data-field-label]').fill('Etage');
|
||||
await karte(2).getByLabel('Nur zeigen, wenn', { exact: true }).selectOption({ label: 'Art' });
|
||||
await karte(2).getByLabel('Wohnung', { exact: true }).check();
|
||||
assert.equal(await karte(2).locator('[data-field-showif]').inputValue(), 'art');
|
||||
assert.equal(await karte(2).locator('[data-field-showvalues]').inputValue(), 'Wohnung');
|
||||
await neu.getByLabel('Antwortadresse aus Feld', { exact: true }).selectOption({ label: 'E-Mail' });
|
||||
assert.equal(await neu.locator('input[name="replyToField"]').inputValue(), 'e_mail');
|
||||
await karte(1).getByRole('button', { name: 'Bild für „Wohnung“ wählen', exact: true }).click();
|
||||
await page.locator('#media-dialog [data-media-id]').first().click();
|
||||
await page.locator('#media-insert').click();
|
||||
await page.locator('#media-dialog').waitFor({ state: 'detached' });
|
||||
assert.match(await karte(1).locator('[data-field-images-input]').inputValue(), /^Wohnung = [a-f0-9]{32}$/);
|
||||
assert.equal(await karte(1).locator('.field-image-picker-thumb').first().isVisible(), true);
|
||||
await axe('form editor with pickers');
|
||||
await neu.getByRole('button', { name: 'Formular anlegen', exact: true }).click();
|
||||
await page.waitForLoadState('load');
|
||||
const gespeichert = page.locator('form[action$="/forms/anfrage-pruefung"]');
|
||||
await gespeichert.waitFor({ state: 'attached' });
|
||||
assert.equal(await gespeichert.locator('input[name="replyToField"]').inputValue(), 'e_mail');
|
||||
assert.equal(await gespeichert.locator('[data-field-showif]').nth(2).inputValue(), 'art');
|
||||
checks.push({ name: 'form editor: condition, reply address and option images are picked', passed: true });
|
||||
// Menü: der eigene Link bekommt eine Seite per Suche, ein Seitenlink sein Sprungziel per Auswahl.
|
||||
await page.goto(origin + '/admin/menus');
|
||||
await page.locator('.menu-add details summary').click();
|
||||
await page.locator('#link-url').locator('xpath=..').getByRole('button', { name: 'Seite wählen', exact: true }).click();
|
||||
await page.locator('dialog.page-dialog [role="option"]').first().click();
|
||||
await page.locator('dialog.page-dialog').waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#link-url').inputValue(), /^\/[a-z0-9\-/]*$/);
|
||||
await page.locator('#link-label').fill('Eigener Prüflink');
|
||||
await page.locator('#menu-add-url').click();
|
||||
await page.locator('#menu-search').focus();
|
||||
await page.locator('#menu-search-results [role="option"]').first().click();
|
||||
await page.locator('#menu-add-page').click();
|
||||
const neuerEintrag = page.locator('#menu-items .menu-entry').last();
|
||||
const sprungziel = neuerEintrag.getByLabel('Sprungziel', { exact: true });
|
||||
assert.equal(await sprungziel.evaluate(node => node.tagName), 'SELECT', 'the anchor is a choice');
|
||||
assert.equal(await sprungziel.locator('option').first().textContent(), 'Seitenanfang');
|
||||
// Die ganze Seite: seit der Ziehknopf neben der Zusammenfassung steht, gibt es keinen Knopf
|
||||
// mehr in einem Knopf.
|
||||
await neuerEintrag.locator('summary').click();
|
||||
await axe('menu editor');
|
||||
await page.screenshot({ path: 'reports/editor/menu-editor.png' });
|
||||
assert.equal(await neuerEintrag.locator('summary [data-menu-drag]').count(), 0, 'no button inside the summary');
|
||||
// Ziehknopf per Tastatur: Pfeil nach oben verschiebt den Eintrag.
|
||||
const eintraege = page.locator('#menu-items .menu-entry');
|
||||
const anzahl = await eintraege.count();
|
||||
assert.ok(anzahl >= 2);
|
||||
const titel = await neuerEintrag.locator('.menu-entry-title').textContent();
|
||||
await eintraege.nth(anzahl - 1).locator('[data-menu-drag]').focus();
|
||||
await page.keyboard.press('ArrowUp');
|
||||
assert.equal(await eintraege.nth(anzahl - 2).locator('.menu-entry-title').textContent(), titel, 'the entry moved up');
|
||||
assert.equal(await eintraege.nth(anzahl - 2).locator('[data-menu-drag]').evaluate(node => node === document.activeElement), true, 'the handle keeps focus');
|
||||
checks.push({ name: 'menu: page search for own links, anchors as a choice', passed: true });
|
||||
// Beitrag: vorhandene Kategorien stehen zum Anklicken am Feld.
|
||||
await page.goto(origin + '/admin/posts');
|
||||
await page.locator('summary').filter({ hasText: 'Neuen Beitrag anlegen' }).click();
|
||||
await page.locator('#new-title').fill('Prüfbeitrag');
|
||||
await page.locator('form.create-form button[type="submit"]').click();
|
||||
await page.waitForLoadState('load');
|
||||
await page.goto(origin + '/admin/posts');
|
||||
let beitrag = page.locator('details.sheet').first();
|
||||
await beitrag.locator('summary').click();
|
||||
await beitrag.locator('input[name="categories"]').fill('Projekte');
|
||||
await beitrag.locator('input[name="categories"]').locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await page.waitForLoadState('load');
|
||||
await page.goto(origin + '/admin/posts');
|
||||
beitrag = page.locator('details.sheet').first();
|
||||
await beitrag.locator('summary').click();
|
||||
const kategorie = beitrag.locator('input[name="categories"]');
|
||||
await kategorie.fill('');
|
||||
const vorschlag = beitrag.locator('.term-chip', { hasText: 'Projekte' }).first();
|
||||
await vorschlag.click();
|
||||
assert.equal(await kategorie.inputValue(), 'Projekte');
|
||||
assert.equal(await vorschlag.getAttribute('aria-pressed'), 'true');
|
||||
await vorschlag.click();
|
||||
assert.equal(await kategorie.inputValue(), '');
|
||||
await axe('post settings with term suggestions');
|
||||
checks.push({ name: 'post settings suggest existing categories', passed: true });
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile('reports/editor/result.json', JSON.stringify({ checks, publicDataUntouched: true }, null, 2));
|
||||
console.log('Editor end-to-end checks passed; isolated database, production data unchanged.');
|
||||
|
||||
@@ -34,7 +34,10 @@ try {
|
||||
data.document.blocks.push({ id: 'qa-anchor', type: 'section', children: [{ id: 'qa-anchor-title', type: 'heading', props: { text: 'Kontaktbereich', level: 2 } }] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
// Im Reiter „Struktur“ wählen und mit „Bearbeiten“ öffnen, wie im Editor selbst.
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator('#structure [data-block-id="qa-anchor"]').click();
|
||||
await page.locator('#structure [data-edit-block="qa-anchor"]').click();
|
||||
await page.locator('#property-anchor').fill('#kontakt-check'); await page.locator('#property-anchor').blur();
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Kantarci-Markt-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/kantarci-markt.db"
|
||||
export MEDIA_DIRECTORY="$root/var/kantarci-markt-media"
|
||||
rm -f "$root/var/kantarci-markt.db"
|
||||
rm -rf "$root/var/kantarci-markt-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/kantarci-markt-assets}"
|
||||
# Logo zuschneiden, Seitensymbol und Stempel rastern (Chromium ueber Playwright).
|
||||
node scripts/build-kantarci-markt-assets.mjs "$assets"
|
||||
"$php" -d memory_limit=512M scripts/seed-kantarci-markt.php "$assets"
|
||||
# DM Sans vom eigenen Server. Der Abruf ersetzt in fonts.css nur die Regeln fuer DM Sans; die
|
||||
# Schriften der anderen Installationen bleiben stehen. Das PHP 8.4 hier kennt keine
|
||||
# Stammzertifikate; mit var/cacert.pem (https://curl.se/ca/cacert.pem) klappt der Abruf.
|
||||
if [ -f "$root/var/cacert.pem" ]; then
|
||||
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
@@ -0,0 +1,651 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut die Vorab-Seite „Kantarci Markt – Neueröffnung“ als Inhalt dieses CMS auf.
|
||||
*
|
||||
* Vorlage ist das gelieferte Paket „Kantarci-Markt-Landingpage.zip“ (23.09.2026): eine statische
|
||||
* Seite (index.html, styles.css) mit Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit zwei
|
||||
* Fotos und Stempel, dem Versprechen (Frisch, Qualität, Vielfältig) auf Waldgrün, dem Abschluss
|
||||
* „Frische. Qualität. Faire Preise.“ und einem schmalen Fuß. Wortlaut und Farben (Waldgrün
|
||||
* #123b26, Gold #ad893b, Creme #fcf9f2) sind daraus übernommen. Gebaut ist alles aus gewöhnlichen
|
||||
* Bausteinen – kein eigener Baustein, kein Plugin.
|
||||
*
|
||||
* Bewusste Abweichungen:
|
||||
* - DM Sans kommt vom eigenen Server (cms:fonts:fetch), nicht von Google. Manrope der Vorlage
|
||||
* (nur Kleinzeilen, Nummern und die Bildzeile) entfällt, dort steht DM Sans: eine Schrift
|
||||
* weniger zu laden. Überschriften in Georgia wie in der letzten Fassung der Vorlage.
|
||||
* - Der runde Stempel ist ein PNG (scripts/build-kantarci-markt-assets.mjs): Text auf einem
|
||||
* Kreisbogen kann kein Baustein, und die Mediathek nimmt kein SVG.
|
||||
* - Das große „K“ im Abschluss und der Goldstrich sind Pseudo-Elemente im eigenen CSS der
|
||||
* Sektion, damit Vorlesesoftware und KI-Leser kein einzelnes „K“ als Inhalt bekommen.
|
||||
* - Neu sind Impressum und Datenschutz, beide Pflicht für einen geschäftlichen Auftritt (§ 5 DDG,
|
||||
* Art. 13 DSGVO). Betreiber, Anschrift mit Hausnummer und Kontakt fehlen in der Vorlage; sie
|
||||
* stehen als „NOCH ZU PRÜFEN“ im Text.
|
||||
*
|
||||
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
|
||||
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-kantarci-markt.sh).
|
||||
* Erstes Argument: der Ordner mit logo.png, symbol.png, stempel.png, market.jpg, vegetables.jpg.
|
||||
*/
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$assets = $argv[1] ?? null;
|
||||
if (null === $assets || !is_dir($assets)) {
|
||||
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
|
||||
exit(1);
|
||||
}
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
|
||||
if (!str_contains($url, 'kantarci-markt')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Kantarci-Markt-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
|
||||
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$media = new MediaLibrary($db, $mediaDirectory);
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$partials = new PartialRepository($db, new DocumentValidator());
|
||||
$theme = new Theme($db);
|
||||
|
||||
$name = 'Kantarci Markt';
|
||||
// Die Domain kantarci-markt.de hat Mailserver (23.09.2026). Ob es das Postfach info@ gibt, ist
|
||||
// nicht bestätigt – deshalb steht es nur im Impressum und dort als „NOCH ZU PRÜFEN“.
|
||||
$mail = 'info@kantarci-markt.de';
|
||||
$strasse = 'Wilhelm-Drapp-Straße';
|
||||
$plz = '76532';
|
||||
$ort = 'Baden-Baden';
|
||||
$SCHEMA = 24;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
|
||||
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
|
||||
$nurLinks = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $w];
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w];
|
||||
|
||||
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
|
||||
if (null !== $anchor) {
|
||||
$block['anchor'] = $anchor;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$row = static function (string $id, array $children, array $design = [], array $style = []): array {
|
||||
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
|
||||
if ([] !== $design) {
|
||||
$block['layout'] = ['design' => $design];
|
||||
}
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Eine Zeile, die nebeneinander stellt statt zu rastern. */
|
||||
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = []) use ($row): array {
|
||||
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, 'margin' => ['top' => '0px'], ...$extra]];
|
||||
if ([] !== $tablet) {
|
||||
$style['tablet'] = $tablet;
|
||||
}
|
||||
if ([] !== $mobil) {
|
||||
$style['mobile'] = $mobil;
|
||||
}
|
||||
|
||||
return $row($id, $children, [], $style);
|
||||
};
|
||||
|
||||
$column = static function (string $id, array $children, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
|
||||
if ([] !== $style) {
|
||||
$block['style'] = $style;
|
||||
}
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Eine reine Fläche: der kurze Goldstrich vor der Kleinzeile. */
|
||||
$flaeche = static function (string $id, array $style, string $css = ''): array {
|
||||
$block = ['id' => $id, 'type' => 'spacer', 'props' => ['size' => 'small'], 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
/** Ein Button ohne den Pfeil des Bausteins. */
|
||||
$button = static function (string $id, string $text, string $href, array $style = []): array {
|
||||
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style, 'css' => '& span{display:none}'];
|
||||
};
|
||||
|
||||
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = '', string $bildzeile = ''): array {
|
||||
$gross = end($eintrag['variants']);
|
||||
$props = [
|
||||
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
|
||||
'mediaId' => $eintrag['id'],
|
||||
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
|
||||
];
|
||||
if (null !== $href) {
|
||||
$props['href'] = $href;
|
||||
$props['click'] = 'link';
|
||||
}
|
||||
if ('' !== $bildzeile) {
|
||||
$props['caption'] = $bildzeile;
|
||||
}
|
||||
|
||||
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
|
||||
if ('' !== $css) {
|
||||
$block['css'] = $css;
|
||||
}
|
||||
|
||||
return $block;
|
||||
};
|
||||
|
||||
$symbol = static function (string $id, string $name, int $groesse, array $style = []): array {
|
||||
return ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt die Einblendungen nach einer Liste, statt sie über das ganze Dokument zu streuen.
|
||||
*
|
||||
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
|
||||
*/
|
||||
$einblenden = static function (array $dokument, array $plan): array {
|
||||
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
|
||||
foreach ($bloecke as $i => $block) {
|
||||
if (isset($plan[$block['id'] ?? ''])) {
|
||||
[$art, $verzug] = [...$plan[$block['id']], 'none'];
|
||||
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
|
||||
}
|
||||
if (isset($block['children'])) {
|
||||
$bloecke[$i]['children'] = $gehen($block['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $bloecke;
|
||||
};
|
||||
$dokument['blocks'] = $gehen($dokument['blocks']);
|
||||
|
||||
return $dokument;
|
||||
};
|
||||
|
||||
// Wiederkehrende Gestaltungswerte der Vorlage.
|
||||
$kleinStil = ['fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.2em', 'lineHeight' => 1.2, 'textTransform' => 'uppercase'];
|
||||
$linkCss = "& a{color:#123b26;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#806222}";
|
||||
|
||||
/** Die Kleinzeile in Versalien mit dem kurzen Goldstrich davor („— NEUERÖFFNUNG“). */
|
||||
$kleinzeile = static function (string $id, string $inhalt, string $farbe, string $strichFarbe = '@gold') use ($text, $zeile, $flaeche, $kleinStil): array {
|
||||
return $zeile($id, [
|
||||
$flaeche($id.'-strich', ['desktop' => ['width' => '38px', 'height' => '2px', 'backgroundColor' => $strichFarbe, 'flexShrink' => 0]], 'min-height:0'),
|
||||
$text($id.'-text', $inhalt, ['desktop' => [...$kleinStil, 'color' => $farbe, 'margin' => ['bottom' => '0px']]]),
|
||||
], '12px', ['margin' => ['top' => '0px', 'bottom' => '24px']]);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Website, Gestaltung, Medien
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$db->insert('website', ['id' => 'default', 'name' => $name, 'default_locale' => 'de', 'theme' => '{}']);
|
||||
|
||||
$theme->save(
|
||||
[
|
||||
['key' => 'forst', 'name' => 'Waldgrün', 'value' => '#123b26'],
|
||||
['key' => 'tief', 'name' => 'Tiefgrün (Fuß)', 'value' => '#0b281a'],
|
||||
['key' => 'gold', 'name' => 'Gold (Linien)', 'value' => '#ad893b'],
|
||||
['key' => 'gold-hell', 'name' => 'Gold hell (Flächen, auf Grün)', 'value' => '#d2b978'],
|
||||
['key' => 'gold-text', 'name' => 'Gold als Schrift auf Creme', 'value' => '#806222'],
|
||||
['key' => 'gold-titel', 'name' => 'Gold in großen Überschriften', 'value' => '#8c6b27'],
|
||||
['key' => 'creme', 'name' => 'Creme', 'value' => '#fcf9f2'],
|
||||
['key' => 'sand', 'name' => 'Sand (Abschluss)', 'value' => '#f4efe3'],
|
||||
['key' => 'text', 'name' => 'Fließtext', 'value' => '#58685e'],
|
||||
['key' => 'text-gruen', 'name' => 'Fließtext auf Grün', 'value' => '#b7c8c1'],
|
||||
['key' => 'klein-gruen', 'name' => 'Kleinzeile auf Grün', 'value' => '#a9bdb4'],
|
||||
['key' => 'fuss-text', 'name' => 'Schrift im Fuß', 'value' => '#a9bab2'],
|
||||
['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'],
|
||||
],
|
||||
[
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'DM Sans', 'source' => 'google', 'weights' => [400, 500, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Georgia', 'source' => 'system', 'weights' => [400], 'fallback' => 'serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@forst', 'backgroundColor' => '@creme', 'linkColor' => '@forst', 'containerWidth' => '1280px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
|
||||
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@forst', 'color' => '@papier', 'hoverBackgroundColor' => '#1d5236', 'hoverColor' => '@papier'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
$medien = [];
|
||||
foreach ([
|
||||
'logo' => ['logo.png', 'Kantarci Markt – grünes K mit goldener Waage'],
|
||||
'symbol' => ['symbol.png', 'Bildmarke von Kantarci Markt'],
|
||||
'stempel' => ['stempel.png', ''],
|
||||
'markt' => ['market.jpg', 'Frisches Blattgemüse an einem Marktstand – Beispielbild'],
|
||||
'tomaten' => ['vegetables.jpg', 'Frische Strauchtomaten – Beispielbild'],
|
||||
] as $schluessel => [$datei, $alt]) {
|
||||
$pfad = rtrim($assets, '/\\').'/'.$datei;
|
||||
if (!is_file($pfad)) {
|
||||
fwrite(STDERR, "Bild fehlt: $pfad\n");
|
||||
exit(1);
|
||||
}
|
||||
$eintrag = $media->upload(new UploadedFile($pfad, basename($datei), null, null, true));
|
||||
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
|
||||
$medien[$schluessel] = $media->find($eintrag['id']);
|
||||
echo "Bild übernommen: $datei\n";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Kopf- und Fußbereich
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$kopf = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('kopf', [
|
||||
$zeile('kopf-zeile', [
|
||||
$bild('kopf-logo', $medien['logo'], 'Kantarci Markt – zur Startseite', [
|
||||
// Volle Breite bis 300 px: sonst hängt die Breite in der zentrierten Zeile am
|
||||
// geladenen Bild, und der Kopf springt beim Laden (CLS).
|
||||
'desktop' => ['width' => '100%', 'maxWidth' => '300px', 'margin' => $seiten('0px')],
|
||||
'mobile' => ['maxWidth' => '230px'],
|
||||
], '/', '& img{display:block;width:100%;height:auto}'),
|
||||
], '0px', ['justifyContent' => 'center']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '18px', 'bottom' => '18px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'mobile' => ['padding' => ['top' => '14px', 'bottom' => '14px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$fussLink = "& a{color:#a9bab2}\n& a:hover{color:#fff;text-decoration:underline}";
|
||||
$fuss = [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
|
||||
'blocks' => [
|
||||
$section('fuss', [
|
||||
$zeile('fuss-zeile', [
|
||||
$text('fuss-copyright', '© 2026 '.$name, ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', 'margin' => ['bottom' => '0px']]]),
|
||||
$text('fuss-adresse', $strasse.' · '.$ort, ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', 'margin' => ['bottom' => '0px']]]),
|
||||
$zeile('fuss-links', [
|
||||
// Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü.
|
||||
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
|
||||
'style' => ['desktop' => ['fontSize' => '14px', 'gap' => '4px', 'color' => '@fuss-text']],
|
||||
'css' => $fussLink],
|
||||
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']]], $fussLink),
|
||||
], '22px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
|
||||
], '20px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap'],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@tief', 'color' => '@fuss-text', 'padding' => ['top' => '28px', 'bottom' => '28px']],
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
$partials->create('header', 'default', 'Kopfbereich', $kopf);
|
||||
$partials->create('footer', 'default', 'Fußbereich', $fuss);
|
||||
echo "Kopf- und Fußbereich angelegt\n";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Startseite
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Ein Versprechen: Nummer, Titel, Satz – auf dem Telefon rückt der Satz unter den Titel. */
|
||||
$versprechen = static fn (string $n, string $titel, string $satz): array => $zeile('versprechen-'.$n, [
|
||||
$text('versprechen-'.$n.'-nummer', $n, ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'color' => '@gold-hell', 'flexBasis' => '50px', 'flexShrink' => 0, 'margin' => ['bottom' => '0px']], 'mobile' => ['flexBasis' => '34px']]),
|
||||
$zeile('versprechen-'.$n.'-inhalt', [
|
||||
$heading('versprechen-'.$n.'-titel', $titel, 3, [
|
||||
'desktop' => ['fontSize' => '34px', 'fontWeight' => '400', 'lineHeight' => 1.2, 'color' => '@papier', 'flexGrow' => 3, 'flexBasis' => '0', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '28px'],
|
||||
'mobile' => ['fontSize' => '28px'],
|
||||
]),
|
||||
$text('versprechen-'.$n.'-satz', $satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@text-gruen', 'maxWidth' => '390px', 'flexGrow' => 5, 'flexBasis' => '0', 'margin' => ['bottom' => '0px']]]),
|
||||
], '20px', ['alignItems' => 'baseline', 'flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'],
|
||||
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '8px']),
|
||||
], '20px', ['alignItems' => 'baseline', 'padding' => ['top' => '31px', 'bottom' => '31px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#cfb57359']);
|
||||
|
||||
$startseite = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Neueröffnung',
|
||||
'description' => 'Kantarci Markt eröffnet bald: Frische, Qualität und faire Preise in der Wilhelm-Drapp-Straße in Baden-Baden.',
|
||||
'blocks' => [
|
||||
// 1 – Hero: Text links, rechts das Marktfoto mit Bildzeile, die gedrehte Tomatenkarte und
|
||||
// der Stempel.
|
||||
$section('hero', [
|
||||
$row('hero-zeile', [
|
||||
$column('hero-text', [
|
||||
$kleinzeile('hero-label', 'Neueröffnung', '@gold-text'),
|
||||
$heading('hero-titel', 'Frische bekommt<br><em>eine neue Adresse.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '80px', 'fontWeight' => '400', 'lineHeight' => 1.04, 'letterSpacing' => '-0.045em', 'color' => '@forst', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '64px'],
|
||||
'mobile' => ['fontSize' => '44px', 'lineHeight' => 1.07],
|
||||
], '& em{font-style:italic;color:#8c6b27}'),
|
||||
$text('hero-intro', 'Freuen Sie sich auf sorgfältig ausgewählte Produkte, echte Vielfalt und einen Markt, der Nachbarschaft neu denkt.', [
|
||||
'desktop' => ['maxWidth' => '600px', 'fontSize' => '20px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '32px', 'bottom' => '38px']],
|
||||
'mobile' => ['fontSize' => '17px', 'margin' => ['top' => '24px', 'bottom' => '30px']],
|
||||
]),
|
||||
// Eine Spalte statt einer Zeile: Zeilen sind im Kern immer durchsichtig.
|
||||
$column('hero-termin', [
|
||||
$column('hero-termin-symbol', [
|
||||
$symbol('hero-termin-icon', 'shop', 24, ['desktop' => ['color' => '@forst']]),
|
||||
], ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '50px', 'height' => '50px', 'flexShrink' => 0, 'backgroundColor' => '@gold-hell', 'borderRadius' => $rund('50%')]]),
|
||||
$column('hero-termin-text', [
|
||||
$text('hero-termin-klein', 'Wir öffnen bald', ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'lineHeight' => 1.3, 'color' => '#bfd1c9', 'margin' => ['bottom' => '3px']]]),
|
||||
$text('hero-termin-satz', 'Eröffnungstermin folgt', ['desktop' => ['fontSize' => '16px', 'fontWeight' => '700', 'lineHeight' => 1.2, 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => '17px', 'backgroundColor' => '@forst', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold',
|
||||
'borderRadius' => $rund('3px'), 'padding' => ['top' => '15px', 'right' => '24px', 'bottom' => '15px', 'left' => '15px'],
|
||||
'shadowY' => '18px', 'shadowBlur' => '50px', 'shadowColor' => '#062f2429'],
|
||||
'mobile' => ['gap' => '12px', 'padding' => ['top' => '14px', 'right' => '16px', 'bottom' => '14px', 'left' => '14px']],
|
||||
]),
|
||||
$text('hero-adresse', 'Hier finden Sie uns bald<br><strong>'.$strasse.'</strong><br>'.$ort, [
|
||||
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.55, 'color' => '@text', 'borderWidth' => $nurLinks('3px'), 'borderStyle' => 'solid', 'borderColor' => '@gold', 'padding' => ['left' => '18px'], 'margin' => ['top' => '28px', 'bottom' => '0px']],
|
||||
], "& strong{color:#123b26;font-weight:700}"),
|
||||
], [
|
||||
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'justifyContent' => 'center'],
|
||||
]),
|
||||
$column('hero-visual', [
|
||||
$bild('hero-markt', $medien['markt'], 'Frisches Blattgemüse an einem Marktstand – Beispielbild', [
|
||||
'desktop' => ['position' => 'absolute', 'top' => '0px', 'right' => '4%', 'bottom' => '8%', 'left' => '5%', 'margin' => $seiten('0px'), 'overflow' => 'hidden', 'backgroundColor' => '@forst',
|
||||
'outlineWidth' => '1px', 'outlineStyle' => 'solid', 'outlineColor' => '@gold', 'outlineOffset' => '8px',
|
||||
'shadowY' => '24px', 'shadowBlur' => '60px', 'shadowColor' => '#021d162e'],
|
||||
'mobile' => ['right' => '0px', 'bottom' => '10%', 'left' => '0px'],
|
||||
], null,
|
||||
"& img{display:block;width:100%;height:100%;object-fit:cover}\n"
|
||||
."&::after{content:'';position:absolute;top:25%;right:0;bottom:0;left:0;background:linear-gradient(transparent,rgba(2,29,22,.85) 65%)}\n"
|
||||
."& figcaption{position:absolute;z-index:1;bottom:150px;left:28px;right:28px;max-width:260px;margin:0;color:#fff;font-size:26px;font-weight:700;line-height:1.2;text-shadow:0 2px 14px rgba(2,29,22,.7)}",
|
||||
'Vorfreude auf frische Vielfalt.'),
|
||||
$bild('hero-tomaten', $medien['tomaten'], 'Frische Strauchtomaten – Beispielbild', [
|
||||
'desktop' => ['position' => 'absolute', 'width' => '49%', 'height' => '33%', 'right' => '-3%', 'bottom' => '-1%', 'margin' => $seiten('0px'), 'overflow' => 'hidden', 'rotate' => 4,
|
||||
'borderWidth' => $seiten('8px'), 'borderStyle' => 'solid', 'borderColor' => '@creme', 'shadowY' => '24px', 'shadowBlur' => '60px', 'shadowColor' => '#021d162e', 'zIndex' => 2],
|
||||
'mobile' => ['width' => '63%', 'height' => '39%', 'right' => '0px', 'borderWidth' => $seiten('7px')],
|
||||
], null, '& img{display:block;width:100%;height:100%;object-fit:cover}'),
|
||||
$bild('hero-stempel', $medien['stempel'], '', [
|
||||
'desktop' => ['position' => 'absolute', 'maxWidth' => '122px', 'width' => '122px', 'left' => '-1%', 'bottom' => '9%', 'margin' => $seiten('0px'), 'rotate' => -12, 'zIndex' => 3,
|
||||
'borderRadius' => $rund('50%'), 'shadowY' => '8px', 'shadowBlur' => '24px', 'shadowColor' => '#123b2626'],
|
||||
'mobile' => ['maxWidth' => '96px', 'width' => '96px', 'left' => '-2%', 'bottom' => '5%'],
|
||||
], null, '& img{display:block;width:100%;height:auto}'),
|
||||
], [
|
||||
'desktop' => ['position' => 'relative', 'minHeight' => '600px'],
|
||||
'tablet' => ['minHeight' => '520px'],
|
||||
'mobile' => ['minHeight' => '390px'],
|
||||
]),
|
||||
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '80px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '51 49'],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
'mobile' => ['gap' => '40px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '72px', 'bottom' => '84px'], 'overflow' => 'hidden'],
|
||||
'tablet' => ['padding' => ['top' => '56px', 'bottom' => '72px']],
|
||||
'mobile' => ['padding' => ['top' => '40px', 'bottom' => '64px']],
|
||||
], 'start'),
|
||||
|
||||
// 2 – Das Versprechen auf Waldgrün.
|
||||
$section('versprechen', [
|
||||
$row('versprechen-zeile', [
|
||||
$zeile('versprechen-intro', [
|
||||
$text('versprechen-nummer', '01', ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'color' => '@gold-hell', 'flexBasis' => '52px', 'flexShrink' => 0, 'margin' => ['top' => '4px', 'bottom' => '0px']], 'mobile' => ['flexBasis' => '38px']]),
|
||||
$column('versprechen-titel', [
|
||||
$text('versprechen-klein', 'Unser Versprechen', ['desktop' => [...$kleinStil, 'letterSpacing' => '0.18em', 'color' => '@klein-gruen', 'margin' => ['bottom' => '20px']]]),
|
||||
$heading('versprechen-h2', 'Mehr als<br>einkaufen.', 2, [
|
||||
'desktop' => ['fontSize' => '80px', 'fontWeight' => '400', 'lineHeight' => 1.04, 'letterSpacing' => '-0.035em', 'color' => '@papier', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '64px'],
|
||||
'mobile' => ['fontSize' => '48px'],
|
||||
]),
|
||||
]),
|
||||
], '0px', ['alignItems' => 'flex-start']),
|
||||
$column('versprechen-liste', [
|
||||
$versprechen('01', 'Frisch', 'Ausgewählte Produkte, die jeden Einkauf ein bisschen besser machen.'),
|
||||
$versprechen('02', 'Qualität', 'Sorgfältig ausgewählt, mit Vertrauen gekauft. Gute Produkte zu fairen Preisen.'),
|
||||
$versprechen('03', 'Vielfältig', 'Ein Sortiment für den Alltag, besondere Momente und jeden Geschmack.'),
|
||||
], ['desktop' => ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#cfb573a6']]),
|
||||
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '120px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'columnWidths' => '3 5'],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
'mobile' => ['gap' => '48px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@forst', 'color' => '@papier', 'padding' => ['top' => '140px', 'bottom' => '140px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'tablet' => ['padding' => ['top' => '100px', 'bottom' => '100px']],
|
||||
'mobile' => ['padding' => ['top' => '80px', 'bottom' => '80px']],
|
||||
], 'unser-versprechen'),
|
||||
|
||||
// 3 – Abschluss: Handschrift links, Gruß rechts, das große K als Wasserzeichen.
|
||||
$section('abschluss', [
|
||||
$row('abschluss-zeile', [
|
||||
$text('abschluss-spruch', 'Frische. Qualität.<br>Faire Preise.', [
|
||||
'desktop' => ['fontFamily' => 'heading', 'fontStyle' => 'italic', 'fontSize' => '84px', 'lineHeight' => 1.12, 'letterSpacing' => '-0.05em', 'color' => '@forst', 'margin' => ['bottom' => '0px']],
|
||||
'tablet' => ['fontSize' => '64px'],
|
||||
'mobile' => ['fontSize' => '48px'],
|
||||
]),
|
||||
$column('abschluss-gruss', [
|
||||
$text('abschluss-gruss-text', 'Wir bereiten gerade alles für Sie vor.', ['desktop' => ['fontSize' => '17px', 'color' => '@text', 'margin' => ['bottom' => '10px']]]),
|
||||
$text('abschluss-gruss-satz', 'Bis ganz bald im Kantarci Markt.', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '34px', 'lineHeight' => 1.25, 'color' => '@forst', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '26px']]),
|
||||
], ['desktop' => ['maxWidth' => '390px', 'alignSelf' => 'center']]),
|
||||
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||
'desktop' => ['gap' => '40px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'position' => 'relative', 'zIndex' => 2],
|
||||
'tablet' => ['gap' => '56px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@sand', 'position' => 'relative', 'overflow' => 'hidden', 'minHeight' => '450px', 'padding' => ['top' => '130px', 'bottom' => '130px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'tablet' => ['padding' => ['top' => '100px', 'bottom' => '110px']],
|
||||
'mobile' => ['padding' => ['top' => '80px', 'bottom' => '96px']],
|
||||
], 'bis-bald', "&::before{content:'K';position:absolute;right:3%;top:4%;font-family:Georgia,serif;font-size:min(30vw,480px);line-height:1;color:rgba(6,47,36,.045);pointer-events:none}\n"
|
||||
."&::after{content:'';position:absolute;right:7%;bottom:58px;width:180px;height:2px;background:#ad893b}"),
|
||||
],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seiten anlegen und veröffentlichen
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): string {
|
||||
$id = $pages->createPage($slug, null, 'page', $dokument);
|
||||
if ('' !== $seoTitel) {
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
|
||||
}
|
||||
if ('' !== $teilbild) {
|
||||
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
|
||||
}
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
// Der Text des Hero spielt beim Laden; die Fotos bleiben ohne Einblendung (LCP).
|
||||
$startseite = $einblenden($startseite, [
|
||||
'hero-label' => ['fade'],
|
||||
'hero-titel' => ['rise', 'short'],
|
||||
'hero-intro' => ['rise', 'medium'],
|
||||
'hero-termin' => ['rise', 'long'],
|
||||
'hero-adresse' => ['fade', 'long'],
|
||||
'versprechen-intro' => ['rise'],
|
||||
'versprechen-liste' => ['rise', 'short'],
|
||||
'abschluss-spruch' => ['rise'],
|
||||
'abschluss-gruss' => ['rise', 'short'],
|
||||
]);
|
||||
|
||||
$anlegen('home', $startseite, 'Kantarci Markt – Neueröffnung in Baden-Baden', $medien['markt']['id']);
|
||||
|
||||
/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Waldgrün, dann Absätze mit Zwischenüberschriften. */
|
||||
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array {
|
||||
$kopf = $section($praefix.'-kopf', [
|
||||
$kleinzeile($praefix.'-label', $label, '@gold-hell', '@gold-hell'),
|
||||
$heading($praefix.'-h1', $titel, 1, [
|
||||
'desktop' => ['fontSize' => '56px', 'fontWeight' => '400', 'lineHeight' => 1.1, 'letterSpacing' => '-0.03em', 'color' => '@papier', 'margin' => ['bottom' => '20px']],
|
||||
'mobile' => ['fontSize' => '40px'],
|
||||
]),
|
||||
$text($praefix.'-vorspann', $vorspann, [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text-gruen', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@forst', 'color' => '@papier', 'padding' => ['top' => '76px', 'bottom' => '68px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '44px']],
|
||||
]);
|
||||
|
||||
$punkte = [];
|
||||
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
|
||||
$punkte[] = $row($praefix.'-punkt-'.$i, [
|
||||
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
|
||||
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
|
||||
'desktop' => ['fontFamily' => 'body', 'fontSize' => '16px', 'fontWeight' => '700', 'lineHeight' => 1.5, 'letterSpacing' => '0', 'color' => '@forst', 'margin' => ['bottom' => '0px']],
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['flexBasis' => '260px', 'flexShrink' => 0],
|
||||
'tablet' => ['flexBasis' => '200px'],
|
||||
'mobile' => ['flexBasis' => 'auto'],
|
||||
]),
|
||||
$column($praefix.'-punkt-'.$i.'-text', [
|
||||
$text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss),
|
||||
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexBasis' => 'auto']]),
|
||||
], [], [
|
||||
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '44px', 'margin' => ['top' => '0px'],
|
||||
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e4dcc8', 'padding' => ['top' => '26px', 'bottom' => '26px']],
|
||||
'tablet' => ['gap' => '28px'],
|
||||
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '20px', 'bottom' => '20px']],
|
||||
]);
|
||||
}
|
||||
|
||||
$inhalt = $section($praefix, [
|
||||
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '920px']]),
|
||||
], ['desktop' => ['padding' => ['top' => '48px', 'bottom' => '90px']], 'mobile' => ['padding' => ['top' => '24px', 'bottom' => '56px']]]);
|
||||
|
||||
return [$kopf, $inhalt];
|
||||
};
|
||||
|
||||
$pruefen = static fn (string $was): string => '<strong>NOCH ZU PRÜFEN:</strong> '.$was;
|
||||
|
||||
$anlegen('impressum', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
|
||||
'description' => 'Anbieterangaben von Kantarci Markt, Baden-Baden.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
|
||||
['Anbieter', $name.'<br>'.$pruefen('Inhaber bzw. Firma mit Rechtsform (etwa „… GmbH“ oder „Inhaber …, e. K.“).').'<br>'.$strasse.' '.$pruefen('Hausnummer').'<br>'.$plz.' '.$ort],
|
||||
['Vertreten durch', $pruefen('Geschäftsführer bzw. Inhaber.')],
|
||||
['Registereintrag', $pruefen('Handelsregisternummer und Registergericht, falls eingetragen.')],
|
||||
['Kontakt', "E-Mail: <a href=\"mailto:$mail\">$mail</a><br>".$pruefen('Ob das Postfach existiert, und eine Telefonnummer als zweiten schnellen Kontaktweg ergänzen.')],
|
||||
['Umsatzsteuer', $pruefen('Umsatzsteuer-Identifikationsnummer nach § 27a UStG, falls vorhanden.')],
|
||||
['Verantwortlich für den Inhalt', $pruefen('Name der verantwortlichen Person, Anschrift wie oben.')],
|
||||
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
|
||||
['Haftung für Inhalte', 'Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Wir sind jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Bei Bekanntwerden von Rechtsverletzungen entfernen wir diese Inhalte umgehend.'],
|
||||
['Urheberrecht', 'Die von uns erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung.'],
|
||||
['Bildnachweis', 'Logo: '.$name.'. Die Fotos von Marktstand und Tomaten sind Beispielbilder. '.$pruefen('Quelle und Lizenz der beiden Fotos eintragen.')],
|
||||
]),
|
||||
],
|
||||
], 'Impressum | '.$name);
|
||||
|
||||
$anlegen('datenschutz', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
|
||||
'description' => 'Welche Daten beim Besuch der Website von Kantarci Markt verarbeitet werden.',
|
||||
'blocks' => [
|
||||
...$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website verarbeitet werden – und welche Rechte Sie haben.', [
|
||||
['1. Verantwortlicher', $name.', '.$strasse.', '.$plz.' '.$ort.". E-Mail <a href=\"mailto:$mail\">$mail</a>.<br><br>".$pruefen('Inhaber bzw. Firma wie im Impressum.')],
|
||||
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.<br><br>'.$pruefen('Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen und die Speicherdauer der Serverprotokolle eintragen.')],
|
||||
['3. Cookies und externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
|
||||
['4. Kontakt per E-Mail', 'Wenn Sie uns schreiben, verarbeiten wir Ihre Adresse und Ihre Nachricht, um Ihr Anliegen zu beantworten (Art. 6 Abs. 1 lit. b oder f DSGVO). Wir löschen die Nachricht, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'],
|
||||
['5. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen.'],
|
||||
['6. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
|
||||
['', "Fragen zum Datenschutz beantworten wir gern unter <a href=\"mailto:$mail\">$mail</a>."],
|
||||
]),
|
||||
],
|
||||
], 'Datenschutz | '.$name);
|
||||
|
||||
$fehlerId = $anlegen('404', [
|
||||
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
|
||||
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
|
||||
'blocks' => [
|
||||
$section('fehler', [
|
||||
$kleinzeile('fehler-label', 'Fehler 404', '@gold-text'),
|
||||
$heading('fehler-titel', 'Diese Seite<br><em>gibt es nicht.</em>', 1, [
|
||||
'desktop' => ['fontSize' => '64px', 'fontWeight' => '400', 'lineHeight' => 1.05, 'letterSpacing' => '-0.04em', 'color' => '@forst', 'margin' => ['bottom' => '22px']],
|
||||
'mobile' => ['fontSize' => '42px'],
|
||||
], '& em{font-style:italic;color:#8c6b27}'),
|
||||
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
|
||||
'desktop' => ['maxWidth' => '460px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '28px']],
|
||||
]),
|
||||
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '0px']]]),
|
||||
], [
|
||||
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '110px', 'bottom' => '130px']],
|
||||
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Einstellungen, Konto
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
$settings->assignNotFoundPage($fehlerId, '');
|
||||
$settings->save([
|
||||
'legalName' => $name,
|
||||
'description' => 'Kantarci Markt eröffnet bald in der Wilhelm-Drapp-Straße in Baden-Baden: frische Produkte, echte Vielfalt und faire Preise.',
|
||||
'email' => '',
|
||||
'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE',
|
||||
// Noch kein Geschäft mit Öffnungszeiten: keine LocalBusiness-Angaben, bis der Markt öffnet.
|
||||
'localBusiness' => false, 'llmsTxt' => true,
|
||||
'faviconMediaId' => $medien['symbol']['id'],
|
||||
'stickyHeader' => false, 'headerOverlay' => false,
|
||||
'motion' => 'always',
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
|
||||
'consentBanner' => false, 'scrollTop' => false, 'postMetaHeader' => false,
|
||||
]);
|
||||
echo "Einstellungen gespeichert\n";
|
||||
|
||||
$passwort = bin2hex(random_bytes(12));
|
||||
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
|
||||
|
||||
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
|
||||
@@ -239,6 +239,14 @@ final class CustomCode
|
||||
if (1 !== preg_match('/^-{0,2}[a-z][a-z0-9-]{0,40}$/D', $property)) {
|
||||
throw new InvalidDocument(sprintf('„%s“ ist kein CSS-Eigenschaftsname.', mb_substr($property, 0, 40)));
|
||||
}
|
||||
// `content` braucht einen Text in Anfuehrungszeichen, sonst gibt es kein ::before und
|
||||
// ::after fuer Striche, Verlaeufe oder ein Wasserzeichen. Erlaubt ist ein kurzer Text
|
||||
// aus Buchstaben, Ziffern und wenigen Satzzeichen: kein <, kein Backslash, kein
|
||||
// weiteres Anfuehrungszeichen, also kein Weg aus dem String oder dem <style>.
|
||||
if ('content' === $property && 1 === preg_match('/^(["\'])[\p{L}\p{N} .,:!?·•–-]{0,40}\1$/uD', $value)) {
|
||||
$out[] = $property.':'.$value;
|
||||
continue;
|
||||
}
|
||||
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value)) {
|
||||
throw new InvalidDocument(sprintf('Der Wert von „%s“ enthält Zeichen, die in eigenem CSS nicht vorgesehen sind.', $property));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -84,21 +84,41 @@ final class FontLibrary
|
||||
$bytes = 0;
|
||||
foreach ($wanted as $family => $weights) {
|
||||
$css = $this->stylesheet($family, $weights);
|
||||
// Eine variable Schrift (etwa DM Sans) liefert Google als eine Datei für alle
|
||||
// Schnitte, mit einer @font-face-Regel je Schnitt. Die Datei liegt trotzdem je Schnitt
|
||||
// im Ordner, damit missing() weiter nach Schnitten fragen kann, aber das Stylesheet
|
||||
// nennt sie nur einmal, mit einem Gewichtsbereich. Sonst lädt der Browser dieselben
|
||||
// Bytes je Schnitt einmal, und preload() holt sie dreimal vorab.
|
||||
$groups = [];
|
||||
foreach ($this->parse($css, $family) as $face) {
|
||||
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
|
||||
$path = $this->directory.'/'.$name;
|
||||
if (!is_file($path)) {
|
||||
file_put_contents($path, $this->download($face['url']));
|
||||
++$files;
|
||||
if (isset($groups[$face['url']])) {
|
||||
copy($this->directory.'/'.$groups[$face['url']]['name'], $path);
|
||||
} else {
|
||||
file_put_contents($path, $this->download($face['url']));
|
||||
++$files;
|
||||
}
|
||||
}
|
||||
if (isset($groups[$face['url']])) {
|
||||
$groups[$face['url']]['weights'][] = $face['weight'];
|
||||
continue;
|
||||
}
|
||||
$bytes += (int) filesize($path);
|
||||
$groups[$face['url']] = ['name' => $name, 'weights' => [$face['weight']], 'face' => $face];
|
||||
}
|
||||
foreach ($groups as $group) {
|
||||
$face = $group['face'];
|
||||
$low = min($group['weights']);
|
||||
$high = max($group['weights']);
|
||||
$rules[] = sprintf(
|
||||
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%d;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
|
||||
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%s;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
|
||||
$face['subset'],
|
||||
$family,
|
||||
$face['style'],
|
||||
$face['weight'],
|
||||
$name,
|
||||
$low === $high ? (string) $low : $low.' '.$high,
|
||||
$group['name'],
|
||||
$face['range'],
|
||||
);
|
||||
}
|
||||
@@ -106,11 +126,40 @@ final class FontLibrary
|
||||
if ([] === $rules) {
|
||||
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
|
||||
}
|
||||
file_put_contents($this->directory.'/fonts.css', implode("\n", $rules)."\n");
|
||||
$stylesheet = $this->directory.'/fonts.css';
|
||||
$existing = is_file($stylesheet) ? (string) file_get_contents($stylesheet) : '';
|
||||
file_put_contents($stylesheet, $this->combine($existing, $rules, array_keys($wanted)));
|
||||
|
||||
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt fonts.css aus den neuen Regeln und den Regeln anderer Familien zusammen.
|
||||
*
|
||||
* Bisher schrieb jeder Abruf die Datei neu und nur mit den Familien des aktuellen Designs.
|
||||
* Teilen sich mehrere Websites den Ordner (lokal alle Installationen) oder wechselt ein Design
|
||||
* die Schrift und wieder zurück, verschwanden damit Schriften, deren Dateien noch daliegen.
|
||||
* Regeln für Familien, die gerade neu geholt wurden, werden ersetzt; die übrigen bleiben.
|
||||
* Eine unbenutzte @font-face-Regel kostet nichts: der Browser lädt die Datei erst, wenn Text
|
||||
* in dieser Familie steht.
|
||||
*
|
||||
* @param list<string> $rules je Regel ein Kommentar mit dem Bereich und die @font-face
|
||||
* @param list<string> $families die Familien, zu denen $rules gehören
|
||||
*/
|
||||
public function combine(string $existing, array $rules, array $families): string
|
||||
{
|
||||
$kept = [];
|
||||
preg_match_all('~(?:/\*[^*]*\*/\s*)?@font-face\s*\{[^}]*\}~', $existing, $matches);
|
||||
foreach ($matches[0] as $rule) {
|
||||
if (1 !== preg_match("~font-family:\\s*'([^']+)'~", $rule, $family) || in_array($family[1], $families, true)) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = trim($rule);
|
||||
}
|
||||
|
||||
return implode("\n", [...$kept, ...$rules])."\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
|
||||
*
|
||||
@@ -124,11 +173,16 @@ final class FontLibrary
|
||||
*/
|
||||
public function preload(array $theme): array
|
||||
{
|
||||
// Nur Dateien, die das Stylesheet auch nennt: bei einer variablen Schrift steht dort eine
|
||||
// Datei für alle Schnitte, die Kopien je Schnitt holt der Browser nie.
|
||||
$stylesheet = is_file($this->directory.'/fonts.css') ? (string) file_get_contents($this->directory.'/fonts.css') : null;
|
||||
$preload = [];
|
||||
foreach ($this->wanted($theme) as $family => $weights) {
|
||||
foreach ($weights as $weight) {
|
||||
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
|
||||
if (is_file($this->directory.'/'.$name)) {
|
||||
$genannt = null === $stylesheet || !str_contains($stylesheet, '/assets/fonts/'.$this->slug($family).'-')
|
||||
|| str_contains($stylesheet, '/assets/fonts/'.$name.')');
|
||||
if (is_file($this->directory.'/'.$name) && $genannt) {
|
||||
$preload[] = '/assets/fonts/'.$name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ final class EditorController extends AbstractController
|
||||
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
|
||||
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
|
||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||
// Vorhandene Kategorien und Schlagwoerter als Vorschlag - sonst entstehen „Projekt“
|
||||
// und „Projekte“ nebeneinander.
|
||||
'termNames' => 'post' === $type ? $this->termNames() : ['category' => [], 'tag' => []],
|
||||
];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
@@ -118,6 +121,19 @@ final class EditorController extends AbstractController
|
||||
return $this->redirectToRoute('admin_editor', ['id' => $id]);
|
||||
}
|
||||
|
||||
/** @return array{category: list<string>, tag: list<string>} */
|
||||
private function termNames(): array
|
||||
{
|
||||
$names = ['category' => [], 'tag' => []];
|
||||
foreach ($this->posts->allTerms() as $term) {
|
||||
if (isset($names[$term['kind']])) {
|
||||
$names[$term['kind']][] = $term['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** The editor reads the style vocabulary and the palette from here, so both stay in one place. */
|
||||
#[Route('/api/design', name: 'admin_design', methods: ['GET'])]
|
||||
public function design(): JsonResponse
|
||||
@@ -139,9 +155,26 @@ final class EditorController extends AbstractController
|
||||
'buttonOptions' => \App\Content\ButtonDesign::OPTIONS,
|
||||
'buttonPresets' => \App\Content\ButtonDesign::PRESETS,
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
// Was ein Baustein per Adresse verknuepft, waehlt man aus einer Liste - niemand soll
|
||||
// die Adresse eines Formulars oder einer Kategorie auswendig wissen muessen.
|
||||
'choices' => $this->choices(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, string>> Adresse => Name, je Art */
|
||||
private function choices(): array
|
||||
{
|
||||
$choices = ['forms' => [], 'sections' => $this->partials->choices('section'), 'categories' => [], 'tags' => []];
|
||||
foreach ($this->forms->all() as $form) {
|
||||
$choices['forms'][$form['slug']] = $form['name'];
|
||||
}
|
||||
foreach ($this->posts->allTerms() as $term) {
|
||||
$choices['category' === $term['kind'] ? 'categories' : 'tags'][$term['slug']] = $term['name'];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
|
||||
/** Saves or removes a reusable style, so a look can be applied to other blocks and pages. */
|
||||
#[Route('/api/style-presets', name: 'admin_style_preset', methods: ['POST'])]
|
||||
public function stylePreset(Request $request): JsonResponse
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\DocumentLibrary;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -10,12 +11,13 @@ 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, private SiteSettings $siteSettings)
|
||||
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -27,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),
|
||||
@@ -53,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'])]
|
||||
@@ -91,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
|
||||
{
|
||||
@@ -112,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
|
||||
{
|
||||
@@ -240,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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Content\PageRepository;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -17,6 +18,47 @@ final class MenuController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Sprungziele einer Seite, fuer die Auswahl im Menue: jeder Baustein mit eigenem Anker,
|
||||
* beschriftet mit seiner ersten Ueberschrift. Wer ein Sprungziel setzt, soll es waehlen und
|
||||
* nicht wissen muessen, wie es im Editor hiess.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/api/sprungziele/{id}', name: 'admin_menu_anchors', requirements: ['id' => '[A-Za-z0-9_-]{1,64}'], methods: ['GET'])]
|
||||
public function anchors(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$page = $this->pages->findForEditor($id);
|
||||
if (null === $page) {
|
||||
return $this->json(['items' => []], 404);
|
||||
}
|
||||
$items = [];
|
||||
$heading = static function (array $block) use (&$heading): string {
|
||||
if ('heading' === ($block['type'] ?? null)) {
|
||||
return trim(html_entity_decode(strip_tags((string) ($block['props']['text'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
foreach ($block['children'] ?? [] as $child) {
|
||||
$text = $heading($child);
|
||||
if ('' !== $text) {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
$walk = static function (array $blocks) use (&$walk, &$items, $heading): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (is_string($block['anchor'] ?? null) && '' !== $block['anchor']) {
|
||||
$title = $heading($block);
|
||||
$items[] = ['anchor' => $block['anchor'], 'label' => '' === $title ? '#'.$block['anchor'] : mb_strimwidth($title, 0, 70, '…').' (#'.$block['anchor'].')'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($page['document']['blocks'] ?? []);
|
||||
|
||||
return $this->json(['items' => $items]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,19 @@ final class PluginExtension extends AbstractExtension
|
||||
new TwigFunction('kant_plugin_props', $this->props(...)),
|
||||
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
|
||||
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
|
||||
new TwigFunction('kant_media', $this->adminMedia(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bild fuer ein Bildfeld der Verwaltung - auch ein archiviertes: es ist noch gewaehlt und
|
||||
* wird noch ausgeliefert, also soll es auch zu sehen sein.
|
||||
*/
|
||||
public function adminMedia(string $id): ?array
|
||||
{
|
||||
return 1 === preg_match('/^[a-f0-9]{32}$/D', $id) ? $this->media->find($id) : null;
|
||||
}
|
||||
|
||||
/** Die geprüften Eigenschaften eines Bausteins - oder null, wenn sie nicht ins Schema passen. */
|
||||
public function props(array $block): ?array
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% set selected = selected|default(null) %}
|
||||
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}>
|
||||
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}{% if free|default(false) %} data-free="1"{% endif %}>
|
||||
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
|
||||
<div class="picker-input">
|
||||
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
|
||||
|
||||
@@ -53,8 +53,7 @@
|
||||
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
||||
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
||||
{% include 'admin/media-field.html.twig' with {id: 'og-' ~ page.id, name: 'ogMediaId', label: 'Vorschaubild beim Teilen', value: page.og_media_id|default(''), help: 'Erscheint, wenn jemand die Seite in sozialen Netzen oder Messengern teilt.'} only %}
|
||||
</div>
|
||||
{% if page.type == 'post' %}
|
||||
<div>
|
||||
@@ -67,16 +66,15 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="categories-{{ page.id }}">Kategorien</label>
|
||||
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
||||
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen" data-term-input="{{ termNames.category|default([])|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tags-{{ page.id }}">Schlagwörter</label>
|
||||
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
|
||||
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration" data-term-input="{{ termNames.tag|default([])|json_encode|e('html_attr') }}">
|
||||
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="media-{{ page.id }}">Beitragsbild</label>
|
||||
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
||||
{% include 'admin/media-field.html.twig' with {id: 'media-' ~ page.id, name: 'featuredMediaId', label: 'Beitragsbild', value: page.featured_media_id|default(''), help: 'Erscheint in Beitragslisten und im Feed.'} only %}
|
||||
</div>
|
||||
<div>
|
||||
<label for="excerpt-{{ page.id }}">Auszug</label>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% set nav = 'forms' %}
|
||||
{# Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website - dafuer braucht die
|
||||
Seite dessen Stylesheet, genau wie die Designseite. #}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Formulare{% endblock %}
|
||||
{% block heading %}Formulare{% endblock %}
|
||||
@@ -105,7 +105,7 @@
|
||||
{% else %}
|
||||
<div class="card-head">
|
||||
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
|
||||
<span class="muted">Im Editor als Formularblock mit der Adresse <code>{{ form.slug }}</code></span>
|
||||
<span class="muted">Im Editor über den Formularblock wählbar · Adresse <code>{{ form.slug }}</code></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body form-editor">
|
||||
@@ -120,7 +120,7 @@
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen. Kleinbuchstaben, Ziffern, Bindestrich.</p>
|
||||
<p class="field-help">Kurzname des Formulars, etwa kontakt. Kleinbuchstaben, Ziffern, Bindestrich. Im Editor wird das Formular dann aus einer Liste gewählt.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{# Ein Bild aus der Mediathek als Formularfeld: Vorschau, Wählen oder Hochladen im Dialog,
|
||||
Entfernen. Die Kennung steht nur im versteckten Feld - niemand soll sie abtippen müssen.
|
||||
Erwartet: id, name, label, value, help (optional). #}
|
||||
{% set item = value ? kant_media(value) : null %}
|
||||
<div class="media-field" data-media-field role="group" aria-labelledby="{{ id }}-label">
|
||||
<span class="media-field-label" id="{{ id }}-label">{{ label }}</span>
|
||||
<input type="hidden" id="{{ id }}" name="{{ name }}" value="{{ value }}">
|
||||
<div class="media-field-current">
|
||||
<img class="media-field-thumb" alt="" width="96" height="64"{% if item %} src="{{ (item.variants|first).src }}"{% else %} hidden{% endif %}>
|
||||
<span class="media-field-name" aria-live="polite">{{ item ? item.title : (value ? 'Bild nicht gefunden' : 'Kein Bild gewählt') }}</span>
|
||||
</div>
|
||||
<div class="media-field-actions">
|
||||
<button type="button" class="secondary" data-media-choose>{{ value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
|
||||
<button type="button" class="text-button danger-text" data-media-remove{{ value ? '' : ' hidden' }}>Entfernen</button>
|
||||
</div>
|
||||
{% if help|default('') %}<p class="field-help">{{ help }}</p>{% endif %}
|
||||
</div>
|
||||
@@ -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 %}
|
||||
@@ -2,15 +2,18 @@
|
||||
<input type="hidden" data-key="position" name="items[{{ n }}][position]" value="{{ n }}">
|
||||
<input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}">
|
||||
<input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}">
|
||||
{# Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr: ein Knopf in einem Knopf ist
|
||||
für Screenreader und Tastatur nicht bedienbar (axe: nested-interactive). #}
|
||||
<button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button>
|
||||
<details>
|
||||
<summary><button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||
<div class="menu-entry-fields">
|
||||
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
|
||||
{% if row.type == 'page' %}
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'menu-target-' ~ n, name: 'items[' ~ n ~ '][pageId]', label: 'Verknüpfter Inhalt', selected: row.page} %}
|
||||
<label for="menu-anchor-{{ n }}">Sprungziel (optional)</label><input id="menu-anchor-{{ n }}" data-key="anchor" name="items[{{ n }}][anchor]" maxlength="65" value="{{ row.anchor }}" placeholder="kontakt">
|
||||
{% else %}
|
||||
<label for="menu-url-{{ n }}">Adresse</label><input id="menu-url-{{ n }}" data-key="url" name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt">
|
||||
<label for="menu-url-{{ n }}">Adresse</label><div class="link-field" data-link-field><input id="menu-url-{{ n }}" data-key="url" data-link-input name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt"></div>
|
||||
{% endif %}
|
||||
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
|
||||
<div class="menu-icon-fields"><div><label for="menu-icon-{{ n }}">Icon</label><select id="menu-icon-{{ n }}" data-key="icon" name="items[{{ n }}][icon]"><option value="">Kein Icon</option>{% for icon in menuIcons %}<option value="{{ icon }}"{{ row.icon|default('') == icon ? ' selected' : '' }}>{{ icon }}</option>{% endfor %}</select></div><div><label for="menu-iconPosition-{{ n }}">Icon-Position</label><select id="menu-iconPosition-{{ n }}" data-key="iconPosition" name="items[{{ n }}][iconPosition]"><option value="before"{{ row.iconPosition|default('before') == 'before' ? ' selected' : '' }}>Vor dem Text</option><option value="after"{{ row.iconPosition|default('before') == 'after' ? ' selected' : '' }}>Nach dem Text</option></select></div></div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Menüs{% endblock %}
|
||||
{% block heading %}Menüs{% endblock %}
|
||||
{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'admin/menu-icons.html.twig' %}
|
||||
@@ -16,7 +17,7 @@
|
||||
</section>
|
||||
<details class="card"><summary>Eigener Link</summary><div class="card-body">
|
||||
<label for="link-label">Beschriftung</label><input id="link-label" maxlength="80" placeholder="Kontakt">
|
||||
<label for="link-url">Adresse</label><input id="link-url" maxlength="2048" placeholder="https://… oder /kontakt">
|
||||
<label for="link-url">Adresse</label><div class="link-field" data-link-field><input id="link-url" maxlength="2048" placeholder="https://… oder /kontakt" data-link-input></div>
|
||||
<p class="field-help">Auch mailto: und tel: sind möglich.</p>
|
||||
<label class="check-label" for="link-new-tab"><input id="link-new-tab" type="checkbox"> In neuem Tab öffnen</label>
|
||||
<button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
{% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %}
|
||||
{% block title %}{{ plural }}{% endblock %}
|
||||
{% block heading %}{{ plural }}{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script><script type="module" src="/assets/media-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/term-chips.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
{% set nav = 'plugins' %}
|
||||
{% block title %}Plugins{% endblock %}
|
||||
{% block heading %}Plugins{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/link-field.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block lead %}Erweiterungen bringen eigene Bausteine, Vorlagen und Adressen mit. Sie liegen als Ordner unter <code>plugins/</code>; hier schaltest du sie ein und aus und stellst sie ein. Ein ausgeschaltetes Plugin lässt seine Bausteine in den Seiten stehen, zeigt sie aber nicht mehr.{% endblock %}
|
||||
|
||||
{# Ein Feld aus dem Schema eines Plugins - dieselben sieben Arten wie im Editor. #}
|
||||
{% macro feld(plugin, key, schema, value) %}
|
||||
{% set id = 'plugin-' ~ plugin ~ '-' ~ key %}
|
||||
{% set name = 'settings[' ~ key ~ ']' %}
|
||||
{% if schema.kind == 'boolean' %}
|
||||
{% if schema.kind == 'media' %}
|
||||
<div>{% include 'admin/media-field.html.twig' with {id: id, name: name, label: schema.label, value: value|default(''), help: schema.help|default('')} only %}</div>
|
||||
{% elseif schema.kind == 'boolean' %}
|
||||
<div><label class="check-label" for="{{ id }}"><input id="{{ id }}" type="checkbox" name="{{ name }}" value="1"{{ value ? ' checked' : '' }}> {{ schema.label }}</label>{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<label for="{{ id }}">{{ schema.label }}</label>
|
||||
{% if schema.kind == 'textarea' %}<textarea id="{{ id }}" name="{{ name }}" rows="3" maxlength="{{ schema.maxLength }}">{{ value }}</textarea>
|
||||
{% elseif schema.kind == 'select' %}<select id="{{ id }}" name="{{ name }}">{% for wert, text in schema.choices %}<option value="{{ wert }}"{{ value == wert ? ' selected' : '' }}>{{ text }}</option>{% endfor %}</select>
|
||||
{% elseif schema.kind == 'url' %}<div class="link-field" data-link-field><input id="{{ id }}" name="{{ name }}" value="{{ value }}" maxlength="{{ schema.maxLength }}" placeholder="/kontakt oder https://…" data-link-input></div>
|
||||
{% elseif schema.kind == 'number' %}<input id="{{ id }}" name="{{ name }}" type="number" step="any" min="{{ schema.min }}" max="{{ schema.max }}" value="{{ value }}">
|
||||
{% elseif schema.kind == 'media' %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" placeholder="Kennung aus der Mediathek" pattern="[a-f0-9]{32}">
|
||||
{% else %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" maxlength="{{ schema.maxLength }}">{% endif %}
|
||||
{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -292,9 +292,8 @@
|
||||
<input id="from" name="from" maxlength="400" required placeholder="alte-seite">
|
||||
</div>
|
||||
<div>
|
||||
<label for="to">Neue Adresse</label>
|
||||
<input id="to" name="to" maxlength="400" placeholder="leistungen/webdesign">
|
||||
<p class="field-help">Bei 410 leer lassen.</p>
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'to', name: 'to', label: 'Neue Adresse', valueKey: 'path', free: true} only %}
|
||||
<p class="field-help">Seite suchen und wählen – oder einen anderen Pfad eintippen, etwa kategorie/projekte. Bei 410 leer lassen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status">Art</label>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -1430,6 +1516,16 @@ final class CmsTest extends TestCase
|
||||
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
|
||||
self::assertStringContainsString('U+0131', $faces[0]['range']);
|
||||
|
||||
// Ein neuer Abruf ersetzt nur die Regeln der geholten Familien; andere Familien, deren
|
||||
// Dateien noch im Ordner liegen, bleiben im Stylesheet.
|
||||
$vorher = "/* latin */\n@font-face{font-family:'Lato';font-weight:400;src:url(/assets/fonts/lato-400-latin.woff2)}\n"
|
||||
."/* latin */\n@font-face{font-family:'DM Sans';font-weight:400;src:url(/assets/fonts/alt.woff2)}\n";
|
||||
$neu = $fonts->combine($vorher, ["/* latin */\n@font-face{font-family:'DM Sans';font-weight:400;src:url(/assets/fonts/dm-sans-400-latin.woff2)}"], ['DM Sans']);
|
||||
self::assertStringContainsString('lato-400-latin.woff2', $neu);
|
||||
self::assertStringContainsString('dm-sans-400-latin.woff2', $neu);
|
||||
self::assertStringNotContainsString('alt.woff2', $neu);
|
||||
self::assertSame(2, substr_count($neu, '@font-face'));
|
||||
|
||||
// Ohne heruntergeladene Dateien bleibt es bei der Ersatzschrift - verlinkt wird Google
|
||||
// nie, denn das gäbe die IP jedes Besuchers an einen Dritten weiter.
|
||||
$theme = new Theme($this->db);
|
||||
@@ -1461,6 +1557,11 @@ final class CmsTest extends TestCase
|
||||
'/assets/fonts/lato-700-latin.woff2',
|
||||
'/assets/fonts/lato-900-latin.woff2',
|
||||
], $fonts->preload($theme->load()));
|
||||
|
||||
// Eine variable Schrift ist eine Datei für alle Schnitte: das Stylesheet nennt sie einmal
|
||||
// mit Gewichtsbereich, und vorgeladen wird nur diese eine Datei statt dreimal dieselbe.
|
||||
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato';font-weight:400 900;src:url(/assets/fonts/lato-400-latin.woff2) format('woff2')}");
|
||||
self::assertSame(['/assets/fonts/lato-400-latin.woff2'], $fonts->preload($theme->load()));
|
||||
}
|
||||
|
||||
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
|
||||
@@ -3052,6 +3153,9 @@ final class CmsTest extends TestCase
|
||||
& .card { transform: none }', '#b1 .card{border:0;transform:none}'];
|
||||
yield 'element rule after a nested one' => ['& { color: red }
|
||||
display: flex', '#b1{display:flex;color:red}'];
|
||||
// Ein Pseudo-Element braucht content: leer fuer Striche und Verlaeufe, ein Zeichen fuer ein Wasserzeichen.
|
||||
yield 'empty content' => ["&::after { content: ''; height: 2px }", "#b1::after{content:'';height:2px}"];
|
||||
yield 'short content' => ['&::before { content: "K" }', '#b1::before{content:"K"}'];
|
||||
}
|
||||
|
||||
#[DataProvider('ownCssCases')]
|
||||
@@ -3075,6 +3179,9 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
yield 'old expression syntax' => ['color: expression(alert(1))'];
|
||||
yield 'no value' => ['color'];
|
||||
yield 'nonsense property' => ['co lor: red'];
|
||||
yield 'content leaving the style tag' => ['content: "</style><script>"'];
|
||||
yield 'content with a second quote' => ["content: 'a' 'b'"];
|
||||
yield 'quotes outside content' => ["font-family: 'Evil'"];
|
||||
}
|
||||
|
||||
#[DataProvider('refusedOwnCss')]
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
@@ -268,6 +269,35 @@ final class EditorSecurityTest extends WebTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId(): void
|
||||
{
|
||||
// Umut: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild"
|
||||
$client = $this->login('editor');
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$id = $pages->createPage('mit-bild', null, 'post', DemoContent::pages()['home']);
|
||||
$media = str_repeat('c', 32);
|
||||
$this->db->insert('media', ['id' => $media, 'title' => 'Klassenzimmer', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$media.'/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$this->db->executeStatement('UPDATE page SET featured_media_id = ? WHERE id = ?', [$media, $id]);
|
||||
|
||||
$crawler = $client->request('GET', '/admin/posts');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringNotContainsString('ID aus der Mediathek', $client->getResponse()->getContent());
|
||||
self::assertCount(1, $crawler->filter('script[src^="/assets/media-field.js"]'));
|
||||
// Das gewählte Beitragsbild: Kennung versteckt, Vorschau und Titel sichtbar.
|
||||
$featured = $crawler->filter('input[name="featuredMediaId"]')->closest('[data-media-field]');
|
||||
self::assertCount(1, $featured);
|
||||
self::assertSame('hidden', $featured->filter('input[name="featuredMediaId"]')->attr('type'));
|
||||
self::assertSame($media, $featured->filter('input[name="featuredMediaId"]')->attr('value'));
|
||||
self::assertSame('/media/'.$media.'/480', $featured->filter('img')->attr('src'));
|
||||
self::assertStringContainsString('Klassenzimmer', $featured->text());
|
||||
self::assertStringContainsString('Anderes Bild wählen', $featured->text());
|
||||
// Ohne Vorschaubild: Wählen statt Kennung eintippen.
|
||||
$og = $crawler->filter('input[name="ogMediaId"]')->closest('[data-media-field]');
|
||||
self::assertCount(1, $og);
|
||||
self::assertStringContainsString('Bild wählen oder hochladen', $og->text());
|
||||
self::assertNull($og->filter('img')->attr('src'));
|
||||
}
|
||||
|
||||
public function testLiveSearchReturnsFilteredEscapedResultsWithTheSamePermissionsAsTheFullScreen(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
@@ -653,6 +683,81 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form'));
|
||||
}
|
||||
|
||||
public function testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses(): void
|
||||
{
|
||||
// Was ein Baustein nur verknüpft, kommt als Liste Adresse => Name - der Editor macht
|
||||
// daraus Auswahlfelder statt Textfelder für Adressen.
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media));
|
||||
(new FormRepository($this->db))->save('anfrage', 'Anfrage', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true, 'help' => '', 'options' => []]], ['recipient' => 'info@example.com'], 0);
|
||||
$band = DemoContent::starter('Werbeband');
|
||||
$band['blocks'] = [['id' => 'band-text', 'type' => 'text', 'props' => ['text' => 'Aktion']]];
|
||||
(new PartialRepository($this->db, new DocumentValidator()))->create('section', 'band', 'Werbeband', $band);
|
||||
$post = $pages->createPage('ein-beitrag', null, 'post', DemoContent::pages()['home']);
|
||||
(new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']);
|
||||
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/api/design');
|
||||
self::assertResponseIsSuccessful();
|
||||
$choices = json_decode($client->getResponse()->getContent(), true)['choices'];
|
||||
self::assertSame(['anfrage' => 'Anfrage'], $choices['forms']);
|
||||
self::assertSame(['band' => 'Werbeband'], $choices['sections']);
|
||||
self::assertSame(['projekte' => 'Projekte'], $choices['categories']);
|
||||
self::assertSame(['umbau' => 'Umbau'], $choices['tags']);
|
||||
}
|
||||
|
||||
public function testMenuAnchorsAndPostTermsAreOfferedForChoosing(): void
|
||||
{
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media));
|
||||
$document = DemoContent::starter('Leistungen');
|
||||
$document['schemaVersion'] = 21;
|
||||
$document['blocks'][] = ['id' => 'kontakt-sektion', 'type' => 'section', 'anchor' => 'kontakt', 'children' => [
|
||||
['id' => 'kontakt-titel', 'type' => 'heading', 'props' => ['text' => 'So erreichen Sie uns', 'level' => 2]],
|
||||
]];
|
||||
$id = $pages->createPage('leistungen', null, 'page', $document);
|
||||
|
||||
// Die Sprungziele einer Seite, beschriftet mit ihrer Überschrift - nur für Rollen, die
|
||||
// Menüs bearbeiten.
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/api/sprungziele/'.$id);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
$client = $this->login('admin', $client);
|
||||
$client->request('GET', '/admin/api/sprungziele/'.$id);
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame([['anchor' => 'kontakt', 'label' => 'So erreichen Sie uns (#kontakt)']], json_decode($client->getResponse()->getContent(), true)['items']);
|
||||
$client->request('GET', '/admin/api/sprungziele/gibt-es-nicht');
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
|
||||
// Vorhandene Kategorien und Schlagwörter stehen am Feld, damit man sie wählt statt neu tippt.
|
||||
$post = $pages->createPage('beitrag', null, 'post', DemoContent::pages()['home']);
|
||||
(new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']);
|
||||
$crawler = $client->request('GET', '/admin/posts');
|
||||
self::assertSame(['Projekte'], json_decode($crawler->filter('input[name="categories"]')->attr('data-term-input'), true));
|
||||
self::assertSame(['Umbau'], json_decode($crawler->filter('input[name="tags"]')->attr('data-term-input'), true));
|
||||
self::assertCount(1, $crawler->filter('script[src^="/assets/term-chips.js"]'));
|
||||
}
|
||||
|
||||
public function testPluginSettingsForLinksAndImagesAreChosenNotTyped(): void
|
||||
{
|
||||
// Das Feld einer Plugin-Einstellung, direkt aus dem Makro der Plugin-Seite: ein Link
|
||||
// bleibt ein Textfeld mit Knöpfen daneben, ein Bild wird das Bildfeld der Mediathek.
|
||||
self::bootKernel();
|
||||
$twig = static::getContainer()->get('twig');
|
||||
$render = static fn (array $schema, string $value): string => $twig->createTemplate(
|
||||
"{% import 'admin/plugins.html.twig' as f %}{{ f.feld('probe', 'ziel', schema, value) }}",
|
||||
)->render(['schema' => $schema, 'value' => $value]);
|
||||
|
||||
$link = $render(['kind' => 'url', 'label' => 'Ziel', 'maxLength' => 200, 'help' => ''], '/kontakt');
|
||||
self::assertStringContainsString('data-link-field', $link);
|
||||
self::assertMatchesRegularExpression('#<input id="plugin-probe-ziel" name="settings\[ziel\]" value="/kontakt"[^>]*data-link-input#', $link);
|
||||
|
||||
$bild = $render(['kind' => 'media', 'label' => 'Bild', 'help' => ''], '');
|
||||
self::assertStringContainsString('data-media-field', $bild);
|
||||
self::assertStringContainsString('<input type="hidden" id="plugin-probe-ziel" name="settings[ziel]" value="">', $bild);
|
||||
self::assertStringNotContainsString('Kennung aus der Mediathek', $bild);
|
||||
}
|
||||
|
||||
public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
@@ -738,6 +843,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 +931,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);
|
||||
}
|
||||
|
||||
@@ -642,3 +642,18 @@ test('registered plugin blocks get labels, defaults, container rules and presets
|
||||
assert.equal(pluginDefinition(bild), null);
|
||||
assert.equal(isContainer(galerie), false);
|
||||
});
|
||||
|
||||
test('a loaded document is raised to the schema its blocks and properties need', async () => {
|
||||
const { repairSchema } = await import('../public/assets/editor-model.js');
|
||||
// So stand der Kopfbereich von kant-it.de da: Hamburger-Umschaltung bei Fassung 19.
|
||||
const header = { schemaVersion: 19, title: 'Kopf', description: '', blocks: [
|
||||
{ id: 's', type: 'section', anchor: 'oben', children: [{ id: 'm', type: 'menu', props: { slot: 'header', collapse: 'tablet' } }] },
|
||||
] };
|
||||
assert.equal(repairSchema(header).schemaVersion, 22);
|
||||
// Nichts Neues darin: die Fassung bleibt - auch die Umstellung auf formatierten Text (12)
|
||||
// kommt nie von allein.
|
||||
const plain = { schemaVersion: 5, title: 'Alt', description: '', blocks: [{ id: 't', type: 'text', props: { text: 'a < b' } }] };
|
||||
assert.equal(repairSchema(plain).schemaVersion, 5);
|
||||
const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] };
|
||||
assert.equal(repairSchema(faq).schemaVersion, 23);
|
||||
});
|
||||
Reference in new issue
Block a user