Compare commits

...
8 Commits
Author SHA1 Message Date
kantarci 0920e3cb83 Ergänzt Prozentregler für die responsive Bildbreite
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:20:18 +02:00
kantarci d78a58f714 Dokumentiert das geprüfte Release 1.7.1
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:11:16 +02:00
kantarci 41d87f62a4 Ergänzt AJAX-Livesuche für Seiten, Beiträge und Mediathek
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:09:24 +02:00
kantarci 11b60fc5d8 Dokumentiert die geprüfte Veröffentlichung von 1.7.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 10:52:28 +02:00
kantarci 495b26896e Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 10:49:54 +02:00
kantarci b164c4edfe Dokumentiert die gepruefte Veroeffentlichung von 1.6.2
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 09:24:04 +02:00
kantarci 6a044e59e9 Fassung 1.6.2: sichtbare leere Layouts und direktes Befuellen im Editor
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 09:22:23 +02:00
kantarci 149e1fe520 Dokumentiert die gepruefte Veroeffentlichung von 1.6.1
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 09:00:06 +02:00
71 changed files with 2813 additions and 722 deletions

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
APP_ENV=dev
APP_DEBUG=1
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
SITE_ORIGIN=http://127.0.0.1:8080
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
MAILER_DSN=sendmail://default
# Die Adresse der Verwaltung. Aenderbar mit `php bin/console cms:admin:path <neu>`;
# der Befehl schreibt .env.local und prueft, dass die Adresse frei ist.
ADMIN_PATH=admin
+5 -4
View File
@@ -1,6 +1,7 @@
# Vorlage für eine Installation. Der Installer schreibt daraus die echte .env und erzeugt das
# Geheimnis selbst; von Hand: diese Datei nach .env kopieren, Werte eintragen und
# `php bin/setup.php` ausführen, das APP_SECRET nach .env.local schreibt.
# Vorlage: Der Installer erstellt daraus die Konfiguration .env.
# .env gehört der Installation und wird bei Updates erhalten, nicht in Git gespeichert.
# Installer, Setup und Admin-Pfad-Befehl schreiben ausschließlich in .env.
# Ältere .env.local-Einstellungen werden einmalig mit Sicherung zusammengeführt.
APP_ENV=prod
APP_DEBUG=0
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
@@ -10,5 +11,5 @@ SITE_ORIGIN=https://example.de
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
MAILER_DSN=sendmail://default
# Die Adresse der Verwaltung. Ohne Eintrag: /admin. Aenderbar mit
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.local.
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.
#ADMIN_PATH=admin
+3
View File
@@ -3,6 +3,9 @@
/var/
/node_modules/
/.env.local
/.env
/.env.lock
/.env.*.tmp
/.env.*.local
/phpunit.cache/
/reports/
+29 -5
View File
@@ -18,7 +18,7 @@ php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0
Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage.
`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
`bin/setup.php` erzeugt ein zufälliges Secret und `var/`. Die Konfiguration steht ausschließlich in der nicht versionierten `.env`; vorhandene Einstellungen und Secrets bleiben erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
@@ -34,6 +34,8 @@ Das Passwort wird verdeckt zweimal abgefragt (14–72 Bytes). Bestehende Benutze
Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzufügen/auswählen, in der Mitte die Vorschau, rechts Eigenschaften. Die Auswahl ist zwischen Strukturbaum und Vorschau synchron. Sektion, Zeile, Spalte, Überschrift, Text, Bild, Button und Galerie sind vorhanden. Bilder können aus der Mediathek oder über vorhandene lokale/HTTPS-Adressen eingebunden werden.
**Bild prozentual verkleinern (ab 1.7.2):** Bild auswählen → Inhalt → **Bildbreite (%)**. Regler oder Zahlenfeld setzen 1–100 % der verfügbaren Breite in Sektion/Spalte. Desktop, Tablet und Mobil können eigene Werte erhalten; kleinere Geräte übernehmen zunächst den Wert vom größeren Gerät. „Breite zurücksetzen“ entfernt den eigenen Wert und stellt die Vererbung beziehungsweise automatische Breite wieder her. Originalbreite/-höhe beschreiben weiterhin die Datei. Der Regler bearbeitet denselben Wert wie Design → Größe → Breite; eine dort gesetzte maximale Breite bleibt wirksam. Prüfung mit kopierten Kant-IT-Daten: `npm run test:image-size`, Bericht und Screenshots unter `reports/image-size/`.
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
@@ -46,6 +48,8 @@ Die Vorschau rendert auf Desktop mit mindestens 1240 px Layoutbreite und wächst
### Platz und Blockgestaltung im Editor (1.6.1)
**Leere Layouts ab 1.6.2:** Sektionen und Spalten zeigen eine umrahmte Einfügefläche. In einer leeren Sektion direkt **1–4 Spalten** wählen; in einer Spalte **Inhalt hinzufügen** drücken und links das gewünschte Element auswählen. Alternativ ein Element auf die Fläche ziehen: die Markierung nennt das genaue Ziel, am Vorschaurand wird mitgescrollt. Neue Container werden in den sichtbaren Ausschnitt gebracht. Diese Hilfen erscheinen nur im Editor, verschwinden nach dem Befüllen und kehren bei Undo zurück.
**Neue Sektion auf Seitenebene** steht dauerhaft oben links und fügt nach dem aktuellen Seitenabschnitt ein, unabhängig von der ausgewählten Verschachtelung oder einem gesetzten Plus-Einfügepunkt. Auch „Sektion“ im Werkzeugkasten verwendet ohne expliziten Einfügepunkt die Seitenebene. Verschachtelte Sektionen sind weiterhin über die Plus-Einfügepunkte oder Drag-and-drop möglich.
Die Trennlinien links und rechts der Vorschau lassen sich ziehen. Mit Tab fokussieren und mit den Pfeiltasten ändern; Umschalt vergrößert den Schritt, Home/End wählen die Grenzen, Doppelklick setzt die jeweilige Breite zurück. Die Breiten werden im Browser behalten und bei schmalen Fenstern an den verfügbaren Platz angepasst. Auf dem Telefon stehen die Panels unter der Vorschau.
@@ -69,7 +73,7 @@ Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersic
Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen.
Unter **Design → Effekte** Schatten, Hover-Anheben/Bildzoom und Einblendung beim Laden wählen. Effekte gelten für alle Geräte; bei aktivierter Bewegungsreduktion werden Animationen abgeschaltet. Die öffentliche Seite benötigt dafür kein JavaScript. Lightbox, Slideshow, Video/PDF-Upload und Bildzuschnitt sind noch nicht enthalten.
Unter **Design → Bewegung und Effekte** Schatten, Hover-Effekte und **Einblendung beim Erscheinen** wählen. Die gewählte Richtung, Zoom, Unschärfe oder Überblendung startet beim Eintreten in den Bildschirm. Das kleine Bewegungsmodul lädt nur auf Seiten mit Einblendungen; ohne JavaScript bleiben die Inhalte sichtbar. Slider haben Pfeile, optionale Punkte sowie bei aktivierter Automatik Pause/Wiedergabe. Manuelle Bedienung pausiert die Automatik, Wiedergabe setzt sie fort. Bewegungsreduktion schaltet Einblendungen und Automatik aus; manuelles Blättern bleibt möglich. Im Editor startet **Bewegung testen** diese Vorschau, **Bewegung stoppen** beendet sie. Die Vorschau-Sandbox bleibt ohne Skriptfreigabe.
Medien liegen standardmäßig in `var/media`; mit `MEDIA_DIRECTORY` lässt sich ein absoluter Ordner außerhalb von `public` konfigurieren. Er muss für PHP beschreibbar sein. Auf dem Hosting `upload_max_filesize=8M`, `post_max_size=10M` und ausreichend Arbeitsspeicher (lokal 256M) einstellen, soweit der Tarif dies erlaubt. Datenbank und Medienordner gemeinsam sichern. Archivieren erhält Bild-URLs; es ist keine Dateilöschung.
@@ -98,7 +102,7 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
**Datenbank:** SQLite ab Fassung 3.16 (Doctrine liest das Schema mit Tabellenfunktionen, die es erst seit dieser Fassung gibt) oder MySQL/MariaDB. Auf älteren Systemen – manche Shared Hoster liefern noch SQLite 3.7 – bleibt MySQL; die Systemprüfung des Installers nennt die gefundene Fassung und bietet SQLite gar nicht erst an, wenn sie zu alt ist.
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, 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 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.
`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.
@@ -141,8 +145,28 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
- Apache mit Rewrite-Unterstützung für `public/.htaccess` oder entsprechende FastCGI/Nginx-Konfiguration.
- Schreibzugriff für PHP auf `var/`, HTTPS und eine Möglichkeit, Migrationen/Cache-Kommandos kontrolliert auszuführen.
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsvariablen dürfen sie gezielt überschreiben: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
## Verwaltung ab 1.7.0
**Seiten** und **Beiträge** sind getrennte Menüpunkte. Suche, Status einschließlich Papierkorb und 20/50 Einträge begrenzen jede Ergebnisliste bereits in der Datenbank. Vor/Zurück erhält die Filter; übergeordnete Seiten lassen sich unabhängig von der aktuellen Ergebnisseite suchen.
Ab **1.7.1** aktualisieren sich Seiten, Beiträge und Mediathek beim Tippen per AJAX, ohne Enter oder Neuladen. Status, Anzahl und Blättern reagieren ebenfalls direkt. Der Cursor bleibt im Suchfeld; Zurück/Vor im Browser stellt die Filter wieder her. Die Mediathek sucht in Titel und Alternativtext über alle Ergebnisseiten. Menüs, Seitenauswahl und Editor-Suchfelder reagieren ebenfalls beim Tippen. Bei Ladefehlern bleiben die bisherigen Treffer sichtbar und „Erneut suchen“ wiederholt die Anfrage. Ohne JavaScript bleibt das Suchformular mit „Anwenden“ benutzbar.
Unter **Menüs** zuerst den Menüplatz wählen, links Inhalte suchen und zum Menü hinzufügen. Rechts lassen sich Beschriftung, Ziel, Reihenfolge und Unterpunkte bearbeiten. Pfeilbuttons sind per Tastatur erreichbar; Rückgängig stellt entfernte oder umgeordnete Einträge wieder her. Erst **Menü speichern** ändert die Website. Validierungs-, Verbindungs- und Versionsfehler erhalten die lokalen Eingaben. Ein paralleler Speicherstand muss bewusst neu geladen werden.
**Bereiche** verwenden denselben Editor; Speichern wirkt sofort auf allen verknüpften Seiten. Unter **Formulare** ist die Meldung nach dem Absenden ein mehrzeiliges Feld über die verfügbare Formularbreite.
### Konfigurationswechsel von älteren Fassungen
Beim ersten Start von 1.7.0 wird eine vorhandene `.env.local` einmalig mit `.env` zusammengeführt. Die bisher wirksamen Werte aus `.env.local` bleiben dabei erhalten, insbesondere Datenbank, Medienordner und Secret. Beide Originale liegen anschließend unter `var/backups/environment-<Zeitpunkt>-<Kennung>/`; erst nach erfolgreichem Schreiben wird `.env.local` entfernt. Danach gelten Änderungen direkt in `.env`. System zeigt die tatsächlich genutzte Datenbank und die Quelle `.env` oder Server-Umgebung an.
Weitere Symfony-Overlays (`.env.prod`, `.env.prod.local`, `.env.local.php`) werden nicht mehr automatisch geladen. Falls ein Hosting sie bewusst verwendet hat, deren gewünschte Werte vor dem Wechsel in `.env` oder echte Servervariablen übernehmen. Der Installer, Setup und der Admin-Pfad-Befehl erzeugen keine `.env.local` mehr. Rückweg: alten Code und die beiden gesicherten Konfigurationsdateien gemeinsam wiederherstellen.
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`.
+1 -1
View File
@@ -1 +1 @@
1.6.1
1.7.2
+8 -8
View File
@@ -1,13 +1,13 @@
<?php
// Only creates local state; never replaces an existing environment file.
require dirname(__DIR__).'/vendor/autoload.php';
use App\Ops\Environment;
$root = dirname(__DIR__);
if (!is_dir($root.'/var')) {
mkdir($root.'/var', 0775, true);
}
if (!file_exists($root.'/.env.local')) {
file_put_contents($root.'/.env.local', 'APP_SECRET='.bin2hex(random_bytes(32)).PHP_EOL);
echo "Created .env.local with a local secret.\n";
} else {
echo "Existing .env.local preserved.\n";
mkdir($root.'/var', 0o775, true);
}
Environment::prepare($root);
Environment::ensureSecret($root);
echo ".env ist eingerichtet; bestehende Einstellungen und das Secret bleiben erhalten.\n";
+15 -16
View File
@@ -1,29 +1,28 @@
<?php
use App\Ops\Environment;
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
// PHP web hosts may omit E from variables_order. Honor real process configuration.
foreach (['APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', 'MEDIA_DIRECTORY'] as $name) {
$root = dirname(__DIR__);
Environment::prepare($root);
// Server variables are explicit deployment overrides; other .env.* files are never loaded.
$keys = array_keys((new Dotenv())->parse((string) file_get_contents($root.'/.env')));
foreach (array_unique([...$keys, 'APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', 'MEDIA_DIRECTORY', 'ADMIN_PATH', 'FONT_DIRECTORY', 'ASSET_DIRECTORY']) as $name) {
$value = getenv($name, true);
if (false !== $value && !isset($_SERVER[$name]) && !isset($_ENV[$name])) {
$_SERVER[$name] = $_ENV[$name] = $value;
}
}
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
// A freshly unpacked installation has no secret, and without one nothing boots - not even the
// installer that would explain why. So the first request writes one into .env.local and never
// touches it again. If the project directory is not writable, the request still works, but only
// this one: the installer's system check says so in a sentence instead of leaving a white page.
if (!isset($_SERVER['APP_SECRET']) || '' === $_SERVER['APP_SECRET']) {
$secret = bin2hex(random_bytes(32));
$file = dirname(__DIR__).'/.env.local';
$existing = is_file($file) ? (string) file_get_contents($file) : '';
if (false === @file_put_contents($file, $existing.(str_ends_with($existing, "\n") || '' === $existing ? '' : "\n").'APP_SECRET="'.$secret.'"'."\n")) {
$secret .= '-nicht-gespeichert';
} else {
@chmod($file, 0o600);
(new Dotenv())->load($root.'/.env');
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $_SERVER['APP_ENV'] ?? 'prod';
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? '0';
if ('' === ($_SERVER['APP_SECRET'] ?? '')) {
try {
$secret = Environment::ensureSecret($root);
} catch (RuntimeException) {
// Keep the installer reachable: its system check names the missing permission.
$secret = bin2hex(random_bytes(32)).'-nicht-gespeichert';
}
$_SERVER['APP_SECRET'] = $_ENV['APP_SECRET'] = $secret;
}
+3
View File
@@ -63,6 +63,9 @@ services:
App\Controller\SystemController:
arguments:
$adminPath: '%kant.admin_path%'
$databaseUrl: '%env(DATABASE_URL)%'
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
$projectDir: '%kernel.project_dir%'
App\Command\AdminPathCommand:
arguments:
$projectDir: '%kernel.project_dir%'
+62
View File
@@ -0,0 +1,62 @@
# Arbeitsplan: Installation, Verwaltung und Bewegung
Auftrag vom 14.09.2026. Ausgangspunkt: Release 1.6.2 plus bereits vorhandene lokale Änderungen an der Systemdiagnose. Diese bleiben erhalten und werden an den neuen Konfigurationsvertrag angepasst. Vorhandener Patch liegt lokal unter `var/work-before-usability/`.
## 1. Fehler reproduzieren und Daten schützen
- [x] Bereichsfehler auf den Seitenzugriff in der gemeinsamen Vorlage zurückführen und Öffnen/Vorschau/Speichern im Browser prüfen.
- [x] Kant-IT-Datenbank für die Prüfung isoliert kopieren; echte Inhalte und Zugangsdaten nicht verändern.
- [x] Slider und Einblendungen auf der Kant-IT-Startseite mit Positionen und Zeitverlauf prüfen.
## 2. Eine eindeutige Installationskonfiguration
- [x] `.env` ist die Konfigurationsdatei der Installation. Installer, Setup und Admin-Pfad-Befehl schreiben dort; keine neue `.env.local`.
- [x] Bestehende wirksame Einstellungen aus älteren Installationen einmalig und mit Sicherung in `.env` zusammenführen. Datenbankverbindung und Secret erhalten; danach keine versteckte Überschreibung.
- [x] Updates erhalten `.env`; echte Server-Umgebungsvariablen bleiben für Hosting/Testbetrieb möglich. Anzeige im Systembereich muss die tatsächliche Quelle nennen, ohne Passwörter auszugeben.
- [x] Neuinstallation, bestehende Installation, wiederholtes Booten, Updateerhalt und Fehler beim Schreiben prüfen.
## 3. Bereiche zuverlässig bearbeiten
- [x] Gemeinsamen Editor ohne Zugriff auf nicht vorhandene Seitenvariablen rendern; benannte Routen auch bei geändertem Admin-Pfad verwenden.
- [x] Kopf, Fuß und verknüpfte Sektion öffnen, Inhalt ändern, Vorschau prüfen, speichern und neu laden; Versionskonflikte erhalten.
- [x] Sofort wirksames Speichern bei Bereichen in der Oberfläche korrekt benennen.
## 4. Seiten und Beiträge übersichtlich verwalten
- [x] Eigene Navigation und Ansichten für Seiten und Beiträge; passende Anlageformulare.
- [x] Datenbankseitige Pagination, Suche, Statusfilter und Ergebniszahl; keine vollständigen Dokumentlisten für jede Bildschirmseite laden.
- [x] Filter und Seitenzahl beim Blättern erhalten; leere Suche und ungültige Seitenzahlen behandeln. Papierkorb ebenfalls begrenzen.
- [x] Übergeordnete Seiten bleiben unabhängig von der aktuellen Ergebnisseite wählbar.
## 5. Menüeditor mit verständlichem Aufbau
- [x] Seiten/Beiträge nach Titel oder Adresse suchen und mit eindeutiger Vorschau auswählen.
- [x] Inhalt auswählen → zum Menü hinzufügen → Reihenfolge/Unterpunkte ändern → speichern. Eigene Links getrennt anbieten.
- [x] Bestehende Ziele und Hierarchien erhalten, Umordnen auch mit Tastatur/Buttons; Speicherfehler und ungespeicherte Änderungen sichtbar behandeln.
## 6. Formulare, Animationen und Slider
- [x] Meldung nach dem Absenden als ausreichend breites mehrzeiliges Feld anbieten.
- [x] Einblendungen sichtbar beim Eintreten in den Bildschirm starten; Effektauswahl tatsächlich unterscheiden. Inhalte ohne JavaScript lesbar lassen und Bewegungsreduktion respektieren.
- [x] Slider mit sichtbarer Bedienung, funktionierendem Wechsel und nachvollziehbarer Pause/Automatik. Auf der realen Kant-IT-Seite über mehrere Wechsel prüfen.
- [x] Bewegung in der Editorvorschau gezielt prüfbar machen, ohne die Sandbox für Inhaltsskripte zu öffnen.
## 7. Abnahme und Übergabe
- [x] Relevante PHP-/JavaScript-Tests und vollständige Browserabläufe, einschließlich Tastatur, Speicherung, Fehlerzuständen und unterschiedlichen Bildschirmbreiten.
- [x] Änderungen der öffentlichen Ausgabe auf Kant-IT-Inhalten visuell und mit den passenden Qualitätsprüfungen kontrollieren; keine ungeprüften Scores behaupten.
- [x] Tatsächliche Ergebnisse, Grenzen und Migration dokumentieren; Versionsnummer vor Veröffentlichung mit dem Repository abgleichen.
Jeder Abschnitt wird erst als erledigt markiert, wenn seine Prüfung bestanden ist. Der Plan ersetzt keine Umsetzung; Befunde und Änderungen werden darin nachgeführt.
## Ergebnis der Abnahme
Umgesetzt als 1.7.0, ohne neues Datenbank- oder Dokumentformat. Begründungen und Konfigurationsmigration: ADR 076–078. Die ursprünglichen lokalen Änderungen an Systemdiagnose/STATUS wurden integriert, die veraltete lokale Versionsnummer 1.4.7 mit der bestehenden Releasefolge abgeglichen.
Geprüft: PHP-Vollsuite 461/461, JavaScript 30/30, alle 43 Twig-Dateien und Produktionscontainer. Komplette Editorprüfung einschließlich unterschiedlicher Einblendungen und Bewegungsreduktion. Kant-IT-Browserabläufe mit 4 Seiten/29 Beiträgen, Menüfehlern und Versionskonflikt, mehrzeiliger Formularmeldung, Kopf/Fuß und neu angelegter Sektion samt echtem Textinhalt. Desktop, 1024 und 390 px; axe ohne Verstöße. Öffentliche Inhalte und Slider bleiben ohne JavaScript erreichbar.
Kant-IT-Startseite, Produktionsmodus, Lighthouse 13.4.1: jeweils drei Läufe mobil/desktop, Leistungsmedian jeweils 99; Barrierefreiheit, Best Practices, SEO und Agentic Browsing jeweils 100. Lokale Labormessung, keine Felddaten/CWV-Abnahme für Kundenseiten. Messungen: `reports/admin-quality/lighthouse/`; Browserbericht und Bilder im übergeordneten Ordner. Der zusätzliche Updater-Test verändert die Datenbank vor einem Migrationsfehler, prüft echte Wiederherstellung und einen erhaltenen Wartungszustand, falls die Sicherung fehlt.
Veröffentlichungsnachweis und bekannte Grenzen stehen im aktuellen Abschnitt von STATUS.md.
**Veröffentlicht:** `495b268`, Tag `v1.7.0`, Release mit ZIP/Prüfsumme/Installer. Quellabgleich und anonyme Downloads geprüft; frischer Release-Installer antwortet mit HTTP 200 und erzeugt ausschließlich `.env`.
+53 -1
View File
@@ -1,6 +1,58 @@
# Architekturentscheidungen
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026)
Der Inhaltsbereich eines Bildes erhält oben einen Prozentregler und ein Zahlenfeld für 1–100 % der verfügbaren Containerbreite. Beide schreiben ausschließlich `style.<device>.width` über die bestehende `setStyle`-Transaktion. Die generische Breite unter Design und die direkte Bildsteuerung sind dadurch immer derselbe Wert. Originalmaße, Quelle, Varianten, Alternativtext und Bildunterschrift bleiben unverändert. Vorhandene maximale Breiten werden erhalten und im Hinweis genannt; Werte in anderen Einheiten bleiben sichtbar, bis bewusst eine Prozentbreite gesetzt wird. Keine neue Style-Eigenschaft, keine neue Schema- oder Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen JavaScript.
Die Steuerung verwendet das aktive Vorschaugerät und zeigt die Herkunft des Werts. Zurücksetzen entfernt nur die Breite dieses Geräts und stellt die bestehende Desktop→Tablet→Mobil-Vererbung wieder her. Eingaben aktualisieren die Vorschau, ohne das Feld zu ersetzen. Eine Ziehbewegung ist ein Undo-Schritt; Zahlenwerte außerhalb des Bereichs werden mit verständlicher Meldung abgelehnt und nicht gespeichert. Tastatur, Undo/Redo, Speichern/Laden und prozentuale Ausgabe ohne JavaScript werden am realen TeamViewer-Bild aus einer isolierten Kopie der Pilotdaten geprüft.
## ADR 079 – Livesuche mit gemeinsamen HTML-Ergebnissen (14.09.2026)
Seiten, Beiträge und Mediathek verwenden dieselben authentifizierten GET-Routen für vollständige Ansichten und AJAX. Der Header `X-Requested-With: XMLHttpRequest` fordert JSON mit einem serverseitig gerenderten Ergebnisfragment, Gesamtzahl und normalisierten Filtern an. Twig bleibt die einzige Darstellung für Listen, Berechtigungen, CSRF-Felder, leere Ergebnisse und Pagination. Suchformular sowie Anlegen/Hochladen bleiben außerhalb des austauschbaren Bereichs; vorhandene Formulare funktionieren weiter ohne JavaScript. Keine Schema- oder Inhaltsmigration.
Das Verwaltungsmodul wartet beim Tippen 220 ms und ersetzt nur die Ergebnisliste. Eine neue Eingabe bricht die alte Anfrage sofort ab; eine zusätzliche Sequenzprüfung verwirft verspätete Antworten. IME-Eingaben werden erst nach der Komposition gesucht. Beginnt während einer ausstehenden Suche eine Bearbeitung in der Liste, wird die Suche angehalten, damit die Antwort keine Eingabe entfernt. Nach dem Austausch werden Seitenauswahlen initialisiert und die Rückkehrfilter aller Formulare aktualisiert. Fehler erhalten die bisherigen Ergebnisse und bieten Wiederholen an. Ergebniszahl und Ladezustand sind für Screenreader zugänglich; Tastaturfokus bleibt beim Tippen erhalten.
URLs enthalten den Suchzustand. Tippen ersetzt den aktuellen Historieneintrag, Filterwahl und Blättern legen einen neuen an; `popstate` lädt passende Ergebnisse und stellt die Felder wieder her. Ohne JavaScript arbeitet das GET-Formular mit Submit und regulären Links. Das Modul gehört ausschließlich zum Admin-Paket.
Die Mediathek filtert Titel und Alternativtext vor COUNT und LIMIT in der Datenbank, inklusive Archivfilter und wörtlicher Behandlung von `%`, `_` und `!`. SQLite erhält eine Unicode-fähige LOWER-Funktion über PDO/mb_strtolower, weil die eingebaute Funktion nur ASCII verarbeitet und etwa „über“ nicht zu „Über“ passte. MySQL verwendet weiterhin seine native Funktion. Die Korrektur gilt auch für Inhalts- und Menüsuche; bestehende Daten und Indizes werden nicht verändert.
## ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026)
Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. `motion.js` beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert.
Slider behalten Scroll-Snap als Grundlage. Ein kleines Modul ergänzt sichtbare Pfeile, Zähler, optionale Punkte sowie Pause/Wiedergabe bei Autoplay. Manuelle Bedienung stoppt auch einen bereits geplanten Timer. Automatik wartet bei Hover, außerhalb des Bildschirms oder in unsichtbaren Tabs; Bewegungsreduktion deaktiviert sie. Ohne Skript bleibt der Inhalt scrollbar. Im Editor startet der Benutzer diese Module ausdrücklich aus dem Elternfenster; die iframe-Sandbox bleibt `allow-same-origin` ohne `allow-scripts`. Neue Vorschauen räumen Observer, Timer und Ereignisse der alten Instanz auf.
## ADR 077 – Begrenzte Inhaltslisten und Menüaufbau mit Suche (14.09.2026)
Seiten und Beiträge erhalten getrennte Routen und Navigation. Die Datenbank führt Typ, Suche, Status, COUNT und LIMIT/OFFSET aus; nur 20 oder 50 Dokumente werden pro Ansicht dekodiert. Stabile Sortierung verhindert Überschneidungen bei identischen Zeitstempeln. Papierkorb und leere Suchergebnisse folgen demselben Vertrag. Keine Schemaänderung. Unterlagen mit Titeln und Pfaden werden höchstens in Gruppen von 20 als authentifizierte Suchvorschläge geliefert. Eine separate ID-Abfrage hält bereits ausgewählte Eltern unabhängig von der Ergebnisseite sichtbar.
Der Menüeditor bearbeitet einen Menüplatz je Ansicht. Suche und eigene Links stehen links, Reihenfolge und Felder rechts. Hinzufügen, Haupt-/Unterpunkte, Umordnen und Entfernen sind per Buttons/Tastatur bedienbar und rückgängig zu machen. Haupteinträge ziehen ihre Kinder mit; beim Entfernen bleiben deren Links als Hauptpunkte erhalten. Speichern verwendet weiterhin die validierte Menüstruktur und erwartete Version, liefert bei JavaScript JSON und behält bei 422/409 oder Verbindungsfehlern die Eingaben. Keine neuen Datenformate.
Der Fehler 500 bei Bereichen entstand durch einen unbedingten Zugriff auf `page.slug` in der gemeinsamen Editorvorlage. Bereiche haben keine Seite. Routen und öffentliche Links richten sich jetzt nach dem bearbeiteten Objekt; Speichern eines Bereichs ist sichtbar als sofort wirksame Änderung bezeichnet. Der API-Pfad wird aus benannten Routen abgeleitet, auch nach Verlegung der Verwaltung.
## ADR 076 – Eine Installationsdatei und sichtbare Datenbankquelle (14.09.2026)
`.env` ist die einzige automatisch geladene Konfigurationsdatei. Sie gehört zur Installation und wird nicht mehr von Git versioniert. Installer, Setup und Admin-Pfad-Befehl schreiben atomar und unter einer Dateisperre in diese Datei, ohne bestehende fremde Schlüssel zu verwerfen. Ein Marker unterscheidet eine vom Installer bestätigte Datenbank von der bloßen Vorlage. Echte Prozess-/Servervariablen bleiben bewusst höher priorisiert.
Beim ersten Boot mit einer alten `.env.local` bleiben deren bisher wirksame Werte erhalten: beide Originale sichern, zusammengeführte `.env` schreiben, erst danach `.env.local` entfernen. Sicherungen liegen außerhalb des Webroots unter `var/backups/environment-*`. Secret und Datenbank werden nicht neu erzeugt oder gewechselt. Ein Schreibfehler bricht die Migration ab, statt still auf Standarddaten zu wechseln. Andere Symfony-Overlays werden nicht mehr implizit geladen; eine manuelle Nutzung benötigt vor dem Wechsel die Übernahme in `.env`/Servervariablen. Rückweg: Code und beide Konfigurationssicherungen gemeinsam wiederherstellen.
Der Git-Updater sichert Konfigurationsinhalte vor dem Checkout und stellt sie vor jedem neuen PHP-Prozess wieder her, auch beim Rollback. Das behandelt ausdrücklich ältere Repositorys mit getrackter `.env`. Der Archiv-Updater nimmt diese Dateien weiterhin vom Dateiaustausch aus. Zusätzlich wurde ein vorhandener Rückwegfehler behoben: Der Git-Updater speicherte nur einen Beschreibungstext und leitete daraus einen ungültigen Datenbank-Sicherungspfad ab. Der tatsächliche Sicherungsordner wird jetzt getrennt geführt. Ein fehlgeschlagenes Zurückschreiben lässt den Wartungszustand aktiv; der Regressionstest verändert die Datenbank vor dem simulierten Migrationsfehler und prüft ihre Wiederherstellung. Bereits installierter alter Git-Updater-Code kann diesen neuen Schutz beim erstmaligen Wechsel noch nicht ausführen; daher diesen Übergang per Archiv-Update durchführen oder die Konfiguration vor dem manuellen Checkout sichern und anschließend wiederherstellen (UPDATE.md).
Die bereits begonnene Systemdiagnose bleibt erhalten: tatsächliche Datenbankart, SQLite-Pfad/Größe/Schreibrecht beziehungsweise maskierte Serveradresse sowie die tatsächliche Quelle. Die Quelle stammt aus den vom Dotenv-Lader gesetzten Variablen; sie wird nicht aus der bloßen Existenz einer Datei geraten. Passwort und Secret werden nicht angezeigt.
## ADR 075 – Leere Container sind Arbeitsflächen (14.09.2026)
Leere Spalten hatten keine Höhe; leere Sektionen waren zwar durch Abstände vorhanden, aber weder als Container erkennbar noch mit einem direkten Einfügeweg versehen. Der lokale Flatsome-3.20.9-Quelltext verwendet für solche Zustände eine eigene sichtbare Inhaltsfläche mit Mindesthöhe und mittigem Einfügewerkzeug (konkrete Fundstellen in `docs/FLATSOME-ABGLEICH.md`).
`preview-containers.js` ergänzt deshalb erst im vollständig geparsten Vorschau-DOM sichtbare Hilfsflächen für leere Sektionen, Zeilen und Spalten. Eine leere Sektion bietet außerdem Zeilenlayouts mit 1–4 Spalten. Die Hilfen sind echte Buttons mit Tastaturbedienung und eindeutigen Namen, etwa „Spalte 2 von 2: Inhalt hinzufügen“. Sie setzen einen genauen Einfügepunkt oder führen dieselbe Modelltransaktion wie der Werkzeugkasten aus. Abbrechen oder eine andere Auswahl hebt den Einfügepunkt auf.
Die Editor-CSS gibt den Flächen mindestens 130 Layoutpixel Höhe. Der öffentliche Renderer und seine CSS bleiben unverändert; die Mindesthöhe ist eine Bearbeitungshilfe und wird nicht als Designwert gespeichert. Nach dem ersten Inhalt verschwindet die Hilfe automatisch, Undo stellt sie wieder her. Kein Schemawechsel und keine Datenmigration; bestehende leere Container erhalten die Hilfe allein beim Öffnen. `preview.css` und das neue Modul erhalten die Versionskennung 1.6.2, damit ein bestehender Browsercache die Funktion nicht verdeckt.
Neu eingefügte Elemente werden nach dem Rendern sichtbar gescrollt. Die Vorschau gilt erst mit geparstem DOM und geladenen Hilfsstilen als bereit. Die Drop-Geometrie wird beim Scrollen und beim Loslassen neu ermittelt, Zielcontainer werden benannt, und nebeneinanderstehende Spalten werden horizontal eingeordnet; mobil gestapelte Spalten vertikal. Ein Ziehen nahe dem Vorschaurand scrollt den Frame, bis der Zeiger den Rand verlässt oder das Ziehen endet. Inhalt und Undo bleiben von bloßem Scrollen unberührt.
Der Offen-/Geschlossen-Zustand von Feinschliff-Gruppen wird direkt aus ihren bisherigen DOM-Knoten übernommen. Ein verzögertes `toggle`-Ereignis allein reicht nicht: Ein schneller Neuaufbau nach einer Wertänderung kann den Knoten entfernen, bevor das Ereignis ankommt, und würde die gerade geöffnete Gruppe wieder schließen.
## ADR 074 – Editorfläche, Seitenebene und kontextbezogene Gestaltung (14.09.2026)
+14
View File
@@ -1,5 +1,19 @@
# Abgleich mit dem Flatsome UX Builder
## Bedienkorrektur für leere Layouts (14.09.2026, KantSites 1.6.2)
Erneuter Quelltextabgleich mit dem von Umut genannten Ordner `C:\Users\UmutKantarci\Downloads\flatsome`, Theme-Version **3.20.9** laut `style.css`. Keine laufende WordPress-Installation dieses Pakets gestartet; die folgenden Aussagen stammen aus seinen tatsächlichen Builder-Dateien.
| Beobachtung im Flatsome-Code | Umsetzung in KantSites |
| --- | --- |
| `assets/css/builder/core/content.css`: leeres `content` mit mindestens 90 px Höhe, getöntem Hintergrund und gepunktetem Rahmen | Vorschau-only-Flächen für leere Sektionen, Zeilen und Spalten mit realer Mindesthöhe, Rahmen und Typbezeichnung |
| `tools-addable.is-empty` und `assets/js/builder/core/content.js`: Plus in der Mitte, eigener Einfüge-/Drop-Zustand für leere Container | „Inhalt hinzufügen“ im Zielbereich; Spalten werden nummeriert; der Einfügepunkt wird konkret markiert und benannt |
| `inc/builder/shortcodes/row.php`: fertige Zeilen mit einer, zwei, drei und vier Spalten | Diese vier gleichmäßigen Layouts direkt in der leeren Sektion anbieten, einschließlich passender mobiler Stapelung |
Zusätzlich: neue Container werden nach dem Rendern in den sichtbaren Ausschnitt gebracht, ein Auswahlwechsel löst alte Einfügepunkte, und beim Ziehen scrollt die Vorschau an den Rändern mit. Die Ablageanzeige nennt den Zielcontainer; beim Verschieben von Spalten entscheidet die horizontale Position. Editorhilfen entstehen ausschließlich im Vorschau-DOM, nicht im gespeicherten Dokument oder in der öffentlichen Ausgabe.
Der ältere Gesamtabgleich unten bleibt eine historische Bestandsaufnahme. Diese Änderung übernimmt konkrete Bedienprinzipien für den Aufbau, keine vollständige Flatsome-Oberfläche und keine WordPress-/Shortcode-Abhängigkeit.
Stand: 14.09.2026, KantSites 1.5.0 (Dokumentschema v19).
Quelle des Vergleichs: Flatsome-Theme im Download-Ordner, `inc/builder/shortcodes/` (69 Definitionen)
und `inc/shortcodes/`. Ziel laut Auftrag: alle Funktionen übernehmen und verbessern, die
+6
View File
@@ -1,5 +1,11 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Sichtbare leere Layouts, 14.09.2026 (1.6.2)
30 JavaScript-Tests und 27 PHP-Editor-/Sicherheitstests mit 312 Assertions bestanden. Vollständiger isolierter Chromium-Editortest einschließlich axe ohne Verstöße: direkte Tastatureinfügung in leere Sektionen/Spalten, genaues Drag-and-drop-Ziel und Scrollen am Rand, Spaltenreihenfolge auf Desktop und Mobil, Undo/Redo, Speichern/Neuladen, Veröffentlichung und Konflikte. Die Tests prüfen ausdrücklich, dass die neuen Editorhilfen weder im gespeicherten Blockbaum noch im öffentlichen HTML stehen. Desktop-, Mobil- und Drag-Screenshots visuell geprüft; Bericht unter `reports/editor/result.json`.
Vergleich mit lokalen Flatsome-3.20.9-Quelldateien, keine laufende WordPress-Referenz geprüft. Kein neuer Lighthouse-Lauf, da der öffentliche Renderer und dessen CSS unverändert sind; die Messung von 1.6.1 ist weiterhin als solche zu lesen. Kein neuer Screenreader-, Firefox-, Safari-, Touchgeräte- oder Felddatennachweis.
## Ergänzung: Flexible Editorfläche und Inhaltsbreiten, 14.09.2026 (1.6.1)
`npm.cmd test`: 30 Modelltests; PHP 8.4.22: 452 Tests / 3476 Assertions. Der vollständige isolierte Playwright-Lauf einschließlich axe, Speichern/Neuladen, Veröffentlichung, Konfliktbehandlung und neuer Panel-/Sektionsprüfungen besteht. Bildschirmbreiten 390/850/1100/1920 px ohne horizontalen Überlauf geprüft. Screenshotprüfung für Desktop, Bildinspektor und Telefon; kein neuer Screenreader-Test.
+49
View File
@@ -1,7 +1,55 @@
# Projektstatus
## 1.7.2 – Bildbreite direkt in Prozent einstellen (14.09.2026)
**Umgesetzt:** Beim ausgewählten Bild steht im Reiter Inhalt oben „Bildbreite (%)“ mit Regler, Zahlenfeld und Zurücksetzen. 1–100 % beziehen sich auf die verfügbare Breite der Sektion/Spalte. Desktop, Tablet und Mobil verwenden die vorhandene Vererbung; Herkunft und bestehende maximale Breiten sind sichtbar. Beide Bildsteuerungen bearbeiten denselben gespeicherten Breitenwert wie Design → Größe. Originalmaße, Medienvarianten, Alt-Text und Bildunterschrift bleiben erhalten. Ungültige Zahlen verändern den gespeicherten Wert nicht, eine Ziehbewegung bleibt ein Undo-Schritt. Der veraltete Hinweis auf eine erst künftig verfügbare Mediathek wurde berichtigt. Keine neue Schema-/Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen Paket.
**Geprüft:** `npm run test:image-size` auf einer isolierten Kant-IT-Kopie mit dem TeamViewer-Logo: Regler mit echter Mausbewegung und Tastatur, Zahlenfeld, Fokus, ungültiger Wert, Undo/Redo, responsive Vererbung/Reset, Speichern/Laden, unveränderte Bilddaten und Abgleich mit Design → Größe. Veröffentlichte Ausgabe ohne JavaScript bei 1600/900/390 px: 60/75/30 % und unverzerrtes Seitenverhältnis gemessen. Neue Steuerung auf Desktop/Mobil ohne Überlauf und axe-Verstöße, Screenshots visuell geprüft (`reports/image-size/`). Vollständige Editor-Browserprüfung erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). 30 JavaScript-Tests und 35 PHP-Tests zu Styles/Bildern mit 67 Assertions erfolgreich. Alle Schreibtests in eigenen Datenbankkopien.
**Offen:** Geprüfte Fassung als 1.7.2 veröffentlichen. Kundenhostings wurden nicht verändert. Da nur die Editorsteuerung vorhandener Style-Werte geändert wurde, keine erneuten öffentlichen Lighthouse-Messungen; die Prozentdarstellung wurde im gemeinsamen Renderer direkt überprüft.
## 1.7.1 – Livesuche in der Verwaltung (14.09.2026)
**Umgesetzt:** Seiten und Beiträge suchen beim Tippen per AJAX; Status, 20/50 Einträge und Pagination aktualisieren nur die Ergebnisliste. URL, Browser-Zurück/Vor, Eingabefokus und Rückkehrfilter beim Speichern bleiben zusammenhängend. Die Mediathek durchsucht Titel und Alternativtext über alle Seiten, kombiniert mit Archivfilter. Bestehende Suchen in Menüs, übergeordneten Seiten, Editor-Werkzeugkasten/Struktur/Eigenschaften und Bildauswahl reagieren bereits beim Tippen. Kein Enter nötig; ohne JavaScript bleibt „Anwenden“ erhalten. Alte Antworten werden verworfen, Fehler erhalten Treffer und Eingaben, Wiederholen ist direkt möglich. Eine während der Suche begonnene Bearbeitung hält den Austausch an. SQLite-Suche erkennt Groß-/Kleinschreibung auch bei Umlauten. Keine Datenmigration und kein zusätzliches öffentliches JavaScript.
**Geprüft:** PHP 8.4.22: `EditorSecurityTest|CmsTest` 332 Tests / 2832 Assertions; nach der abschließenden Korrektur zur Suchanfrage „0“ die fünf Livesuche-/Medienfälle erneut mit 111 Assertions. 30 JavaScript-Tests; 24 Admin-Twig-Vorlagen; Produktionscontainer; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:search`: sechs Browser-Prüfgruppen mit kopierten Kant-IT-Daten, darunter schnelle/überholte Anfragen, Fehler/Wiederholen, abgelaufene Sitzung, IME, Pagination/Historie, Seitenauswahl nach AJAX, Bearbeitung während laufender Suche, Medien und Betrieb ohne JavaScript. Desktop 1440 und Mobil 390: kein horizontaler Überlauf, axe ohne Verstöße; Screenshots visuell geprüft. `test:admin -- --admin-only` bestätigt zusätzlich Menüs, Formulare, Bereiche, Speichern/Laden sowie die Vorschau mit aktivierter Bewegung; Tablet/Mobil ohne axe-Befunde. Berichte: `reports/live-search/`, `reports/admin-quality/`; Testkonten und Änderungen nur in isolierten Datenbankkopien.
**Veröffentlicht:** Commit `41d87f6` und Tag `v1.7.1` auf `origin`; [Release 1.7.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.1) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` hochgeladen und anschließend ohne Anmeldung heruntergeladen/bytegenau geprüft. Paket aus dem exakten Commit: 3531 Dateien, davon 208 versionierte Quelldateien verglichen; Produktionsabhängigkeiten ohne Entwicklungspakete, `.env` aus Vorlage, keine `.env.local`. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite mit HTTP 200 geprüft. ZIP-SHA-256: `08846e3b8b700e66075160956a9e1963dc5d4f11a350dae175a0ff9511dd2ab1`.
**Grenzen und nächster Schritt:** Das Update ist veröffentlicht und kann über die Systemverwaltung eingespielt werden; Kundenhostings wurden nicht verändert. MySQL wurde in diesem Schritt nicht ausgeführt; die lokale Prüfung verwendet SQLite. Öffentliche Lighthouse-Messungen wurden für diese Verwaltungsänderung nicht wiederholt; Nachweise aus 1.7.0 stehen unten.
## 1.7.0 – Installation, Verwaltungsabläufe und Bewegung (14.09.2026)
**Veröffentlicht:** Commit `495b268` und annotierter Tag `v1.7.0` sind auf `origin` hochgeladen. [Release 1.7.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.0) enthält ZIP, SHA-256-Datei und `install.php`. Gebaut aus dem exakten Commit: 3527 Archivdateien, 204 versionierte Quelldateien bytegenau verglichen, Produktionsabhängigkeiten ohne Entwicklungspakete. Archivintegrität, Prüfsumme im Installer, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env` vorhanden, keine `.env.local`) geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; API meldet 1.7.0 als neuestes stabiles Release. ZIP-SHA-256: `b708f7dffad74e1def31325533b646557963b0f3e702c46bd85ee109fc22a192`. Das Update ist bereitgestellt, nicht auf Kundenhostings eingespielt.
**Umgesetzt:** `.env` ist die einzige automatisch geladene Installationsdatei und aus Git entfernt. Setup, Webinstaller und Admin-Pfad-Befehl schreiben dort. Alte `.env.local` wird einmalig mit Sicherung beider Originale zusammengeführt; wirksame Datenbank und Secret bleiben erhalten. System zeigt die tatsächliche Datenbank samt Quelle. Der Git-Updater erhält Konfiguration vor dem neuen PHP-Prozess und beim Rückweg; ein entdeckter falscher Datenbank-Sicherungspfad wurde ebenfalls korrigiert. Ein fehlgeschlagenes Zurückschreiben lässt die Website im Wartungszustand. Vorgehen beim erstmaligen Wechsel von altem Git-Updater-Code und bei FTP: UPDATE.md. Keine Schema-/Datenbankmigration.
**Verwaltung:** Fehler 500 beim Öffnen von Bereichen behoben. Seiten/Beiträge mit eigener Navigation, datenbankseitiger Pagination (20/50), Suche, Status, begrenztem Papierkorb und Filtererhalt. Elternauswahl unabhängig von der Ergebnisseite. Menüeditor mit Suchauswahl per Tastatur, eigenen Links, klarer Reihenfolge, Haupt-/Unterpunkten und Rückgängig; Validierungs-/Verbindungs-/Versionsfehler erhalten Eingaben. Formularmeldung mehrzeilig über die verfügbare Breite; Felddefinitionen auf schmalen Bildschirmen als beschriftete Gruppen. Mobil zugänglicher Kontolink.
**Bewegung:** Richtungen und Effekte werden beim Erscheinen tatsächlich unterschieden. Slider haben Pfeile, Zähler, optionale Punkte und Pause/Wiedergabe. Ein bereits geplanter Wechsel wird bei manueller Bedienung abgebrochen. Automatik wartet bei Hover, außerhalb des Bildschirms und in unsichtbaren Tabs; reduzierte Bewegung stoppt sie. Bewegung im Editor ausdrücklich testbar, iframe-Sandbox weiterhin ohne Skriptfreigabe. Kein JavaScript für die Lesbarkeit der Inhalte erforderlich.
**Geprüft:** PHP 8.4.22 Vollsuite 461 Tests/3577 Assertions bestanden; anschließend 10/10 Tests des finalen Updaters mit 206 Assertions, einschließlich echter Datenbankänderung vor dem Fehler und ausgefallenem Restore. `npm.cmd test` 30/30. Alle 43 Twig-Dateien und Produktionscontainer gültig. Vollständiger `npm.cmd run test:editor` bestanden, einschließlich neuer Links-/Rechtseinblendung, reduzierter Bewegung, Drag-and-drop, Undo/Redo, Speicher-/Veröffentlichungs-/Konfliktabläufen und sämtlichen axe-Prüfungen. `node scripts/admin-quality-check.mjs --audit` bestanden: isolierte Kant-IT-Kopie mit 4 Seiten/29 Beiträgen; Pagination ohne Überschneidungen, leere Suche, Seitengrößen und Grenzwerte; Menüsuche per Tastatur, Hierarchie/Undo/Speicherung sowie 422/409 ohne Eingabeverlust; breite Formularmeldung nach Neuladen; echte gemeinsame Sektion mit Text; Kopf/Fuß ändern und neu laden; mehrfaches Slider-Autoplay, manuelle Pause, Fortsetzen, reduzierte Bewegung und bedienbare Sandbox-Vorschau. Desktop/1024/390 px ohne Seitenüberlauf oder axe-Verstoß. Zusätzliche Prüfung ohne JavaScript und visuelle Screenshots bestanden. Kunden-/Pilotdatenbank bleibt eine nur gelesene Quelle; Testkonten und Änderungen liegen ausschließlich in temporären Kopien, deren Hintergrundaufgaben abgeschaltet sind.
**Öffentliche Messung:** Kant-IT-Startseite mit Originalmedien, Produktionsmodus, Lighthouse **13.4.1**, je drei Läufe: Leistung mobil **99/99/99**, Desktop **99/99/97**, Median jeweils **99**. Barrierefreiheit, Best Practices, SEO und Agentic Browsing in allen sechs Läufen **100**. Lokaler PHP-Server mit Kompression/Cachekopfzeilen über `bin/dev-router.php`; keine Garantie für Hosting oder beliebige Kundeninhalte. Berichte: `reports/admin-quality/lighthouse/summary.json`, einzelne Läufe daneben; Editor: `reports/editor/result.json`; Verwaltungsabläufe: `reports/admin-quality/result.json`. Screenshots der Verwaltung, der mobilen Formulardefinition und des Piloten visuell geprüft.
**Grenzen / nächster Schritt:** Kein neuer Firefox-/Safari-/Screenreader- oder echter Touchgerätetest, keine CWV-Felddaten. Der Pilot enthält vereinzelt bereits gespeicherte HTML-Entities als Klartext. Die übrigen offenen Entwicklungsplan-Pakete bleiben bestehen. Installations- und Bedienkorrekturen nach Veröffentlichung über den vorhandenen Archiv-Updater auf der gewünschten Installation übernehmen. Arbeitsplan: ARBEITSPLAN-ADMIN-QUALITAET.md; Entscheidungen: ADR 076–078.
## 1.6.2 – Sichtbare leere Sektionen und Spalten (14.09.2026)
**Veröffentlicht:** Commit `6a044e5` und annotierter Tag `v1.6.2` sind auf `origin` hochgeladen. [Release 1.6.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.2) enthält ZIP, SHA-256-Datei und `install.php`. Aus dem exakten Commit mit Produktionsabhängigkeiten gebaut: 3521 Archivdateien, 199 Quelldateien gegen den Commit verglichen, Archivintegrität und Produktionscontainer geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und gegen die lokalen Dateien geprüft; 1.6.2 ist laut API das neueste stabile Release. ZIP-SHA-256: `6af093d752fafa76c720c203e1e3ced03e37ace3481a405b7d6eed378255e449`. Das Update ist bereitgestellt, nicht auf Kundenwebsites installiert.
**Erledigt:** Leere Sektionen, Zeilen und Spalten besitzen in der Vorschau sichtbare, beschriftete Arbeitsflächen. „Inhalt hinzufügen“ setzt einen genauen Einfügepunkt, öffnet den Werkzeugkasten und fokussiert die Suche. Leere Sektionen bieten unmittelbar 1–4 Spalten; neue Elemente werden sichtbar gescrollt. Beim Ziehen sind Container und Ablageziel erkennbar, die Vorschau scrollt am Rand mit. Spalten werden auf Desktop horizontal, bei mobiler Stapelung vertikal eingeordnet. Geöffnete Feinschliff-Gruppen bleiben auch bei schnellen Änderungen erhalten. Cachekennung 1.6.2; keine Schema- oder Datenbankmigration. Die Hilfen werden ausschließlich im Editor-DOM erzeugt und erscheinen weder in gespeicherten Dokumenten noch auf öffentlichen Seiten. Entscheidungen: ADR 075.
**Referenz:** Die tatsächlichen Builder-Dateien des bereitgestellten Flatsome 3.20.9 wurden erneut gelesen. Übernommen sind sichtbare Leerflächen, direkte Plus-Aktionen und schnelle Spaltenlayouts. Konkrete Fundstellen und Umfang stehen in `docs/FLATSOME-ABGLEICH.md`; keine laufende WordPress-Referenzinstallation geprüft.
**Geprüft:** `npm.cmd test` 30/30; PHP 8.4.22 `vendor/bin/phpunit --filter EditorSecurityTest` 27/27 mit 312 Assertions; JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig. Der vollständige isolierte `npm.cmd run test:editor` besteht einschließlich axe-Prüfungen. Neue Abdeckung: sichtbare leere Sektion, Tastaturwahl des Zweispaltenlayouts, Einfügen in genau die zweite Spalte, Ziehen in die erste Spalte mit Zielmarkierung und automatischem Scrollen, Undo/Redo, leere Layouts nach Speichern/Neuladen, Spaltenumsortierung Desktop/Mobil und Veröffentlichung ohne Editorhilfen. Desktop-, Mobil- und Drag-Ansichten anhand der Screenshots visuell geprüft. Bericht: `reports/editor/result.json`; Bilder: `empty-section.png`, `empty-layout-desktop.png`, `empty-layout-mobile.png`, `empty-layout-drag.png` im selben Ordner. Keine Änderungen an Kundendaten durch die isolierten Tests.
**Bekannte Grenzen:** Der öffentliche Renderer ist unverändert; Lighthouse wurde für diese Fassung nicht erneut gemessen. Die Messung zu 1.6.1 bleibt unten dokumentiert. Kein neuer Screenreader-, Firefox-, Safari- oder echter Touchgerätetest. **Nächster Schritt:** Die neuen Aufbauabläufe mit Umuts tatsächlichen Seiten nutzen und anschließend die restlichen Arbeitspakete des Entwicklungsplans fortsetzen.
## 1.6.1 – Editorbreite, neue Seitenabschnitte und passende Eigenschaften (14.09.2026)
**Veröffentlicht am 14.09.2026:** Commit `b783d9d` und annotierter Tag `v1.6.1` sind auf `origin` (`https://git.kant-it.de/kantarci/KantSites.git`) hochgeladen. Das stabile [Release 1.6.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.1) enthält ZIP, SHA-256-Datei und `install.php`. Das Archiv wurde aus dem exakten Commit mit Produktionsabhängigkeiten gebaut (3520 Dateien); Archivintegrität, Übereinstimmung mit dem Commit und Produktionscontainer wurden geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; die API liefert 1.6.1 als neuestes stabiles Release. Archiv-SHA-256: `a6e7da2bb14ede43dd2d94960b3b4d67cc285d9c717b560f796a91cbcde4dd26`. Dieser Schritt veröffentlicht das Update, installiert es aber nicht auf Kundenwebsites.
**Erledigt:** Die festen 690/900/760-px-Grenzen für Text/H1/H2 sind entfernt. Elemente nutzen ihren Container, eigene Maximalbreiten bleiben möglich. Dauerhaft sichtbare Aktion **Neue Sektion auf Seitenebene**; auch der normale Sektionsklick erzeugt ohne expliziten Einfügepunkt einen übergeordneten Seitenabschnitt. Linke und rechte Sidebar sind per Ziehen und Tastatur verstellbar und merken ihre Breite im Browser. Die Vorschau nutzt ihre Fläche ohne Außenränder; die Elementbibliothek läuft nicht mehr horizontal über. Auf kleinen Fenstern passen sich die Panels an, auf dem Telefon stehen sie untereinander.
**Feinschliff:** Bild-, Text- und Containeroptionen werden passend gefiltert und sortiert. Gespeicherte Werte bleiben sichtbar; die gesamte Eigenschaftenauswahl ist über eine Schaltfläche erreichbar. Die Auswahl eines anderen Blocks setzt die Panelrolleiste nach oben. Geöffnete Eigenschaftengruppen bleiben beim Ändern eines Werts geöffnet. Automatische Größen und „Ohne Begrenzung“ sind explizit wählbar. Cachekennung auf 1.6.1 angehoben; keine Datenbankmigration, keine Kundeninhalte durch Tests geändert. Begründung und Rückweg: ADR 074.
@@ -56,6 +104,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
- **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt.
- **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden.
- **System nennt die tatsächlich benutzte Datenbank und deren Quelle** (1.7.0, ADR 076): Art, Dateipfad, Größe, Schreibrecht und `.env` beziehungsweise Server-Umgebung. Die bereits lokal begonnene Diagnose wurde in den neuen Konfigurationsvertrag übernommen. Verdeckte Datei-Overlays entfallen; die bisher wirksamen Werte werden einmalig gesichert und in `.env` zusammengeführt.
- **Das Update schreibt mit** (1.4.6, ADR 071): Jeder Schritt des Archiv-Updaters geht sofort nach `var/updates.log` – während des Laufs, nicht am Ende. Auf Umuts Hosting war die Ergebnisseite tot, und damit war auch verloren, was der Updater berichtet hatte; genau so wurde der nächste Fehlschlag dann doch noch lesbar („`bin` ließ sich nicht beiseitelegen – der alte Stand wurde zurückgeschoben"). Die Ergebnisseite versteht zusätzlich die Schreibweise bis 1.4.4: Beim Update schreibt der **alte** Code die Schritte und der **neue** zeigt sie an.
- **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde.
- **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`.
+8
View File
@@ -125,3 +125,11 @@ var/releases/install.php → Release-Anhang
- **Der Rauchtest ist `cms:health`** und läuft als eigener Prozess, weil nur ein frischer Prozess den neuen Code ausführt.
- **Noch offen:** MySQL wird gesichert, indem `mysqldump` aufgerufen wird – das ist **nicht** umgesetzt, weil noch keine MySQL-Installation existiert (Arbeitspaket J). Bei einer Datenbank, die nicht als Datei vorliegt, sichert der Updater derzeit nur die Umgebungsdateien und sagt das im Schritt „Sicherung“. Vor dem ersten Livegang mit MySQL muss das nachgezogen werden.
- **Ebenfalls offen:** der Installer aus Arbeitspaket N. Die Systemprüfung, die er braucht, existiert jetzt.
## Übergang auf 1.7.0: `.env` gehört zur Installation
Der Archiv-Updater erhält die Konfiguration auch bei diesem Übergang. Der erste Start führt eine bestehende `.env.local` mit `.env` zusammen und sichert beide Originale in `var/backups/environment-*`. Danach ist allein `.env` maßgeblich; tatsächliche Servervariablen bleiben möglich. Datenbank, Medienordner und Secret bleiben die bisher wirksamen Werte. Siehe ADR 076 und README.
In alten Git-Installationen war `.env` noch versioniert. Beim erstmaligen Wechsel kann ein alter Git-Updater diese neue Schutzlogik noch nicht besitzen. Nutze dafür den Archiv-Updater. Bei einem manuellen Git-Checkout zuerst `.env` und gegebenenfalls `.env.local` außerhalb des Checkout-Verzeichnisses sichern, beide nach dem Checkout wiederherstellen und erst danach Setup/Migration/Cache starten. Ab 1.7.0 erhält der Git-Updater die Dateien selbst vor dem ersten neuen PHP-Prozess. `.env` nicht erneut ins Repository aufnehmen.
Bei FTP-Updates die `.env` aus dem neuen Archiv überspringen. Die Vorlage im Archiv ist ausschließlich für Erstinstallationen bestimmt. Ein Rückweg benötigt den alten Code plus die beiden gesicherten Konfigurationsdateien.
+4 -1
View File
@@ -8,7 +8,10 @@
"test:editor": "node scripts/editor-check.mjs",
"test:browser": "node scripts/browser-check.mjs",
"test:links": "node scripts/link-check.mjs",
"audit": "node scripts/audit.mjs"
"audit": "node scripts/audit.mjs",
"test:admin": "node scripts/admin-quality-check.mjs",
"test:search": "node scripts/live-search-check.mjs",
"test:image-size": "node scripts/image-size-check.mjs"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
+27
View File
@@ -705,3 +705,30 @@ details.card > summary:hover { background: #fafbf8 }
.shell-version { display: none }
.shell-user strong { display: none }
}
/* Search and forms share usable widths throughout administration. */
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
.live-search-ready .search-submit{display:none}.search-status{min-height:20px;margin:-12px 0 0}#list-results{scroll-margin-top:16px}#list-results>.section-head{margin-top:0}#list-results:focus-visible{outline:2px solid var(--accent);outline-offset:5px}#search-error p{margin-top:0}
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
.picker-results{position:absolute;z-index:30;top:100%;left:0;right:0;min-width:220px;max-height:300px;overflow-y:auto;background:white;border:1px solid #a7b9af;border-radius:8px;box-shadow:0 10px 30px #18382b24;padding:5px}
.picker-results[hidden]{display:none}.picker-results button{display:flex;flex-direction:column;align-items:start;width:100%;padding:10px;text-align:left;color:#163d30;background:white;border:0;border-radius:5px}
.picker-results button:hover,.picker-results button[aria-selected=true]{background:#e8f2e9}.picker-results span{font-size:12px;color:#496458;overflow-wrap:anywhere}.picker-status:empty{display:none}
@media(max-width:650px){.list-toolbar>div,.list-toolbar .list-search{min-width:0;flex-basis:100%}.picker-results{min-width:0}.picker-input button{min-width:44px;min-height:44px}}
/* One menu at a time: content picker on the left, ordered structure on the right. */
.menu-slots{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 24px}.menu-slots a{padding:10px 16px;border:1px solid var(--line);border-radius:8px;text-decoration:none;background:white}.menu-slots a[aria-current=page]{background:var(--shell);color:white;border-color:var(--shell)}
.menu-workspace{display:grid;grid-template-columns:minmax(260px,320px) minmax(0,1fr);gap:24px;align-items:start}.menu-add{display:grid;gap:16px;position:sticky;top:20px}.menu-add .card{margin:0}.menu-add .content-picker{margin-bottom:16px}.menu-structure{min-width:0}.menu-items{list-style:none;padding:0;margin:24px 0;display:grid;gap:12px}.menu-entry{border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.menu-entry.menu-child{margin-left:32px;border-left:3px solid #8eaa99}.menu-entry summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px;cursor:pointer}.menu-entry summary::after{content:'⌄';font-size:18px}.menu-entry-title{overflow-wrap:anywhere;flex:1}.menu-entry-fields{padding:0 16px 16px}.menu-entry-fields .content-picker{margin-top:14px}.menu-entry-actions{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;border-top:1px solid var(--line);background:#f7f9f6;border-radius:0 0 9px 9px}.menu-entry-actions button{min-height:36px}.menu-entry-actions .danger-text{margin-left:auto}.menu-save{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding-top:16px;border-top:1px solid var(--line)}.menu-save [role=status]{font-size:13px;color:var(--muted)}
@media(max-width:1100px){.menu-workspace{grid-template-columns:minmax(220px,280px) minmax(0,1fr)}}
@media(max-width:850px){.menu-workspace{grid-template-columns:1fr}.menu-add{position:static;grid-template-columns:1fr 1fr}.menu-add>p{grid-column:1/-1}}
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
.form-def{min-width:0}.field-table{table-layout:fixed;width:100%}.field-table input:not([type=checkbox]),.field-table select{width:100%;min-width:0;max-width:100%}
@media(max-width:1100px){
.field-table,.field-table tbody{display:block}.field-table thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)}
.field-table tbody{display:grid;gap:16px}.field-table tr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px}
.field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left}
}
@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}}
+46 -10
View File
@@ -4,9 +4,9 @@ import { blockLabel, locate, dropList, isContainer } from './editor-model.js';
// The layer above the preview.
//
// The preview is a sandboxed iframe without scripts of its own, and every change replaces its
// whole document. Anything drawn inside it would therefore be wiped on the next keystroke and
// would also end up in the markup we are supposed to be showing faithfully. So the outline, the
// element toolbar, the insert points and the drop marker all live in the editor document, in an
// whole document. The selection tools therefore live outside it and get rebound after a refresh.
// Empty-container hit areas are rebuilt separately inside the frame by preview-containers.js.
// The outline, element toolbar, insert points and drop marker live in the editor document, in an
// absolutely positioned layer that sits exactly on top of the frame.
//
// Geometry: the layer is the same box as the frame and carries the same scale transform, so a
@@ -50,10 +50,11 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
toolbar.setAttribute('role', 'toolbar');
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
const drop = element('div', 'ov-drop');
const dropLabel = element('div', 'ov-drop-label');
const insertBefore = insertButton('before', 'Element davor einfügen');
const insertAfter = insertButton('after', 'Element dahinter einfügen');
toolbar.append(selectName);
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop);
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop, dropLabel);
stage.append(layer);
for (const tool of TOOLS) {
@@ -206,7 +207,10 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
positionToolbar(selectedBox, Boolean(sibling));
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
if (dragging) paintDrop();
if (dragging) {
dropPlan = dragPoint ? planDrop(dragPoint.x, dragPoint.y, dragging.block) : null;
paintDrop();
}
}
// -------------------------------------------------------------------------------------------
@@ -259,8 +263,9 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
const parentId = item?.parent?.id ?? null;
if (id && !ownBranch && dropList(document_, parentId, block)) {
const box = rectOf(id);
const after = box ? y > box.top + box.height / 2 : false;
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: after ? 'bottom' : 'top' } : null };
const horizontal = item.parent?.type === 'row' && indexInside(parentId, x, y).sideBySide;
const after = box ? (horizontal ? x > box.left + box.width / 2 : y > box.top + box.height / 2) : false;
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: horizontal ? (after ? 'right' : 'left') : (after ? 'bottom' : 'top') } : null };
}
if (!node) break;
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
@@ -276,11 +281,19 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
function paintDrop() {
const line = dropPlan?.line;
if (!line?.box) { drop.hidden = true; return; }
if (!line?.box) { drop.hidden = true; dropLabel.hidden = true; return; }
drop.hidden = false;
const scale = getScale();
const thickness = 3 / scale;
const { box, edge } = line;
const target = dropPlan.parentId ? doc()?.getElementById(dropPlan.parentId) : null;
const label = target?.dataset.editorLabel || (dropPlan.parentId ? blockLabel(locate(getDocument().blocks, dropPlan.parentId)?.block) : 'Seite');
dropLabel.textContent = `${edge === 'inside' ? 'Einfügen in' : 'Einfügen ·'} ${label}`;
dropLabel.hidden = false;
dropLabel.style.left = `${Math.max(0, box.left + 8)}px`;
dropLabel.style.top = `${Math.max(2, box.top + 8)}px`;
counterScale(dropLabel, 'left', 'top');
drop.style.marginLeft = drop.style.marginTop = '0';
drop.classList.toggle('ov-drop-inside', edge === 'inside');
if (edge === 'inside') {
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
@@ -317,15 +330,20 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
document_.addEventListener('dragover', event => {
if (!dragging) return;
event.preventDefault();
event.dataTransfer.dropEffect = dragging.kind === 'move' ? 'move' : 'copy';
dragPoint = { x: event.clientX, y: event.clientY };
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
event.dataTransfer.dropEffect = dropPlan ? (dragging.kind === 'move' ? 'move' : 'copy') : 'none';
paintDrop();
if (!scrollFrame) scrollFrame = requestAnimationFrame(scrollDrag);
});
document_.addEventListener('dragleave', event => {
if (!event.relatedTarget) { dragPoint = null; dropPlan = null; paintDrop(); }
});
document_.addEventListener('drop', event => {
if (!dragging) return;
event.preventDefault();
// Both are read before the clean-up clears them.
const payload = dragging, plan = dropPlan;
const payload = dragging, plan = planDrop(event.clientX, event.clientY, dragging.block);
endDrag();
if (plan) onDrop(payload, plan);
});
@@ -335,17 +353,35 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
function startDrag(payload) {
dragging = payload;
layer.classList.add('is-dragging');
doc()?.documentElement.classList.add('editor-dragging');
hovered = null;
sync();
}
function endDrag() {
dragging = null;
dragPoint = null;
cancelAnimationFrame(scrollFrame); scrollFrame = null;
dropPlan = null;
drop.hidden = true;
dropLabel.hidden = true;
layer.classList.remove('is-dragging');
doc()?.documentElement.classList.remove('editor-dragging');
sync();
}
let dragPoint = null, scrollFrame = null;
function scrollDrag() {
scrollFrame = null;
if (!dragging || !dragPoint) return;
const edge = 60 / getScale();
const amount = dragPoint.y < edge ? -14 : dragPoint.y > frame.clientHeight - edge ? 14 : 0;
if (amount) {
doc()?.defaultView?.scrollBy(0, amount / getScale());
sync();
scrollFrame = requestAnimationFrame(scrollDrag);
}
}
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
}
+5 -1
View File
@@ -124,6 +124,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
let clipboard = null;
let showAll = false, lastBlockId = null;
const openGroups = new Map();
const renderedGroups = new Map();
/** Replaces the styling of a block with a copy, or removes it when nothing is given. */
const applyStyle = (doc, id, style) => {
@@ -459,6 +460,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
/** Renders the whole panel for one block into the given container. */
return function render(panel) {
// Read the previous nodes directly; a queued toggle event can be lost during a redraw.
for (const [key, details] of renderedGroups) openGroups.set(key, details.open);
renderedGroups.clear();
const block = getBlock();
const deviceName = getDevice();
if (!block) return;
@@ -511,7 +515,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
const details = element('details', 'design-group');
const groupKey = `${block.type}:${group}`;
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing');
details.addEventListener('toggle', () => { if (details.isConnected) openGroups.set(groupKey, details.open); });
renderedGroups.set(groupKey, details);
details.append(element('summary', null, groupLabel));
const body = element('div', 'design-group-body');
+6
View File
@@ -246,6 +246,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.ov-insert:hover,.ov-insert:focus-visible{opacity:1}
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
/* While something is being dragged the outlines would only add noise. */
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
@@ -308,3 +309,8 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
@media(max-width:850px){.panel-resizer{display:none}.editor-layout{display:flex}.left-panel,.right-panel{width:50%}.canvas-scroll{padding:0}.left-panel>.panel-section:not(.structure-section){max-height:50%}}
@media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}}
.canvas-toolbar{flex-wrap:wrap;gap:8px}.canvas-toolbar #preview-motion{white-space:nowrap;min-height:36px}
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
+127 -15
View File
@@ -4,6 +4,9 @@ import { createDesignPanel } from './design-panel.js';
import { createRichText } from './rich-text.js';
import { createCanvasOverlay } from './canvas-overlay.js';
import { preview, rowPreview } from './library-previews.js';
import { decorateContainers } from './preview-containers.js';
import { initMotion } from './motion.js';
import { initSliders } from './slider.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'];
@@ -15,6 +18,7 @@ const $ = selector => document.querySelector(selector);
// same document, draft and preview endpoints, so this file does not care which one it drives.
const api = root.dataset.api;
const editingArea = 'page' !== root.dataset.kind;
const adminBase = document.querySelector('meta[name="admin-base"]').content;
const mayScript = 'true' === root.dataset.canScript;
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
@@ -24,6 +28,22 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d
let previewScale = 1, overlay = null, pendingInsert = null;
const collapsed = new Set();
let renderedProperties = null;
let revealAfterPreview = null;
let previewPlayback = false, stopPlayback = () => {};
function playPreview() {
stopPlayback(); stopPlayback = () => {};
const button = $('#preview-motion');
button.setAttribute('aria-pressed', String(previewPlayback));
button.textContent = previewPlayback ? 'Bewegung stoppen' : 'Bewegung testen';
const doc = $('#preview').contentDocument;
if (previewPlayback && doc?.body) {
const stopMotion = initMotion(doc), stopSliders = initSliders(doc);
stopPlayback = () => { stopMotion(); stopSliders(); };
}
}
$('#preview-motion').addEventListener('click', () => { previewPlayback = !previewPlayback; playPreview(); });
function notify(message = '', recover = false) {
$('#message').textContent = message;
@@ -54,7 +74,7 @@ async function request(path, body, signal) {
function controls() {
const ready = Boolean(history);
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : editingArea ? 'Bereich gespeichert' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
$('#save').disabled = !ready || busy || conflict || !history.dirty;
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
$('#undo').disabled = !ready || busy || !history.past.length;
@@ -75,6 +95,7 @@ function controls() {
$('#select-page').disabled = !ready || busy;
$('#add-root-section').disabled = !ready || busy;
$('#open-media-library').disabled = !ready || busy;
$('#preview-motion').disabled = !ready || busy;
}
function renderTree() {
@@ -114,6 +135,7 @@ function renderTree() {
function choose(id, scroll = true) {
if (busy) return;
if (id !== selected && pendingInsert) setPendingInsert(null);
selected = locate(history.document.blocks, id) ? id : null;
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
if (!selected) propertyPanel = 'content';
@@ -311,15 +333,16 @@ function renderProperties() {
}
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
if (block.type === 'image') {
renderImageWidth(block, panel);
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
field('Bildadresse', 'src', block.props.src, { help: 'Lokale Datei oder HTTPS-Adresse. Über die Mediathek wird die passende Bildvariante automatisch gewählt.' });
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
field('Originalbreite (px)', 'width', block.props.width, { number: true });
field('Originalhöhe (px)', 'height', block.props.height, { number: true });
field('Originalhöhe (px)', 'height', block.props.height, { number: true, help: 'Abmessungen der Bilddatei. Die Anzeigegröße stellst du oben unter Bildbreite ein.' });
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
}
if (block.type === 'gallery') {
@@ -375,6 +398,48 @@ function renderAdvanced(block, panel) {
}
}
function renderImageWidth(block, panel) {
const group = document.createElement('div'); group.className = 'image-width-control';
const label = document.createElement('label'); label.htmlFor = 'image-width-percent'; label.textContent = 'Bildbreite (%)';
const row = document.createElement('div'); row.className = 'image-width-inputs';
const slider = document.createElement('input'); slider.type = 'range'; slider.setAttribute('aria-label', 'Bildbreite Schieberegler');
const exact = document.createElement('input'); exact.type = 'number'; exact.id = label.htmlFor;
for (const input of [slider, exact]) { input.min = '1'; input.max = '100'; input.step = '0.1'; input.setAttribute('aria-describedby', 'image-width-help image-width-state'); }
const state = document.createElement('p'); state.id = 'image-width-state'; state.className = 'field-help';
const help = document.createElement('p'); help.id = 'image-width-help'; help.className = 'field-help';
help.textContent = 'Anteil an der verfügbaren Breite der Sektion oder Spalte. Die Originalmaße der Datei bleiben erhalten.';
const error = document.createElement('p'); error.className = 'field-help image-width-error'; error.setAttribute('role', 'alert'); error.hidden = true;
error.textContent = 'Bitte 1 bis 100 eingeben. Die bisherige Breite bleibt bis dahin erhalten.';
const reset = document.createElement('button'); reset.type = 'button'; reset.id = 'image-width-reset'; reset.className = 'text-button'; reset.textContent = 'Breite zurücksetzen';
const sync = (keepInput = false) => {
const current = locate(history.document.blocks, block.id)?.block;
const { value, source } = effectiveStyle(current, device, 'width');
const percent = typeof value === 'string' && /^\d+(?:\.\d+)?%$/.test(value) ? parseFloat(value) : null;
if (!keepInput) exact.value = percent ?? '';
exact.placeholder = value && percent === null ? value : '100';
slider.value = percent ?? 100;
slider.setAttribute('aria-valuetext', percent !== null ? `${percent} Prozent` : `Aktuell ${value || 'automatisch'}. Prozentwert wählen.`);
state.textContent = `${devices[device]} · ${source === device ? 'Eigener Wert' : source ? `Von ${devices[source]} übernommen` : 'Automatische Breite'}${value ? ` (${value})` : ''}`;
const maximum = effectiveStyle(current, device, 'maxWidth').value;
if (maximum && maximum !== 'none') state.textContent += `. Maximale Breite: ${maximum} (Design → Größe).`;
};
const apply = (value, keepInput = false) => {
error.hidden = true; exact.removeAttribute('aria-invalid');
history.change(doc => setStyle(doc, block.id, device, 'width', value), `image-width:${block.id}:${device}`);
controls(); schedulePreview(); sync(keepInput);
};
slider.addEventListener('input', () => apply(`${slider.value}%`));
exact.addEventListener('input', () => {
if (exact.validity.badInput || (exact.value !== '' && !exact.checkValidity())) { error.hidden = false; exact.setAttribute('aria-invalid', 'true'); return; }
apply(exact.value === '' ? '' : `${Number(exact.value)}%`, true);
});
// A whole drag or keyboard edit is one undo step, independent of preview requests.
slider.addEventListener('change', () => { history.mergeKey = null; });
exact.addEventListener('blur', () => { history.mergeKey = null; if (error.hidden) sync(); });
reset.addEventListener('click', () => { history.mergeKey = null; apply(''); history.mergeKey = null; });
row.append(slider, exact); group.append(label, row, state, help, error, reset); panel.append(group); sync();
}
function renderDesign(block, panel) {
if (designSchema && designPanel) {
designPanel(panel);
@@ -437,7 +502,7 @@ function renderLegacyEffects(block, panel) {
none: 'Keiner', lift: 'Leicht anheben', sink: 'Leicht absenken', zoom: 'Bild vergrößern',
glow: 'Leuchten', tilt: 'Leicht kippen', 'grow-border': 'Rahmen einfahren',
}],
['entrance', 'Einblendung beim Laden', {
['entrance', 'Einblendung beim Erscheinen', {
none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten', sink: 'Von oben', left: 'Von links',
right: 'Von rechts', zoom: 'Heranzoomen', flip: 'Kippen', blur: 'Aus der Unschärfe',
}],
@@ -450,7 +515,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 = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder. Einblendungen laufen beim Laden der Seite, nicht beim Scrollen: eine öffentliche Seite führt kein JavaScript aus.'; panel.append(note);
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);
}
function chooseMedia(multiple) {
@@ -595,7 +660,13 @@ async function updatePreview() {
const doc = frame.contentDocument;
if (!doc || doc === altesDokument || !doc.getElementById('kant-styles')) return;
angewandt = true;
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = `/assets/preview.css?v=${new URL(import.meta.url).searchParams.get('v') ?? 'dev'}`;
// Register before insertion: cached loads (or errors) can precede DOMContentLoaded.
const styleReady = new Promise(resolve => {
style.addEventListener('load', () => resolve(true), { once: true });
style.addEventListener('error', () => resolve(false), { once: true });
});
doc.head.append(style);
// The preview delivers the generated stylesheet as inert template text (see page.html.twig).
// Applying it as a constructed stylesheet is not a source in the sense of the policy, so the
// preview shows exactly what the published page shows, without a single blocked style.
@@ -607,6 +678,22 @@ async function updatePreview() {
}
doc.addEventListener('click', event => {
event.preventDefault();
const insert = event.target.closest('[data-editor-target]');
if (insert && !busy && history) {
stopInlineEdit();
const id = insert.dataset.editorTarget;
if (!locate(history.document.blocks, id)) return;
choose(id, false);
if (insert.hasAttribute('data-editor-row')) {
setPendingInsert(null);
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
propertyPanel = 'content'; changed();
} else if (locate(history.document.blocks, id).block.type === 'row') {
selected = history.change(content => insertBlockAt(content, 'column', id, 0));
changed();
} else setPendingInsert({ parentId: id, index: 0 });
return;
}
if (inlineEdit) return;
const element = event.target.closest('[id]');
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
@@ -618,15 +705,28 @@ async function updatePreview() {
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
});
doc.addEventListener('submit', event => event.preventDefault());
// Every refresh is a new document, so the layer above it has to bind to it again.
overlay?.attach();
frame.contentWindow.scrollTo(0, top); highlight();
// Erst jetzt ansehen lassen: bis hierher zeigte das Fenster eine Seite ohne Gestaltung.
frame.style.visibility = '';
$('#preview-status').textContent = 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.';
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
const finish = () => {
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
decorateContainers(doc, history.document);
overlay?.attach();
styleReady.then(loaded => {
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
frame.contentWindow.scrollTo(0, top);
highlight(revealAfterPreview === selected); revealAfterPreview = null;
markInsertTarget();
frame.style.visibility = '';
playPreview();
$('#preview-status').textContent = loaded
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
});
};
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', finish, { once: true }); else finish();
};
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
frame.style.visibility = 'hidden';
stopPlayback();
frame.srcdoc = html;
// Sobald der Kopf da ist, wird gestaltet - geprüft Bild für Bild statt auf ein Ereignis zu
// warten, das es für srcdoc nicht zuverlässig gibt.
@@ -654,12 +754,13 @@ async function updatePreview() {
function changed() {
if (!locate(history.document.blocks, selected)) selected = null;
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
renderTree(); renderProperties(); controls(); schedulePreview();
}
async function loadDesignSchema() {
try {
const response = await fetch('/admin/api/design', { credentials: 'same-origin' });
const response = await fetch(adminBase + '/api/design', { credentials: 'same-origin' });
if (!response.ok) return;
designSchema = await response.json();
designPanel = createDesignPanel({
@@ -727,12 +828,23 @@ function setPendingInsert(target) {
updateLibrary();
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
$('#insert-hint').hidden = !target;
markInsertTarget();
if (target) {
if ($('#toolbox').hidden) $('#toggle-library').click();
$('#toolbox').scrollIntoView({ block: 'nearest' });
$('#library-search').value = ''; filterLibrary();
const name = target.parentId ? $('#preview').contentDocument?.getElementById(target.parentId)?.dataset.editorLabel || blockLabel(locate(history.document.blocks, target.parentId)?.block) : 'Seite';
$('#insert-hint').firstChild.textContent = `Einfügen in ${name}. Wähle jetzt ein Element. `;
$('#library-search').focus({ preventScroll: true });
$('#library-search').scrollIntoView({ block: 'nearest' });
}
}
function markInsertTarget() {
const doc = $('#preview').contentDocument;
doc?.querySelectorAll('.editor-insert-target').forEach(node => node.classList.remove('editor-insert-target'));
if (pendingInsert?.parentId) doc?.getElementById(pendingInsert.parentId)?.classList.add('editor-insert-target');
}
// ---------------------------------------------------------------------------------------------
// The toolbox.
//
@@ -976,7 +1088,7 @@ $('#confirm-preset').addEventListener('click', async () => {
const name = $('#preset-name').value.trim();
const error = $('#preset-error');
try {
const response = await fetch('/admin/api/style-presets', {
const response = await fetch(adminBase + '/api/style-presets', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': designSchema.csrfToken },
body: JSON.stringify({ name, style: pendingPreset }),
+5 -66
View File
@@ -1,26 +1,11 @@
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}}
/* Entrance animations and hover effects, added 12.09.2026.
Every animation stays inside the reduced-motion guard: whoever asked their system for less
movement gets the finished state, not a softened animation. They play on load, because a public
page runs no script and there is nothing to observe a scroll position with - that is a
limitation, not an oversight, and it is written in the editor. */
/* Hover effects remain CSS; entrance effects are observed by motion.js. */
@media (prefers-reduced-motion: no-preference) {
.fx-entrance-sink { animation: kant-sink .55s ease-out backwards }
.fx-entrance-left { animation: kant-left .55s ease-out backwards }
.fx-entrance-right { animation: kant-right .55s ease-out backwards }
.fx-entrance-zoom { animation: kant-zoom .55s ease-out backwards }
.fx-entrance-flip { animation: kant-flip .7s ease-out backwards }
.fx-entrance-blur { animation: kant-unblur .6s ease-out backwards }
.fx-delay-short { animation-delay: .15s }
.fx-delay-medium { animation-delay: .3s }
.fx-delay-long { animation-delay: .6s }
.fx-hover-sink { transition: transform .2s ease }
.fx-hover-sink:hover { transform: translateY(3px) }
.fx-hover-glow { transition: box-shadow .2s ease }
@@ -30,17 +15,11 @@
.fx-hover-grow-border { transition: box-shadow .2s ease }
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
}
@keyframes kant-sink { from { opacity: 0; transform: translateY(-18px) } to { opacity: 1; transform: none } }
@keyframes kant-left { from { opacity: 0; transform: translateX(-24px) } to { opacity: 1; transform: none } }
@keyframes kant-right { from { opacity: 0; transform: translateX(24px) } to { opacity: 1; transform: none } }
@keyframes kant-zoom { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: none } }
@keyframes kant-flip { from { opacity: 0; transform: perspective(800px) rotateX(-12deg) } to { opacity: 1; transform: none } }
@keyframes kant-unblur { from { opacity: 0; filter: blur(8px) } to { opacity: 1; filter: none } }
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
/* Beitragskarten und KnÃpfe, ergänzt am 13.09.2026.
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
@media (prefers-reduced-motion: no-preference) {
@@ -66,43 +45,3 @@
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
.button:hover { transform: translateY(-2px) }
}
/* Einblenden beim Hineinscrollen, ergänzt am 13.09.2026.
Bisher liefen die Einblendungen beim Laden der Seite - was unterhalb der Falz stand, war
fertig animiert, bevor es jemand gesehen hat. Mit einer Scroll-Zeitachse läuft dieselbe
Animation genau dann, wenn das Element in den Blick kommt, und zwar ohne eine Zeile
JavaScript: `animation-timeline: view()` bindet den Ablauf an die Sichtbarkeit.
Drei Bedingungen, alle bewusst:
- Hinter der Bewegungsschranke: wer weniger Bewegung eingestellt hat, bekommt keine.
- Hinter `@supports`: Browser ohne Scroll-Zeitachse (heute Firefox und Safari) behalten die
bisherige Fassung - die Animation läuft beim Laden. Niemand sieht deshalb weniger Inhalt.
- `animation-range` endet früh (`cover 26%`): die Bewegung ist vorbei, bevor das Element in
der Mitte des Bildschirms steht. Eine Einblendung, die beim Lesen noch läuft, stört. */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
/* Eigene Kurven für den Scrollweg: **nur Bewegung, keine Deckkraft**.
Eine Scroll-Zeitachse ist umkehrbar - ein Abschnitt außerhalb des Blicks steht wieder am
Anfang. Alles unterhalb der Falz wäre also dauerhaft blass, und blasser Text ist nicht
weniger Effekt, sondern schlechterer Kontrast: #3d4d5c bei 85 Prozent ergibt 3,86:1
statt 7,1:1 - axe meldet das zu Recht. Der Weg trägt den Effekt allein. */
@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }
@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }
.fx-entrance-fade, .fx-entrance-rise, .fx-entrance-sink, .fx-entrance-left,
.fx-entrance-right, .fx-entrance-zoom, .fx-entrance-flip, .fx-entrance-blur {
animation-name: kant-scroll-rise;
animation-timeline: view();
animation-range: entry 10% cover 28%;
animation-duration: 1ms; /* Die Zeitachse bestimmt den Ablauf, nicht die Uhr. */
animation-fill-mode: both;
}
.fx-entrance-fade { animation-name: kant-scroll-fade }
/* Die Verzögerungsstufen sind auf einer Scroll-Zeitachse eine Verschiebung im Weg,
nicht in der Zeit - sonst würden sie gar nicht wirken. */
.fx-delay-short { animation-range: entry 16% cover 32% }
.fx-delay-medium { animation-range: entry 22% cover 36% }
.fx-delay-long { animation-range: entry 28% cover 40% }
}
}
+94
View File
@@ -0,0 +1,94 @@
import { setupPicker } from './page-picker.js';
const form = document.querySelector('[data-live-search]');
if (form) {
const results = document.querySelector('#list-results');
const status = document.querySelector('#search-status');
const error = document.querySelector('#search-error');
const search = form.querySelector('input[type="search"]');
const fields = [...form.querySelectorAll('input[name],select[name]')];
let timer, controller, sequence = 0, composing = false;
const cancel = () => { clearTimeout(timer); controller?.abort(); sequence++; results.removeAttribute('aria-busy'); };
const updateReset = () => {
form.querySelector('[data-search-reset]').hidden = !fields.some(field => field.value !== (field.dataset.searchDefault || ''));
};
const urlFor = parameters => {
const url = new URL(form.action);
for (const [name, value] of Object.entries(parameters)) {
const fallback = fields.find(field => field.name === name)?.dataset.searchDefault || (name === 'seite' ? '1' : '');
if (String(value) !== fallback) url.searchParams.set(name, String(value));
}
return url;
};
const currentUrl = () => urlFor({ ...Object.fromEntries(new FormData(form)), q: search.value.trim(), seite: 1 });
const restoreFilters = url => {
for (const field of fields) field.value = url.searchParams.get(field.name) ?? field.dataset.searchDefault ?? '';
updateReset();
};
async function load(url, navigation = 'replace', focusResults = false) {
cancel(); const request = sequence;
controller = new AbortController(); const signal = controller.signal;
results.setAttribute('aria-busy', 'true'); error.hidden = true;
status.textContent = 'Ergebnisse werden gesucht …';
updateReset();
try {
const response = await fetch(url, { signal, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' } });
if (response.redirected || response.status === 401 || response.status === 403) throw new Error('Deine Sitzung ist abgelaufen. Bitte erneut anmelden und die Suche wiederholen.');
if (!response.ok) throw new Error('Die Suche konnte nicht geladen werden. Die bisherigen Ergebnisse bleiben sichtbar.');
const data = await response.json();
if (signal.aborted || request !== sequence) return;
if (typeof data.html !== 'string' || !data.parameters || !Number.isInteger(data.total)) throw new Error('Die Suchantwort konnte nicht verarbeitet werden.');
const parsed = new DOMParser().parseFromString(data.html, 'text/html');
results.replaceChildren(...parsed.body.childNodes);
results.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
// Keep create/upload forms outside the replaced list on the same filtered screen.
const context = { _list_query: data.parameters.q, _list_status: data.parameters.status, _list_limit: data.parameters.limit, _list_page: data.parameters.seite, q: data.parameters.q, archiv: data.parameters.archiv, seite: data.parameters.seite };
for (const [name, value] of Object.entries(context)) if (value !== undefined) {
document.querySelectorAll(`form:not([data-live-search]) input[type="hidden"][name="${name}"]`).forEach(input => { input.value = value; });
}
const canonical = urlFor(data.parameters);
if (navigation !== 'none' && canonical.href !== location.href) history[navigation === 'push' ? 'pushState' : 'replaceState'](null, '', canonical);
status.textContent = `${data.total} Treffer${data.parameters.q ? ` für „${data.parameters.q}“` : ''}.`;
if (focusResults) { results.focus({ preventScroll: true }); results.scrollIntoView({ block: 'start' }); }
} catch (failure) {
if (signal.aborted || request !== sequence) return;
error.querySelector('[role="alert"]').textContent = failure instanceof TypeError ? 'Verbindung unterbrochen. Die bisherigen Ergebnisse bleiben sichtbar.' : failure.message;
error.hidden = false; status.textContent = 'Suche fehlgeschlagen.';
} finally {
if (request === sequence) results.removeAttribute('aria-busy');
}
}
const queue = () => {
cancel(); updateReset();
if (composing) return;
error.hidden = true; status.textContent = 'Ergebnisse werden gesucht …'; results.setAttribute('aria-busy', 'true');
timer = setTimeout(() => load(currentUrl()), search.value ? 220 : 0);
};
search.addEventListener('compositionstart', () => { composing = true; cancel(); });
search.addEventListener('compositionend', () => { composing = false; queue(); });
search.addEventListener('input', queue);
form.addEventListener('change', event => { if (event.target.matches('select')) load(currentUrl(), 'push'); });
form.addEventListener('submit', event => { event.preventDefault(); if (!composing) load(currentUrl()); });
form.querySelector('[data-search-reset]').addEventListener('click', event => {
event.preventDefault(); const url = new URL(form.action); restoreFilters(url); load(url, 'push'); search.focus();
});
results.addEventListener('click', event => {
const link = event.target.closest('.pager a');
if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault(); load(new URL(link.href), 'push', true);
});
// A delayed response must not overwrite a form the user has just started editing.
results.addEventListener('input', () => {
if (results.getAttribute('aria-busy') === 'true') { cancel(); status.textContent = 'Suche angehalten, während du einen Eintrag bearbeitest.'; }
});
window.addEventListener('popstate', () => { const url = new URL(location.href); restoreFilters(url); load(url, 'none'); });
window.addEventListener('pagehide', cancel);
document.querySelector('#search-retry').addEventListener('click', () => load(currentUrl()));
form.classList.add('live-search-ready');
status.textContent = 'Die Suche aktualisiert sich beim Tippen.';
search.setAttribute('aria-controls', results.id);
updateReset();
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function openMedia({ multiple = false, onChoose } = {}) {
$('#media-insert').disabled = state || !selected.size;
};
async function api(path = '', body) {
const response = await fetch('/admin/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
const response = await fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
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 || `Medienanfrage fehlgeschlagen (${response.status}).`);
+154
View File
@@ -0,0 +1,154 @@
import { setupPicker } from './page-picker.js';
const form = document.querySelector('#menu-editor');
const list = form.querySelector('#menu-items');
const status = form.querySelector('#menu-status');
const error = form.querySelector('#menu-error');
const history = [];
let serial = list.children.length, pendingPage = null, busy = false;
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
const rows = () => [...list.children].map(row => {
const data = { level: Number(row.dataset.level) };
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.value; });
data.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
return data;
});
const state = () => ({ name: form.elements.name.value, rows: rows() });
const key = value => JSON.stringify(value);
let last = state(), saved = key(last);
const dirty = () => key(state()) !== saved;
function refresh() {
[...list.children].forEach((row, index) => {
row.classList.toggle('menu-child', row.dataset.level === '2');
row.querySelectorAll('[data-key]').forEach(input => { input.name = `items[${index}][${input.dataset.key}]`; });
row.querySelector('[data-key="position"]').value = index;
row.querySelector('[data-key="level"]').value = row.dataset.level;
const picker = row.querySelector('.picker-value'); if (picker) picker.name = `items[${index}][pageId]`;
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
row.querySelector('.menu-entry-title').textContent = name;
row.querySelector('[data-menu-action="up"]').disabled = index === 0;
row.querySelector('[data-menu-action="down"]').disabled = index === list.children.length - 1;
row.querySelector('[data-menu-action="indent"]').disabled = index === 0 || row.dataset.level === '2' || list.children[index + 1]?.dataset.level === '2';
row.querySelector('[data-menu-action="outdent"]').disabled = row.dataset.level === '1';
row.querySelector('[role="group"]').setAttribute('aria-label', `${name} verschieben oder entfernen`);
row.querySelectorAll('button').forEach(button => { button.disabled ||= busy; });
row.querySelector('[data-menu-action="up"]').setAttribute('aria-label', `${name} nach oben`);
row.querySelector('[data-menu-action="down"]').setAttribute('aria-label', `${name} nach unten`);
});
form.querySelector('#menu-count').textContent = `${list.children.length} Einträge`;
form.querySelector('#menu-empty').hidden = Boolean(list.children.length);
form.querySelector('#menu-undo').disabled = !history.length || busy;
status.textContent = busy ? 'Wird gespeichert …' : dirty() ? 'Ungespeicherte Änderungen' : 'Gespeicherter Stand';
}
function createRow(data) {
const id = `new-${serial++}`;
const row = el('li', 'menu-entry'); row.dataset.level = String(data.level ?? 1);
const field = (key, value, label, type = 'text') => {
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
input.id = `menu-${key}-${id}`;
if (type !== 'hidden') { const caption = el('label', '', label); caption.htmlFor = input.id; body.append(caption, input); }
else row.append(input);
return input;
};
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
const details = el('details'); const summary = el('summary');
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'muted', data.type === 'page' ? 'Inhalt' : 'Eigener Link'));
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(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;
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
picker.querySelector('label').htmlFor = input.id; picker.querySelector('label').textContent = 'Verknüpfter Inhalt';
const options = picker.querySelector('[role="listbox"]'); options.id = `${input.id}-results`; options.replaceChildren(); options.hidden = true;
input.setAttribute('aria-controls', options.id); input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant');
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; }
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
}
row.append(actions); return row;
}
function restore(value) {
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
}
function commit(before) {
refresh(); const after = state();
if (key(before) !== key(after)) { history.push(before); if (history.length > 50) history.shift(); }
last = after; refresh();
}
function add(data) {
if (list.children.length >= 60) { error.textContent = 'Ein Menü fasst höchstens 60 Einträge.'; error.hidden = false; return; }
const before = state(); const row = createRow(data); list.append(row); commit(before);
row.scrollIntoView({ block: 'nearest' }); row.querySelector('summary').focus();
}
const addPicker = form.querySelector('.menu-add .content-picker');
addPicker.querySelector('.picker-value').addEventListener('change', () => {
const id = addPicker.querySelector('.picker-value').value;
pendingPage = id ? { id, display: addPicker.querySelector('[role="combobox"]').value } : null;
form.querySelector('#menu-add-page').disabled = !pendingPage;
});
form.querySelector('#menu-add-page').addEventListener('click', () => {
if (pendingPage) add({ type: 'page', label: pendingPage.display.replace(/ \(\/.*\)$/, ''), pageId: pendingPage.id, pageDisplay: pendingPage.display, level: 1 });
});
form.querySelector('#menu-add-url').addEventListener('click', () => {
const name = form.querySelector('#link-label'), url = form.querySelector('#link-url');
if (!name.value.trim() || !url.value.trim()) { error.hidden = false; error.textContent = 'Bitte Beschriftung und Adresse des Links ausfüllen.'; (!name.value.trim() ? name : url).focus(); return; }
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), level: 1 }); name.value = url.value = '';
});
form.addEventListener('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
list.addEventListener('click', event => {
const button = event.target.closest('[data-menu-action]'); if (!button || busy) return;
const row = button.closest('.menu-entry'), before = state(), action = button.dataset.menuAction;
const entries = [...list.children], index = entries.indexOf(row);
const bundle = [row];
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
if (action === 'remove') {
row.remove();
// Removing a heading never deletes its links: promote its children visibly.
bundle.slice(1).forEach(child => { child.dataset.level = '1'; });
}
if (action === 'indent') row.dataset.level = '2';
if (action === 'outdent') row.dataset.level = '1';
if (action === 'up' && index > 0) {
let target = index - 1;
if (row.dataset.level === '1') while (target > 0 && entries[target].dataset.level === '2') target--;
bundle.forEach(item => list.insertBefore(item, entries[target]));
}
if (action === 'down') {
let target = index + bundle.length;
if (entries[target]) {
target++;
if (row.dataset.level === '1') while (entries[target]?.dataset.level === '2') target++;
bundle.forEach(item => list.insertBefore(item, entries[target] || null));
}
}
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
commit(before); if (row.isConnected) row.querySelector('summary').focus();
});
form.querySelector('#menu-undo').addEventListener('click', () => { const previous = history.pop(); if (previous) restore(previous); });
window.addEventListener('beforeunload', event => { if (dirty()) { event.preventDefault(); event.returnValue = ''; } });
form.noValidate = true;
form.addEventListener('submit', async event => {
event.preventDefault(); if (busy) return;
const invalid = form.querySelector(':invalid');
if (invalid) { const details = invalid.closest('details'); if (details) details.open = true; invalid.reportValidity(); return; }
refresh(); const body = new FormData(form); const snapshot = key(state());
busy = true; error.hidden = true; form.querySelectorAll('input,button').forEach(node => { node.disabled = true; }); refresh();
try {
const response = await fetch(form.action, { method: 'POST', body, headers: { Accept: 'application/json' }, credentials: 'same-origin' });
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Die Änderungen bleiben hier erhalten. Bitte erneut anmelden.');
const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Das Menü konnte nicht gespeichert werden.');
form.elements.expectedVersion.value = result.version; saved = snapshot;
} catch (failure) { error.textContent = failure.message || 'Verbindung unterbrochen. Die Änderungen bleiben hier erhalten.'; error.hidden = false; }
finally { busy = false; form.querySelectorAll('input,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); }
});
refresh(); last = state(); saved = key(last);
+35
View File
@@ -0,0 +1,35 @@
/** Visible content is the baseline. Enhance only when it enters the viewport. */
export function initMotion(doc = document) {
const view = doc.defaultView;
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
const animations = new Set();
const seen = new WeakSet();
const start = {
fade: { opacity: .2 }, rise: { transform: 'translateY(28px)' }, sink: { transform: 'translateY(-28px)' },
left: { transform: 'translateX(-36px)' }, right: { transform: 'translateX(36px)' },
zoom: { transform: 'scale(.92)' }, flip: { transform: 'perspective(800px) rotateX(-16deg)' }, blur: { filter: 'blur(6px)' },
};
const nodes = [...doc.querySelectorAll('[class*="fx-entrance-"]')].filter(node => Object.keys(start).some(name => node.classList.contains(`fx-entrance-${name}`)));
const show = node => {
if (seen.has(node) || preference.matches) return;
seen.add(node);
const name = Object.keys(start).find(name => node.classList.contains(`fx-entrance-${name}`));
const delay = node.classList.contains('fx-delay-long') ? 600 : node.classList.contains('fx-delay-medium') ? 300 : node.classList.contains('fx-delay-short') ? 150 : 0;
// Fade itself is optional; direction, zoom and flip keep text at full contrast.
const style = view.getComputedStyle(node);
const finish = { opacity: style.opacity, transform: style.transform, filter: style.filter };
const first = { ...start[name] };
if (first.transform && finish.transform !== 'none') first.transform += ' ' + finish.transform;
const animation = node.animate([first, finish], { duration: 650, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'none' });
animations.add(animation); animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
};
const observer = new view.IntersectionObserver(entries => {
for (const entry of entries) if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); }
}, { threshold: 0, rootMargin: '0px 0px -24px 0px' });
if (!preference.matches) nodes.forEach(node => observer.observe(node));
const reduce = () => { if (preference.matches) { observer.disconnect(); animations.forEach(animation => animation.cancel()); } };
preference.addEventListener('change', reduce);
return () => { observer.disconnect(); animations.forEach(animation => animation.cancel()); preference.removeEventListener('change', reduce); };
}
if (!document.querySelector('#editor')) initMotion();
+67
View File
@@ -0,0 +1,67 @@
export function setupPicker(picker, onSelect = () => {}) {
if (picker.dataset.ready) return;
picker.dataset.ready = 'true';
const input = picker.querySelector('[role="combobox"]');
const value = picker.querySelector('.picker-value');
const results = picker.querySelector('[role="listbox"]');
const status = picker.querySelector('.picker-status');
let controller, timer, current = -1, items = [];
const close = () => {
results.hidden = true; input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant'); current = -1;
};
const select = item => {
value.value = item?.id ?? '';
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
};
const activate = index => {
current = Math.max(0, Math.min(items.length - 1, index));
[...results.children].forEach((node, i) => node.setAttribute('aria-selected', String(i === current)));
const option = results.children[current];
if (option) { input.setAttribute('aria-activedescendant', option.id); option.scrollIntoView({ block: 'nearest' }); }
};
const search = async query => {
controller?.abort(); controller = new AbortController();
const signal = controller.signal;
const url = new URL(picker.dataset.source, location.href);
url.searchParams.set('q', query); url.searchParams.set('type', picker.dataset.type || '');
url.searchParams.set('parents', picker.dataset.parents || '0');
status.textContent = 'Wird gesucht …';
try {
const response = await fetch(url, { signal, credentials: 'same-origin' });
if (!response.ok || response.redirected) throw new Error();
const data = await response.json();
if (signal.aborted) return;
items = data.items; current = -1; results.replaceChildren();
items.forEach((item, index) => {
const option = document.createElement('button'); option.type = 'button'; option.tabIndex = -1;
option.id = `${input.id}-option-${index}`; option.setAttribute('role', 'option'); option.setAttribute('aria-selected', 'false');
const title = document.createElement('strong'); title.textContent = item.title;
const detail = document.createElement('span'); detail.textContent = `/${item.path === 'home' ? '' : item.path} · ${item.type === 'post' ? 'Beitrag' : 'Seite'}${item.published ? '' : ' · Entwurf'}`;
option.append(title, detail); option.addEventListener('mousedown', event => event.preventDefault());
option.addEventListener('click', () => { select(item); input.focus({ preventScroll: true }); close(); }); results.append(option);
});
results.hidden = !items.length; input.setAttribute('aria-expanded', String(Boolean(items.length)));
status.textContent = items.length ? `${items.length} Treffer${items.length === 20 ? ' · Suche eingrenzen für weitere Ergebnisse' : ''}.` : 'Keine passenden Inhalte gefunden.';
} catch (error) {
if (error.name !== 'AbortError') { close(); status.textContent = 'Inhalte konnten nicht geladen werden. Bitte erneut suchen oder neu anmelden.'; }
}
};
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.' : '');
value.dispatchEvent(new Event('change', { bubbles: true }));
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
});
input.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); controller?.abort(); clearTimeout(timer); close(); }
if (['ArrowDown', 'ArrowUp'].includes(event.key) && !results.hidden) { event.preventDefault(); activate(current + (event.key === 'ArrowDown' ? 1 : -1)); }
if (event.key === 'Enter' && current >= 0 && !results.hidden) { event.preventDefault(); select(items[current]); }
});
picker.addEventListener('focusout', event => { if (!picker.contains(event.relatedTarget)) { controller?.abort(); clearTimeout(timer); close(); } });
picker.querySelector('.picker-clear').addEventListener('click', () => { controller?.abort(); clearTimeout(timer); select(null); input.focus(); });
}
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
+45
View File
@@ -0,0 +1,45 @@
import { blockLabel } from './editor-model.js';
/** Editing aids are created only in the preview DOM, never in a saved block or public HTML. */
export function decorateContainers(preview, content) {
const visit = (blocks, parent = null) => {
blocks.forEach((block, index) => {
const node = preview.getElementById(block.id);
if (node && ['section', 'row', 'column'].includes(block.type)) {
const label = block.type === 'column' && parent?.type === 'row'
? `Spalte ${index + 1} von ${blocks.length}` : blockLabel(block);
node.classList.add('editor-container');
node.dataset.editorLabel = label;
if (!block.children?.length) {
node.classList.add('editor-empty-container');
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner') : node;
if (!host) return;
const empty = preview.createElement('div');
empty.className = 'editor-empty';
const name = preview.createElement('span');
name.className = 'editor-empty-name'; name.textContent = label;
const hint = preview.createElement('span');
hint.className = 'editor-empty-hint';
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen' : 'Element hierher ziehen oder hinzufügen';
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
const add = (text, attribute, value, accessibleName) => {
const button = preview.createElement('button');
button.type = 'button'; button.textContent = text;
button.dataset.editorTarget = block.id;
button.setAttribute(attribute, value);
button.setAttribute('aria-label', accessibleName);
actions.append(button);
};
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
if (block.type === 'section') {
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
}
empty.append(name, hint, actions);
host.append(empty);
}
}
visit(block.children ?? [], block);
});
};
visit(content.blocks);
}
+14
View File
@@ -7,3 +7,17 @@
/* Inline editing. Only ever active in the editor preview, never on a published page. */
[contenteditable].editor-inline{outline:3px solid #b55b12;outline-offset:4px;border-radius:3px;cursor:text;background:#fffdf7}
/* Empty layout areas must have a real hit area, including when sibling columns have content. */
.editor-empty-container{min-height:130px!important}
.editor-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;min-height:130px;width:100%;padding:22px 14px;border:2px dashed #93aa9a;border-radius:7px;background:#f3f8f4;color:#264f3d;text-align:center;font:14px/1.5 system-ui,sans-serif;box-sizing:border-box;grid-column:1/-1}
.editor-empty-name{font-size:12px;font-weight:700;letter-spacing:.04em}
.editor-empty-hint{font-size:13px;color:#496351}
.editor-empty-actions{display:flex;justify-content:center;flex-wrap:wrap;gap:8px}
.editor-empty button{appearance:none;font:600 13px/1.3 system-ui,sans-serif;color:#264f3d;background:white;border:1px solid #94ad9d;border-radius:6px;min-height:40px;padding:9px 13px;cursor:pointer;letter-spacing:normal;text-transform:none;box-shadow:none;margin:0}
.editor-empty button:hover,.editor-empty button:focus-visible{background:#deeee2;border-color:#32644a;outline:2px solid #32644a;outline-offset:2px}
.editor-empty button[data-editor-insert]{background:#2c634a;color:white;border-color:#2c634a}
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
+6 -1
View File
@@ -197,7 +197,12 @@ body.consent-open{padding-bottom:260px}
.slider-snap>.slider-slide{scroll-snap-align:start}
.slider-slide{min-width:0}
.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */
.slider-dots{scroll-marker-group:after;scroll-behavior:smooth;position:relative;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots::-webkit-scrollbar{display:none}.slider-dots>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}
.slider-dots{position:relative}
@supports selector(::scroll-marker){.slider-dots:not(.slider-controlled){scroll-marker-group:after;scroll-behavior:smooth;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots:not(.slider-controlled)::-webkit-scrollbar{display:none}.slider-dots:not(.slider-controlled)>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}}
.slider-controlled{scrollbar-width:none;scrollbar-gutter:auto}.slider-controlled::-webkit-scrollbar{display:none}
.slider-controls{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px;min-height:48px;margin-top:8px;padding:4px;font:14px/1.4 system-ui,sans-serif}.slider-controls button{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:40px;padding:8px 12px;border:1px solid #b2c2b8;border-radius:7px;background:#fff;color:#193c2d;cursor:pointer;font:inherit}.slider-controls button:focus-visible{outline:3px solid #b55b12;outline-offset:3px}.slider-controls button:hover{background:#e4efe7}.slider-controls button:disabled{opacity:.6;cursor:default}.slider-position{font-variant-numeric:tabular-nums;min-width:44px;text-align:center}.slider-control-dots{display:flex;flex-wrap:wrap;gap:3px}.slider-control-dots button{min-width:30px;padding:5px;border:0;background:transparent;color:inherit;opacity:.5}.slider-control-dots button[aria-pressed=true]{opacity:1;outline:1px solid currentColor;outline-offset:-5px}.slider-controls[hidden]{display:none}
@media(max-width:650px){.slider-controls button{min-height:44px;min-width:44px}}
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
.slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)}
+78 -64
View File
@@ -1,67 +1,81 @@
/**
* Lets a slider advance by itself - and stop as soon as somebody takes over.
*
* Without this script a slider is a scroll container with snap points and, where the browser can
* draw them, dots: complete, swipeable and keyboard operable. The script only adds the automatic
* step, and it is loaded exclusively on pages that carry a slider which asked for it.
*
* What it refuses to do:
* - it does not run when the system asks for reduced motion,
* - it stops for good at the first touch, click, key press or manual scroll,
* - it pauses while the pointer is inside, while something in it has focus, and while the tab is
* in the background or the slider is off screen.
*
* A carousel that keeps moving while somebody reads it is a nuisance, and one that cannot be
* stopped is a barrier.
*/
(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const setup = slider => {
const seconds = Number(slider.dataset.autoplay);
if (!Number.isFinite(seconds) || seconds < 3) return;
let paused = false;
let stopped = false;
let visible = true;
let ownScroll = 0;
const slides = [...slider.children];
if (slides.length < 2) return;
const step = () => {
if (stopped || paused || !visible || document.hidden) return;
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
// Measured, not computed from offsetLeft: whether a slide's offset parent is the slider or
// something further up decides the number, and getting that wrong means scrolling nowhere.
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
const next = last ? slides[0] : slides.find(slide => left(slide) > slider.scrollLeft + 2);
if (!next) return;
// Remember that the next scroll event is ours, so it does not look like a person.
ownScroll = Date.now();
slider.scrollTo({ left: last ? 0 : left(next), behavior: 'smooth' });
/** Scroll snapping is the baseline; arrows, dots and controllable playback enhance it. */
export function initSliders(doc = document) {
const view = doc.defaultView;
const cleanups = [];
doc.querySelectorAll('.block-slider').forEach(slider => {
const slides = [...slider.querySelectorAll(':scope > .slider-slide')];
if (slides.length < 2 || slider.dataset.enhanced) return;
slider.dataset.enhanced = 'true';
const events = new view.AbortController(), signal = events.signal;
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
const seconds = Number(slider.dataset.autoplay || 0);
let playing = seconds >= 3 && !preference.matches, hover = false, visible = false, timer;
const controls = doc.createElement('div'); controls.className = 'slider-controls';
controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen');
const button = (text, label, action) => {
const node = doc.createElement('button'); node.type = 'button'; node.textContent = text;
node.setAttribute('aria-label', label); node.setAttribute('aria-controls', slider.id);
node.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); action(); }, { signal });
controls.append(node); return node;
};
const stop = () => { stopped = true; clearInterval(timer); };
const timer = setInterval(step, seconds * 1000);
slider.addEventListener('pointerenter', () => { paused = true; });
slider.addEventListener('pointerleave', () => { paused = false; });
slider.addEventListener('focusin', () => { paused = true; });
slider.addEventListener('focusout', () => { paused = false; });
for (const event of ['pointerdown', 'keydown', 'touchstart', 'wheel']) {
slider.addEventListener(event, stop, { passive: true });
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
const active = () => {
let index = 0, distance = Infinity;
slides.forEach((slide, i) => { const delta = Math.abs(left(slide) - slider.scrollLeft); if (delta < distance) { distance = delta; index = i; } });
return index;
};
const go = index => {
slider.scrollTo({ left: Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index]))), behavior: preference.matches ? 'instant' : 'smooth' });
};
const step = direction => {
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
const first = slider.scrollLeft <= 2;
go(direction > 0 ? (last ? 0 : Math.min(slides.length - 1, active() + 1)) : (first ? slides.length - 1 : Math.max(0, active() - 1)));
};
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1)));
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
const next = button('→', 'Nächste Folie', () => manual(() => step(1)));
const dots = [];
if (slider.classList.contains('slider-dots')) {
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
slides.forEach((slide, i) => {
const dot = button('●', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
});
}
slider.addEventListener('scroll', () => {
// A scroll that is not the one we just started belongs to a person; then we are done.
if (Date.now() - ownScroll > 1200) stop();
}, { passive: true });
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
const update = () => {
const fits = slider.scrollWidth <= slider.clientWidth + 2;
controls.hidden = fits; previous.disabled = next.disabled = fits;
const index = active(); count.textContent = `${index + 1} / ${slides.length}`;
dots.forEach((dot, i) => dot.setAttribute('aria-pressed', String(i === index)));
if (play) {
play.textContent = playing ? 'Pause' : 'Wiedergabe';
play.setAttribute('aria-label', playing ? 'Automatische Wiedergabe pausieren' : 'Automatische Wiedergabe starten');
play.disabled = preference.matches; play.title = preference.matches ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
}
};
const schedule = () => {
clearTimeout(timer);
if (playing && !preference.matches) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000);
};
const pause = () => { playing = false; update(); clearTimeout(timer); };
slider.after(controls); slider.classList.add('slider-controlled'); update();
slider.addEventListener('scroll', update, { passive: true, signal });
slider.addEventListener('pointerenter', () => { hover = true; }, { signal });
slider.addEventListener('pointerleave', () => { hover = false; schedule(); }, { signal });
for (const event of ['pointerdown', 'wheel', 'touchstart', 'focusin']) slider.addEventListener(event, pause, { passive: true, signal });
slider.addEventListener('keydown', event => {
if (event.target !== slider || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault(); manual(() => step(event.key === 'ArrowRight' ? 1 : -1));
}, { signal });
const observer = new view.IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0 }); observer.observe(slider);
const resize = new view.ResizeObserver(update); resize.observe(slider);
preference.addEventListener('change', () => { if (preference.matches) pause(); update(); }, { signal });
schedule();
cleanups.push(() => { clearTimeout(timer); events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
});
return () => cleanups.forEach(cleanup => cleanup());
}
if ('IntersectionObserver' in window) {
new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0.35 })
.observe(slider);
}
};
document.querySelectorAll('.block-slider[data-autoplay]').forEach(setup);
})();
if (!document.querySelector('#editor')) initSliders();
+234
View File
@@ -0,0 +1,234 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8' }));
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory } });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], {
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
});
await mkdir('reports/admin-quality', { recursive: true });
let browser, page;
const checks = [];
try {
for (let i = 0; i < 60; i++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (i === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'no-preference' }); page = await context.newPage();
page.setDefaultTimeout(15000);
const errors = []; page.on('pageerror', error => errors.push(error.message));
page.on('dialog', dialog => dialog.accept());
if (process.argv.includes('--visual-only')) {
await page.goto(origin);
await page.evaluate(async () => {
const images = [...document.images]; images.forEach(image => { image.loading = 'eager'; });
await Promise.all(images.map(image => image.decode().catch(() => {}))); await document.fonts.ready;
});
await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true, animations: 'disabled' });
const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1440, height: 1000 } });
const noScript = await plain.newPage(); await noScript.goto(origin);
assert.equal(await noScript.locator('h1').isVisible(), true);
assert.equal(await noScript.locator('#hero-slider .slider-slide').count(), 4);
assert.ok(await noScript.locator('main').innerText().then(text => text.includes('Dienstleistungen') || text.includes('Full-Service')));
await noScript.screenshot({ path: 'reports/admin-quality/pilot-without-javascript.png', fullPage: true });
await plain.close();
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.field-table tbody tr').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
console.log('Public no-JavaScript baseline and visual captures passed.');
} else {
if (!process.argv.includes('--admin-only')) {
await page.goto(origin); await page.mouse.move(1, 1);
const slider = page.locator('#hero-slider');
const geometry = () => slider.evaluate(el => ({ left: el.scrollLeft, width: el.clientWidth, total: el.scrollWidth, slides: el.children.length, columns: getComputedStyle(el).gridAutoColumns }));
const before = await geometry(); await page.waitForTimeout(8000); const after = await geometry();
console.log('Pilot slider:', JSON.stringify({ before, after }));
if (process.argv.includes('--probe')) {
console.log('Motion:', await page.locator('[class*="fx-entrance-"]').evaluateAll(nodes => nodes.map(el => ({ id: el.id, name: getComputedStyle(el).animationName, duration: getComputedStyle(el).animationDuration, timeline: getComputedStyle(el).animationTimeline }))));
} else {
assert.ok(after.left > before.left + 20, 'the real pilot slider advances');
}
await page.waitForTimeout(7100); const second = await geometry();
console.log('Second slider:', JSON.stringify(second), await slider.locator('xpath=following-sibling::*[1]').innerText());
assert.ok(second.left > after.left + 20, 'autoplay advances more than once');
const controls = slider.locator('xpath=following-sibling::*[1]');
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
await page.waitForTimeout(700); const manual = await geometry();
console.log('Manual slider:', JSON.stringify(manual), await controls.innerText());
assert.ok(manual.left < second.left - 20);
await page.mouse.move(1, 1); await page.waitForTimeout(7200);
console.log('Paused slider:', JSON.stringify(await geometry()), await controls.innerText());
assert.ok(Math.abs((await geometry()).left - manual.left) < 2, 'manual navigation pauses autoplay');
await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).click();
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: 'Automatische Wiedergabe starten' }).isDisabled(), true);
const reduced = await geometry(); await page.waitForTimeout(7100);
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
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' });
checks.push({ name: 'pilot slider: repeated autoplay, manual pause, resume, reduced motion', passed: true });
await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true });
}
if (process.argv.includes('--audit')) {
execFileSync(process.execPath, ['scripts/audit.mjs'], { stdio: 'inherit', windowsHide: true, env: { ...process.env, CMS_URL: origin, CMS_AUDIT_DIR: 'reports/admin-quality/lighthouse' } });
}
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');
for (const [name, path] of [['pages', '/admin'], ['posts', '/admin/posts'], ['menus', '/admin/menus'], ['forms', '/admin/forms'], ['areas', '/admin/partials']]) {
const response = await page.goto(origin + path); assert.equal(response.status(), 200, path);
await page.screenshot({ path: `reports/admin-quality/${name}.png`, fullPage: true });
const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
checks.push({ name, passed: true });
}
await page.goto(origin + '/admin/posts');
const rowLinks = () => page.locator('.rows article h3 a');
assert.equal(await rowLinks().count(), 20);
const firstIds = await rowLinks().evaluateAll(links => links.map(link => link.href));
await page.getByRole('navigation', { name: 'Ergebnisseiten oben', exact: true }).getByRole('link', { name: 'Weiter' }).click();
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 9);
assert.equal(await rowLinks().count(), 9);
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
assert.equal((await rowLinks().evaluateAll(links => links.map(link => link.href))).some(id => firstIds.includes(id)), false);
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50');
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 29);
assert.equal(await rowLinks().count(), 29);
await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff');
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 0);
assert.equal(await rowLinks().count(), 0);
await page.goto(origin + '/admin/posts?seite=999'); assert.equal(await rowLinks().count(), 9);
await page.goto(origin + '/admin'); assert.equal(await rowLinks().count(), 4);
checks.push({ name: 'separate pages/posts, bounded pages, no overlap, size/search/clamping', passed: true });
await page.goto(origin + '/admin/menus');
const entries = page.locator('#menu-items > li'); const initialCount = await entries.count();
const search = page.getByRole('combobox', { name: 'Inhalte suchen' });
await search.fill('Impressum');
await page.getByRole('option').filter({ hasText: 'Impressum' }).first().waitFor();
await search.press('ArrowDown'); await search.press('Enter');
await page.getByRole('button', { name: 'Zum Menü hinzufügen', exact: true }).click();
assert.equal(await entries.count(), initialCount + 1);
await entries.last().getByRole('button', { name: 'Als Unterpunkt', exact: true }).click();
assert.equal(await entries.last().getAttribute('data-level'), '2');
await entries.last().getByRole('button', { name: 'Entfernen', exact: true }).click();
assert.equal(await entries.count(), initialCount);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
assert.equal(await entries.count(), initialCount + 1);
assert.equal(await entries.last().getAttribute('data-level'), '2');
const menuSave = () => Promise.all([
page.waitForResponse(response => response.url().includes('/menus/header') && response.request().method() === 'POST'),
page.getByRole('button', { name: 'Menü speichern', exact: true }).click(),
]).then(([response]) => response);
assert.equal((await menuSave()).status(), 200);
await page.reload(); assert.equal(await entries.count(), initialCount + 1);
assert.equal(await entries.last().getAttribute('data-level'), '2');
await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click();
await page.locator('#link-label').fill('QA Kontakt'); await page.locator('#link-url').fill('javascript:alert(1)');
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 entries.last().locator('summary').click();
await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt');
assert.equal((await menuSave()).status(), 200);
const externalSave = await page.locator('#menu-editor').evaluate(async form => {
const response = await fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } });
return response.status;
});
assert.equal(externalSave, 200);
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);
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');
const form = page.locator('.form-def').first(), message = form.locator('[name="success"]');
const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth }));
assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width');
await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
await form.getByRole('button', { name: 'Formular speichern', exact: true }).click();
assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
checks.push({ name: 'wide multiline form response persists', passed: true });
await page.goto(origin + '/admin/partials');
await page.getByText('Neuen Bereich anlegen', { exact: true }).click();
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();
await page.locator('#structure .tree-item').first().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');
await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor();
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor();
await page.reload();
await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor();
await page.goto(origin + '/admin/partials');
const areaLinks = await rowLinks().evaluateAll(links => links.map(link => link.href));
for (const url of areaLinks) {
assert.equal((await page.goto(url)).status(), 200);
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.locator('#select-page').click();
const title = page.getByLabel('Seitentitel', { exact: true });
await title.fill((await title.inputValue()) + ' QA'); await title.press('Tab');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor();
await page.reload(); await page.locator('#select-page').click();
await page.waitForFunction(() => document.querySelector('#save-status')?.textContent !== 'Wird geladen …');
assert.match(await title.inputValue(), / QA$/);
}
checks.push({ name: 'real pilot areas open, edit, preview, save and reload', passed: true });
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();
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.
const nextPreview = preview.getByRole('button', { name: 'Nächste Folie', exact: true });
await nextPreview.waitFor(); await page.waitForTimeout(1500);
const previewBox = await nextPreview.boundingBox(); await page.waitForTimeout(150);
const settledBox = await nextPreview.boundingBox();
assert.ok(Math.abs(settledBox.y - previewBox.y) < 1 && settledBox.width > 0, 'preview control has stable visible geometry');
await page.mouse.click(settledBox.x + settledBox.width / 2, settledBox.y + settledBox.height / 2);
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();
assert.equal(await preview.locator('.slider-controls').count(), 0);
checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true });
for (const width of [1024, 390]) {
await page.setViewportSize({ width, height: 844 });
for (const route of ['/admin/posts', '/admin/menus', '/admin/forms']) {
await page.goto(origin + route);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`);
const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], `${route} ${width}`);
await page.screenshot({ path: `reports/admin-quality/${route.split('/').pop()}-${width}.png` });
}
}
checks.push({ name: 'tablet/mobile layouts fit and pass axe', passed: true });
assert.deepEqual(errors, []);
await writeFile('reports/admin-quality/result.json', JSON.stringify({ checks }, null, 2));
console.log('Pilot and administration checks passed.');
}
} catch (error) {
if (page) await page.screenshot({ path: 'reports/admin-quality/failure.png', fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+3 -3
View File
@@ -68,7 +68,7 @@ echo "Fassung {$version}\n";
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
// dort, wo eine frische Installation ihre Tabellen anlegen will.
$include = ['bin', 'config', 'migrations', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', 'LICENSE', '.env.example'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
$count = 0;
@@ -98,7 +98,7 @@ foreach ($include as $entry) {
// denn ohne sie bootet die Anwendung nicht einmal so weit, dass der Installer erscheint. Also
// wird die Vorlage zur .env des Releases - mit Produktionswerten und ohne Geheimnis. Das
// Geheimnis erzeugt der erste Aufruf selbst, die Zugangsdaten schreibt der Installer nach
// .env.local.
// .env.
copy($root.'/.env.example', $staging.'/.env');
++$files;
echo "Quelldateien: {$files}\n";
@@ -115,7 +115,7 @@ if (!$ok) {
fwrite(STDERR, "composer install ist gescheitert:\n".$output."\n");
exit(1);
}
echo "vendor/ gebaut".($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
echo 'vendor/ gebaut'.($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
// The environment file is not part of a release: it holds the secret of one installation.
foreach (['.env.local', '.env.prod.local'] as $secret) {
+126 -1
View File
@@ -175,6 +175,107 @@ try {
await leftHandle.dblclick(); await rightHandle.dblclick();
await page.locator('[data-panel="content"]').click();
checks.push({ name: 'resizable persistent panels, root sections, contextual image/text properties and full text width', passed: true });
// A layout starts empty: it must be visible, reachable and editable before it has any text.
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await frame.locator('h1').click();
await page.locator('#add-root-section').click();
await previewReady();
const emptySection = frame.locator('main > .editor-empty-container');
await emptySection.waitFor();
const newSectionId = await emptySection.getAttribute('id');
assert.ok((await emptySection.boundingBox()).height > 100, 'new empty section has a visible hit area');
await page.screenshot({ path: 'reports/editor/empty-section.png', fullPage: true });
await frame.getByRole('button', { name: '2 Spalten in dieser Sektion anlegen', exact: true }).press('Enter');
await previewReady();
const emptyColumns = frame.locator(`#${newSectionId} .block-column`);
assert.equal(await emptyColumns.count(), 2);
for (let index = 0; index < 2; index++) assert.ok((await emptyColumns.nth(index).boundingBox()).height > 80, 'empty column has a hit area');
const secondColumnId = await emptyColumns.nth(1).getAttribute('id');
const firstColumnId = await emptyColumns.first().getAttribute('id');
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).press('Enter');
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
await page.locator('#library-search').fill('Text');
await page.locator('#library [data-add="text"]').click();
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
await previewReady();
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
await page.locator('#library-search').fill('');
const dragSource = page.locator('#library [data-add="button"]');
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
await dragSource.scrollIntoViewIfNeeded();
const sourceBox = await dragSource.boundingBox();
let targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
await page.mouse.down();
await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 10, sourceBox.y + sourceBox.height / 2, { steps: 3 });
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 });
// Chromium dispatches dragenter on the first move into a target, dragover on the next.
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
await page.locator('.ov-drop-label').filter({ hasText: 'Spalte 1 von 2' }).waitFor();
await page.screenshot({ path: 'reports/editor/empty-layout-drag.png', fullPage: true });
const beforeDragScroll = await page.locator('#preview').evaluate(el => el.contentWindow.scrollY);
const frameBox = await page.locator('#preview').boundingBox();
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8, { steps: 5 });
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8);
await page.waitForFunction(top => document.querySelector('#preview').contentWindow.scrollY > top + 10, beforeDragScroll);
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 6 });
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
await page.mouse.up();
await frame.locator(`#${firstColumnId} .button`).waitFor();
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 0, 'placeholders disappear after filling');
await page.locator('#undo').click(); await previewReady();
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'undo restores the empty target');
await page.locator('#redo').click(); await previewReady();
await frame.locator(`#${firstColumnId} .button`).waitFor();
await page.locator('#undo').click(); await previewReady();
await page.locator('#undo').click(); await previewReady();
await page.screenshot({ path: 'reports/editor/empty-layout-desktop.png', fullPage: true });
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: 'reports/editor/empty-layout-mobile.png', fullPage: true });
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
await page.locator('#save').click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 2, 'saved empty columns survive reload');
for (const device of ['Desktop', 'Mobil']) {
await page.getByRole('button', { name: device, exact: true }).click();
const destination = frame.locator(`#${secondColumnId}`);
await destination.scrollIntoViewIfNeeded();
const box = await destination.boundingBox();
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
});
await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), secondColumnId, `column order follows the ${device} layout`);
await page.locator('#undo').click(); await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
}
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
const publicBefore = await (await fetch(origin)).text();
assert.equal(publicBefore.includes(newSectionId), false, 'draft remains private');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const publishedEmpty = await (await fetch(origin)).text();
assert.ok(publishedEmpty.includes(newSectionId));
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
await page.locator(`#structure [data-block-id="${newSectionId}"]`).click();
await page.locator('#delete').click();
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();
await page.reload(); await previewReady();
checks.push({ name: 'empty sections and columns: keyboard insertion, exact drop target, undo, responsive preview, reload and public isolation', passed: true });
await frame.locator('h1').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
@@ -558,7 +659,31 @@ try {
await openGroup('Bewegung und Effekte');
await page.getByLabel('Schatten', { exact: true }).selectOption('soft');
await page.getByLabel('Hover-Effekt', { exact: true }).selectOption('lift');
await page.getByLabel('Einblendung beim Laden', { exact: true }).selectOption('rise');
for (const [effect, transform] of [['left', 'translateX(-36px)'], ['right', 'translateX(36px)']]) {
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption(effect);
await frame.locator(`#${galleryId}.fx-entrance-${effect}`).waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.evaluate(id => {
const element = document.querySelector('#preview').contentDocument.getElementById(id);
window.observedEntrance = [];
const animate = element.animate.bind(element);
element.animate = (frames, options) => { window.observedEntrance.push(frames); return animate(frames, options); };
element.scrollIntoView();
}, galleryId);
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
await page.waitForFunction(() => window.observedEntrance.length > 0);
assert.equal(await page.evaluate(() => window.observedEntrance[0][0].transform), transform);
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
}
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(() => { window.observedEntrance = []; });
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
await page.waitForTimeout(800);
assert.equal(await page.evaluate(() => window.observedEntrance.length), 0, 'reduced motion suppresses entrance playback');
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
await page.emulateMedia({ reducedMotion: 'no-preference' });
checks.push({ name: 'distinct left/right entrance playback and reduced motion in the sandbox', passed: true });
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption('rise');
await frame.locator(`#${galleryId}.fx-entrance-rise`).waitFor();
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await openGroup('Layout');
+124
View File
@@ -0,0 +1,124 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
import { mediaProps } from '../public/assets/editor-model.js';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/image-size'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800);
assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title;
data.document.blocks.unshift({ id: 'qa-image-size-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px' } }, children: [{ id: 'qa-image-size', type: 'image', props: original }] });
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
assert.equal(created.status(), 200);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
const select = async () => { await page.locator('#structure [data-block-id="qa-image-size"]').click(); await page.locator('[data-panel="content"]').click(); };
await select();
const exact = () => page.getByLabel('Bildbreite (%)', { exact: true });
const slider = () => page.getByRole('slider', { name: 'Bildbreite Schieberegler' });
const width = async percent => page.waitForFunction(percent => {
const doc = document.querySelector('#preview')?.contentDocument;
const figure = doc?.getElementById('qa-image-size'); if (!figure) return false;
const parent = figure.parentElement; const style = doc.defaultView.getComputedStyle(parent);
const available = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
return Math.abs(figure.getBoundingClientRect().width / available * 100 - percent) < .1;
}, percent);
const saved = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); };
const changeDevice = async device => page.locator(`[data-viewport="${device}"]`).click();
assert.equal(await exact().inputValue(), '');
await exact().fill('50'); await width(50);
assert.equal(await exact().evaluate(input => input === document.activeElement), true);
await exact().blur();
await slider().focus(); await slider().press('ArrowRight'); await width(50.1);
await page.locator('#undo').click(); await width(50); await page.locator('#redo').click(); await width(50.1);
const box = await slider().boundingBox();
await page.mouse.move(box.x + box.width * .2, box.y + box.height / 2); await page.mouse.down();
await page.mouse.move(box.x + box.width * .6, box.y + box.height / 2, { steps: 8 }); await page.mouse.up();
const dragged = Number(await exact().inputValue()); assert.ok(dragged > 50.1); await width(dragged);
await page.locator('#undo').click(); await width(50.1);
await page.locator('#redo').click(); await width(dragged);
await exact().fill('0'); assert.equal(await exact().getAttribute('aria-invalid'), 'true'); await width(dragged);
await exact().fill('50'); await width(50); await exact().blur();
checks.push('number, keyboard and drag update preview; one undo per gesture; invalid values do not change the document');
await changeDevice('tablet'); assert.equal(await exact().inputValue(), '50');
assert.match(await page.locator('#image-width-state').innerText(), /Von Desktop übernommen/);
await exact().fill('75'); await width(75); await exact().blur();
await changeDevice('mobile'); assert.equal(await exact().inputValue(), '75');
await exact().fill('30'); await width(30); await exact().blur();
await page.locator('#image-width-reset').click(); await width(75);
assert.match(await page.locator('#image-width-state').innerText(), /Von Tablet übernommen/);
await page.locator('#undo').click(); await width(30);
await changeDevice('desktop'); assert.equal(await exact().inputValue(), '50'); await width(50);
await saved(); await page.reload(); await select(); assert.equal(await exact().inputValue(), '50'); await width(50);
const stored = await (await context.request.get(api)).json();
const image = stored.document.blocks[0].children[0];
assert.deepEqual(image.style, { desktop: { width: '50%' }, tablet: { width: '75%' }, mobile: { width: '30%' } });
assert.deepEqual(image.props, original, 'original dimensions, media variants, caption and alt text are unchanged');
checks.push('device inheritance, reset, undo, save/reload and unchanged image metadata');
await page.locator('[data-panel="design"]').click();
const size = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Größe$/ }) });
if (!(await size.getAttribute('open'))) await size.locator('summary').click();
assert.equal(await page.locator('#style-width').inputValue(), '50');
await page.locator('#style-width').fill('60'); await page.locator('#style-width').blur();
await page.locator('[data-panel="content"]').click(); assert.equal(await exact().inputValue(), '60'); await width(60);
await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true });
for (const viewport of [1600, 390]) {
await page.setViewportSize({ width: viewport, height: 1050 });
await exact().scrollIntoViewIfNeeded();
const results = await new AxeBuilder({ page }).include('.image-width-control').analyze();
assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
assert.equal(await page.locator('.image-width-control').evaluate(el => el.scrollWidth <= el.clientWidth), true);
await page.locator('.image-width-control').screenshot({ path: `${report}/control-${viewport}.png` });
}
await page.setViewportSize({ width: 1600, height: 1050 }); await saved();
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
for (const [viewport, percent] of [[1600, 60], [900, 75], [390, 30]]) {
await publicPage.setViewportSize({ width: viewport, height: 900 }); await publicPage.goto(origin);
const geometry = await publicPage.locator('#qa-image-size').evaluate(async figure => {
const img = figure.querySelector('img'); await img.decode();
const parent = figure.parentElement, style = getComputedStyle(parent);
return { figure: figure.getBoundingClientRect().width, available: parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), width: img.getBoundingClientRect().width, height: img.getBoundingClientRect().height };
});
assert.ok(Math.abs(geometry.figure / geometry.available * 100 - percent) < .1);
assert.ok(Math.abs(geometry.width / geometry.height - original.width / original.height) < .02, 'image aspect ratio is preserved');
await publicPage.locator('#qa-image-size-section').screenshot({ path: `${report}/published-${viewport}.png` });
}
await plain.close(); checks.push('shared Design width; desktop/mobile accessible controls; published responsive percentages and aspect ratio without JavaScript');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Image size checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+162
View File
@@ -0,0 +1,162 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: environment });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...environment, SITE_ORIGIN: origin } });
const report = 'reports/live-search'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
page = await context.newPage(); page.setDefaultTimeout(15000);
page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const search = () => page.locator('[data-live-search] input[type="search"]');
const rows = () => page.locator('.rows article h3 a');
const settled = () => page.waitForFunction(() => /^\d+ Treffer/.test(document.querySelector('#search-status')?.textContent) && !document.querySelector('#list-results').hasAttribute('aria-busy'));
const type = async value => { await search().fill(value); await settled(); };
const count = async expected => { await settled(); assert.equal(await rows().count(), expected); };
const mark = async () => page.evaluate(() => { window.searchDocumentMarker = 'same-document'; });
const sameDocument = async () => assert.equal(await page.evaluate(() => window.searchDocumentMarker), 'same-document');
const next = () => page.locator('.pager').first().getByRole('link', { name: 'Weiter' });
await page.goto(origin + '/admin/posts'); await mark();
assert.equal(await rows().count(), 20);
await next().click(); await count(9); await sameDocument();
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
await page.goBack(); await count(20);
await page.goForward(); await count(9);
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50'); await count(29);
assert.equal(new URL(page.url()).searchParams.has('seite'), false);
await type('nichtvorhandener-qa-begriff'); await count(0); await sameDocument();
assert.equal(await search().evaluate(input => input === document.activeElement), true);
assert.equal(await page.locator('.create-form input[name="_list_query"]').inputValue(), 'nichtvorhandener-qa-begriff');
await type(''); await count(29);
await page.locator('[data-search-reset]').click(); await count(20);
assert.equal(await page.getByLabel('Pro Seite', { exact: true }).inputValue(), '20');
await page.getByLabel('Status', { exact: true }).selectOption('trash'); await count(0);
await page.goBack(); await count(20);
assert.equal(await page.getByLabel('Status', { exact: true }).inputValue(), 'all');
checks.push('posts: typing, focus, filter, page size, pagination, reset, back/forward and form context without navigation');
// Let an old server response arrive after the latest query has already completed.
let delayed;
const oldRequest = new Promise(resolve => { delayed = resolve; });
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'old-delayed-search', async route => {
const response = await route.fetch(); delayed();
await new Promise(resolve => setTimeout(resolve, 800));
await route.fulfill({ response }).catch(() => {});
});
await search().fill('old-delayed-search'); await oldRequest;
await type(''); await count(20); await page.waitForTimeout(1000); await count(20);
await page.unrouteAll({ behavior: 'wait' });
let requests = 0;
const countRequests = request => { if (request.headers()['x-requested-with'] === 'XMLHttpRequest') requests++; };
page.on('request', countRequests);
await search().pressSequentially('fast-query', { delay: 25 }); await count(0);
assert.equal(requests, 1, 'rapid typing is debounced');
page.off('request', countRequests);
await type(''); await count(20);
await search().dispatchEvent('compositionstart'); await search().fill('ime-query');
await page.waitForTimeout(400); assert.equal(await rows().count(), 20);
await search().dispatchEvent('compositionend'); await count(0);
await type(''); await count(20);
checks.push('stale responses ignored; rapid input debounced; IME composition waits for completed text');
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'error-query', route => route.fulfill({ status: 500, body: 'Unavailable' }));
await search().fill('error-query'); await page.locator('#search-error').waitFor({ state: 'visible' });
assert.equal(await rows().count(), 20); assert.equal(await search().inputValue(), 'error-query');
await page.unrouteAll({ behavior: 'wait' }); await page.getByRole('button', { name: 'Erneut suchen' }).click(); await count(0);
assert.equal(await page.locator('#search-error').isVisible(), false);
await type(''); await count(20);
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'expired-session', route => route.fulfill({ status: 401, body: '' }));
await search().fill('expired-session'); await page.getByRole('alert').filter({ hasText: 'erneut anmelden' }).waitFor();
assert.equal(await rows().count(), 20); await page.unrouteAll({ behavior: 'wait' });
await type(''); await count(20);
checks.push('server error and expired session preserve results and input; retry succeeds');
await page.goto(origin + '/admin'); await mark();
await type('Impressum'); await count(1); await sameDocument();
assert.match(await rows().first().innerText(), /Impressum/);
await page.screenshot({ path: `${report}/pages-search.png`, fullPage: true });
await page.reload(); assert.equal(await search().inputValue(), 'Impressum'); assert.equal(await rows().count(), 1);
await type(''); await count(4);
const entry = page.locator('.rows article').filter({ hasText: 'Impressum' }).first();
await entry.locator('summary').first().click();
await entry.getByRole('combobox', { name: 'Übergeordnete Seite (optional)' }).fill('Datenschutz');
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().waitFor();
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().click();
assert.ok(await entry.locator('.picker-value').inputValue());
let pending;
const pendingRequest = new Promise(resolve => { pending = resolve; });
await page.route(url => url.pathname === '/admin' && url.searchParams.get('q') === 'late-edit-search', async route => {
const response = await route.fetch(); pending();
await new Promise(resolve => setTimeout(resolve, 600));
await route.fulfill({ response }).catch(() => {});
});
await search().fill('late-edit-search'); await pendingRequest;
await entry.getByLabel('Titel für Suchergebnisse').fill('Ungespeicherte Bearbeitung');
await page.waitForTimeout(800);
assert.equal(await entry.getByLabel('Titel für Suchergebnisse').inputValue(), 'Ungespeicherte Bearbeitung');
assert.match(await page.locator('#search-status').innerText(), /angehalten/);
await page.unrouteAll({ behavior: 'wait' });
checks.push('pages: live title search, reloadable URLs, reinitialized parent picker after AJAX');
await page.goto(origin + '/admin/media'); await mark();
const library = await (await context.request.get(origin + '/admin/api/media')).json();
const target = library.items.filter(item => !item.archived).at(-1); assert.ok(target, 'pilot has media');
await type(target.title);
assert.ok((await page.locator('.library-body strong').allTextContents()).includes(target.title));
assert.equal(await page.locator('form[action$="/media/upload"] input[name="q"]').inputValue(), target.title);
await sameDocument();
await page.screenshot({ path: `${report}/media-search.png`, fullPage: true });
await page.getByLabel('Status', { exact: true }).selectOption('1'); await settled();
assert.equal(new URL(page.url()).searchParams.get('archiv'), '1');
await type('nichtvorhandenes-bild'); assert.equal(await page.locator('.library-tile').count(), 0);
await type(''); assert.ok(await page.locator('.library-tile').count());
const older = page.locator('.pager a[rel="next"]');
if (await older.count()) { await older.click(); await settled(); assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); await sameDocument(); }
checks.push('media: global title search, archive filter, empty/reset states, pagination, upload context');
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
for (const route of ['/admin', '/admin/posts', '/admin/media']) {
await page.goto(origin + route); await type('nichtvorhandener-begriff');
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`);
const audit = await new AxeBuilder({ page }).analyze();
assert.deepEqual(audit.violations.map(v => ({ id: v.id, targets: v.nodes.map(n => n.target) })), [], `${route} ${width}`);
if (width === 390) await page.screenshot({ path: `${report}/${route.split('/').pop()}-mobile.png`, fullPage: true });
}
}
const plain = await browser.newContext({ javaScriptEnabled: false, storageState: await context.storageState() });
const fallback = await plain.newPage(); await fallback.goto(origin + '/admin');
await fallback.getByLabel('Seiten durchsuchen').fill('Impressum');
await fallback.getByRole('button', { name: 'Anwenden', exact: true }).click();
assert.equal(await fallback.locator('.rows article h3 a').count(), 1);
await plain.close(); checks.push('desktop/mobile axe and overflow checks; HTML fallback without JavaScript');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Live search browser checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+5 -9
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
* ankommt, kostet Rechenzeit und Logzeilen. Heißt die Verwaltung anders, findet sie niemand, der
* sie nicht kennt. Das ersetzt kein gutes Passwort - es nimmt der Maschinerie nur das Ziel.
*
* Der Wert steht in `.env.local` und nicht in der Datenbank: die Routen stehen fest, bevor die
* Der Wert steht in `.env` und nicht in der Datenbank: die Routen stehen fest, bevor die
* erste Datenbankabfrage läuft. Deshalb dieser Befehl - er schreibt die Datei, prüft vorher, dass
* die Adresse benutzbar ist, und räumt den Zwischenspeicher auf, damit die neue Adresse sofort
* gilt.
@@ -68,14 +68,10 @@ final class AdminPathCommand extends Command
return Command::FAILURE;
}
$datei = $this->projectDir.'/.env.local';
$inhalt = is_file($datei) ? (string) file_get_contents($datei) : '';
$zeile = sprintf('ADMIN_PATH=%s', $neu);
$inhalt = 1 === preg_match('/^ADMIN_PATH=.*$/m', $inhalt)
? (string) preg_replace('/^ADMIN_PATH=.*$/m', $zeile, $inhalt)
: rtrim($inhalt)."\n".$zeile."\n";
if (false === @file_put_contents($datei, ltrim($inhalt, "\n"))) {
$io->error('.env.local ist nicht beschreibbar. Trage dort von Hand ein: '.$zeile);
try {
\App\Ops\Environment::write($this->projectDir, ['ADMIN_PATH' => $neu]);
} catch (\RuntimeException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
+12 -7
View File
@@ -39,17 +39,22 @@ final class MediaLibrary
*
* @return array{items: list<array<string, mixed>>, total: int, page: int, pages: int, perPage: int}
*/
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE): array
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE, string $query = ''): array
{
$where = $withArchived ? '' : ' WHERE archived = 0';
$total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'.$where);
$qb = $this->db->createQueryBuilder()->from('media');
if (!$withArchived) {
$qb->andWhere('archived = 0');
}
if ('' !== trim($query)) {
$qb->andWhere('(LOWER(title) LIKE :query ESCAPE \'!\' OR LOWER(alt) LIKE :query ESCAPE \'!\')')
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
}
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
$perPage = max(1, min(96, $perPage));
$pages = max(1, (int) ceil($total / $perPage));
$page = max(1, min($pages, $page));
$items = $this->db->fetchAllAssociative(
'SELECT * FROM media'.$where.' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?',
[$perPage, ($page - 1) * $perPage],
);
$items = $qb->select('*')->orderBy('created_at', 'DESC')->addOrderBy('id', 'DESC')
->setMaxResults($perPage)->setFirstResult(($page - 1) * $perPage)->executeQuery()->fetchAllAssociative();
return [
'items' => array_map($this->present(...), $items),
+58
View File
@@ -420,6 +420,64 @@ final class PageRepository
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.path, p.parent_id, p.type, p.locale, p.version, p.published_revision_id, p.draft_revision_id, p.excerpt, p.author, p.published_at, p.featured_media_id, p.robots, p.seo_title, p.og_media_id, p.publish_at, p.header_area, p.footer_area, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.deleted_at IS NULL ORDER BY p.path', ['default']);
}
/** Only the requested screenful is hydrated; filters and counts run in the database. */
public function editorPage(string $type, int $page = 1, int $limit = 20, string $query = '', string $status = 'all'): array
{
$limit = in_array($limit, [20, 50], true) ? $limit : 20;
$type = 'post' === $type ? 'post' : 'page';
$qb = $this->db->createQueryBuilder()->from('page', 'p')
->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id')
->where('p.website_id = :website')->andWhere('p.type = :type')
->setParameter('website', 'default')->setParameter('type', $type);
$qb->andWhere('trash' === $status ? 'p.deleted_at IS NOT NULL' : 'p.deleted_at IS NULL');
if ('draft' === $status) {
$qb->andWhere('p.published_revision_id IS NULL');
} elseif ('published' === $status) {
$qb->andWhere('p.published_revision_id IS NOT NULL');
} elseif ('changed' === $status) {
$qb->andWhere('p.published_revision_id IS NOT NULL AND p.published_revision_id <> p.draft_revision_id');
}
if ('' !== trim($query)) {
$qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')')
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
}
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
$pages = max(1, (int) ceil($total / $limit));
$page = max(1, min($pages, $page));
$items = $qb->select('p.*', 'r.document')->orderBy('post' === $type ? 'p.created_at' : 'p.path', 'post' === $type ? 'DESC' : 'ASC')
->addOrderBy('p.id')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit];
}
/** Bounded suggestions for menus and parent selectors, including a selected ID lookup. */
public function choices(string $query = '', string $type = '', bool $parentsOnly = false, ?string $id = null): array
{
$qb = $this->db->createQueryBuilder()->select('p.id', 'p.path', 'p.type', 'p.published_revision_id', 'r.document')
->from('page', 'p')->leftJoin('p', 'revision', 'r', 'r.id = p.draft_revision_id AND r.page_id = p.id')
->where('p.website_id = :website')->andWhere('p.deleted_at IS NULL')->setParameter('website', 'default');
if (null !== $id) {
$qb->andWhere('p.id = :id')->setParameter('id', $id);
}
if ($parentsOnly || in_array($type, ['page', 'post'], true)) {
$qb->andWhere('p.type = :type')->setParameter('type', $parentsOnly ? 'page' : $type);
}
if ($parentsOnly) {
$qb->andWhere("LENGTH(p.path) - LENGTH(REPLACE(p.path, '/', '')) < ".(self::MAX_DEPTH - 1));
}
if ('' !== trim($query)) {
$qb->andWhere('(LOWER(p.path) LIKE :query ESCAPE \'!\' OR LOWER(r.document) LIKE :query ESCAPE \'!\')')
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
}
$rows = $qb->orderBy('p.path')->addOrderBy('p.id')->setMaxResults(20)->executeQuery()->fetchAllAssociative();
return array_map(static fn (array $row): array => [
'id' => $row['id'], 'path' => $row['path'], 'type' => $row['type'],
'title' => json_decode($row['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $row['path'],
'published' => null !== $row['published_revision_id'],
], $rows);
}
public function findForEditor(string $id): ?array
{
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ? AND p.deleted_at IS NULL', [$id, 'default']);
+62 -12
View File
@@ -20,6 +20,7 @@ use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
@@ -27,29 +28,60 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
#[Route('/%kant.admin_path%')]
final class EditorController extends AbstractController
{
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
{
}
#[Route('', name: 'admin_pages', methods: ['GET'])]
public function index(): Response
#[Route('/posts', name: 'admin_posts', methods: ['GET'])]
public function index(Request $request): Response
{
$pages = $this->pages->listForEditor();
$type = 'admin_posts' === $request->attributes->get('_route') ? 'post' : 'page';
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
$status = $request->query->getString('status', 'all');
$status = in_array($status, ['all', 'draft', 'published', 'changed', 'trash'], true) ? $status : 'all';
$result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status);
$pages = $result['items'];
$parentChoices = [];
foreach ($pages as &$page) {
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
$page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$page['title'] = $page['document']['title'] ?? $page['slug'];
$page['depth'] = substr_count((string) $page['path'], '/');
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) {
$parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null;
}
}
unset($page);
return $this->render('admin/pages.html.twig', [
'pages' => $pages,
'trash' => $this->pages->trash(),
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
'postLayouts' => PostLayouts::all(),
// Which header and footer areas a page can choose from.
$view = [
'pages' => 'trash' === $status ? [] : $pages,
'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [],
'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(),
'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')],
]);
];
if ($request->isXmlHttpRequest()) {
return $this->json([
'html' => $this->renderView('admin/content-results.html.twig', $view),
'total' => $result['total'],
'parameters' => ['q' => $query, 'status' => $status, 'limit' => $result['limit'], 'seite' => $result['page']],
]);
}
return $this->render('admin/pages.html.twig', $view);
}
#[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])]
public function contentChoices(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
return $this->json(['items' => $this->pages->choices(
mb_substr($request->query->getString('q'), 0, 160),
$request->query->getString('type'), $request->query->getBoolean('parents'),
)]);
}
#[Route('/pages', name: 'admin_create', methods: ['POST'])]
@@ -317,8 +349,26 @@ final class EditorController extends AbstractController
private function back(string $message, string $type = 'error'): Response
{
$this->addFlash($type, $message);
$request = $this->requests->getCurrentRequest();
$post = 'post' === $request?->request->getString('_list_type', $request?->request->getString('type', 'page'));
return $this->redirectToRoute('admin_pages');
if (!$request?->request->has('_list_type')) {
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages');
}
$params = [
'q' => $request?->request->getString('_list_query', ''),
'status' => $request?->request->getString('_list_status', 'all'),
'seite' => $request?->request->getInt('_list_page', 1),
'limit' => $request?->request->getInt('_list_limit', 20),
];
foreach (['q' => '', 'status' => 'all', 'seite' => 1, 'limit' => 20] as $key => $default) {
if ($params[$key] === $default) {
unset($params[$key]);
}
}
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages', $params);
}
#[Route('/pages/{id}', name: 'admin_editor', methods: ['GET'])]
+13 -5
View File
@@ -24,11 +24,18 @@ final class MediaController extends AbstractController
// The screen renders the images itself, page by page. The dialog stays what it is: the way
// to pick an image while editing, not the way to see what the library holds.
$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];
if ($request->isXmlHttpRequest()) {
return $this->json([
'html' => $this->renderView('admin/media-results.html.twig', $view),
'total' => $library['total'],
'parameters' => ['q' => $query, 'archiv' => $archived ? '1' : '0', 'seite' => $library['page']],
]);
}
return $this->render('admin/media.html.twig', [
'library' => $this->media->page($request->query->getInt('seite', 1), $archived),
'archived' => $archived,
]);
return $this->render('admin/media.html.twig', $view);
}
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
@@ -90,7 +97,8 @@ final class MediaController extends AbstractController
$query = array_filter([
'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : null,
'archiv' => $request->request->getBoolean('archiv') ? '1' : null,
]);
'q' => mb_substr(trim($request->request->getString('q')), 0, 160),
], static fn ($value): bool => null !== $value && '' !== $value);
return $this->redirectToRoute('admin_media', $query);
}
+23 -19
View File
@@ -18,24 +18,24 @@ final class MenuController extends AbstractController
}
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
public function index(): Response
public function index(Request $request): Response
{
$menus = $this->menus->all();
$rows = [];
foreach (MenuRepository::SLOTS as $slot => $label) {
$rows[$slot] = $this->flatten($menus[$slot]['items'] ?? []);
}
$pages = [];
foreach ($this->pages->listForEditor() as $page) {
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$pages[] = ['id' => $page['id'], 'path' => $page['path'], 'title' => $document['title'] ?? $page['slug']];
$slot = $request->query->getString('slot', 'header');
$slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header';
$menu = $this->menus->all()[$slot] ?? null;
$rows = $this->flatten($menu['items'] ?? []);
$selected = [];
foreach ($rows as &$row) {
if ($row['pageId']) {
$selected[$row['pageId']] ??= $this->pages->choices(id: $row['pageId'])[0] ?? ['id' => $row['pageId'], 'title' => 'Nicht mehr verfügbar', 'path' => ''];
}
$row['page'] = $selected[$row['pageId']] ?? null;
}
unset($row);
return $this->render('admin/menus.html.twig', [
'slots' => MenuRepository::SLOTS,
'menus' => $menus,
'rows' => $rows,
'pages' => $pages,
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
'menu' => $menu, 'rows' => $rows,
]);
}
@@ -44,7 +44,7 @@ final class MenuController extends AbstractController
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.'], 403) : $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', slot: $slot);
}
try {
$this->menus->save(
@@ -54,10 +54,14 @@ final class MenuController extends AbstractController
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidMenu|StaleWrite $e) {
return $this->back($e->getMessage());
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => $e->getMessage()], $e instanceof StaleWrite ? 409 : 422) : $this->back($e->getMessage(), slot: $slot);
}
return $this->back('Das Menü wurde gespeichert.', 'success');
if ('json' === $request->getPreferredFormat()) {
return $this->json(['message' => 'Menü gespeichert.', 'version' => $this->menus->all()[$slot]['version']]);
}
return $this->back('Das Menü wurde gespeichert.', 'success', $slot);
}
/**
@@ -122,10 +126,10 @@ final class MenuController extends AbstractController
return $items;
}
private function back(string $message, string $type = 'error'): Response
private function back(string $message, string $type = 'error', string $slot = 'header'): Response
{
$this->addFlash($type, $message);
return $this->redirectToRoute('admin_menus');
return $this->redirectToRoute('admin_menus', 'header' === $slot ? [] : ['slot' => $slot]);
}
}
+15 -12
View File
@@ -50,10 +50,14 @@ final class PageController extends AbstractController
$localFonts = [];
// The page decides which header and footer it gets - including none.
$areas = $this->areas($page);
$needsSlider = $needsMotion = false;
if (null !== $page) {
$theme = $this->theme->load();
// Theme first, block styles after, so a block always wins over the site defaults.
$sections = $this->sections($page['document'], $areas);
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
@@ -88,7 +92,8 @@ final class PageController extends AbstractController
'needsTabs' => $this->hasType($page['document'], 'tabs'),
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
'needsSlider' => $this->hasAutoplaySlider($page['document']),
'needsSlider' => $needsSlider,
'needsMotion' => $needsMotion,
// 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' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
@@ -124,7 +129,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 = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
if ($mitTurnstile) {
$frames[] = 'challenges.cloudflare.com';
@@ -397,21 +402,19 @@ final class PageController extends AbstractController
return $gefunden;
}
/** True when a slider on this page asked to advance by itself. */
private function hasAutoplaySlider(array $document): bool
private function hasEntrance(array $document): bool
{
$found = false;
$walk = function (array $blocks) use (&$walk, &$found): void {
$walk = function (array $blocks) use (&$walk): bool {
foreach ($blocks as $block) {
if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) {
$found = true;
if ('none' !== ($block['layout']['effects']['entrance'] ?? 'none') || $walk($block['children'] ?? [])) {
return true;
}
$walk($block['children'] ?? []);
}
};
$walk($document['blocks'] ?? []);
return $found;
return false;
};
return $walk($document['blocks'] ?? []);
}
private function hasType(array $document, string $type): bool
+37 -1
View File
@@ -21,7 +21,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)
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)
{
}
@@ -48,6 +48,8 @@ final class SystemController extends AbstractController
// Releasearchiv -, hat danach neuen Code und eine alte Datenbank. Dieser Bildschirm
// ist die einzige Stelle, an der das jemand merken kann.
'pendingMigrations' => $this->pendingMigrations(),
// Actual runtime connection, with credentials excluded from the screen.
'database' => $this->databaseFacts(),
]);
}
@@ -86,6 +88,40 @@ final class SystemController extends AbstractController
return $this->redirectToRoute('admin_system');
}
/**
* Die Datenbank, wie sie wirklich benutzt wird - samt der Datei, in der das steht.
*
* @return array{art: string, datei: ?string, bytes: ?int, schreibbar: bool, quelle: string, adresse: string}
*/
private function databaseFacts(): array
{
$url = $this->databaseUrl;
$datei = null;
if (1 === preg_match('#^sqlite:///(.+)$#', $url, $treffer)) {
$datei = str_replace(['%kernel.project_dir%', '\\'], [$this->projectDir, '/'], $treffer[1]);
}
return [
'art' => null !== $datei ? 'SQLite' : (str_starts_with($url, 'mysql') ? 'MySQL' : 'unbekannt'),
'datei' => $datei,
'bytes' => null !== $datei && is_file($datei) ? (int) filesize($datei) : null,
// Eine Datei, die PHP nicht beschreiben darf, ist der haeufigste Fehler nach einem
// Upload per FTP: Dort gehoert sie dem FTP-Benutzer, hier laeuft ein anderer.
'schreibbar' => null !== $datei ? (is_writable($datei) && is_writable(\dirname($datei))) : true,
'quelle' => $this->environmentSource('DATABASE_URL'),
// Bei MySQL steht das Passwort in der Adresse - es wird nicht angezeigt.
'adresse' => (string) preg_replace('#://([^:/@]+):[^@]*@#', '://$1:***@', $url),
];
}
/** Explicit server values take precedence over the single installation file. */
private function environmentSource(string $name): string
{
$loaded = explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '');
return in_array($name, $loaded, true) ? '.env' : 'Server-Umgebung';
}
/** Das Lesen der Liste darf nie der Grund sein, dass dieser Bildschirm nicht erscheint. */
private function pendingMigrations(): array
{
+3
View File
@@ -21,6 +21,9 @@ final class ConnectionFactory
}
$connection = DriverManager::getConnection($params, $configuration);
if ('pdo_sqlite' === $params['driver']) {
// SQLite's built-in LOWER only handles ASCII: searching for "über" must also find
// "Über uns", just as on MySQL. No database rewrite or search-specific schema needed.
$connection->getNativeConnection()->sqliteCreateFunction('lower', static fn (?string $value): ?string => null === $value ? null : mb_strtolower($value, 'UTF-8'), 1, \PDO::SQLITE_DETERMINISTIC);
$connection->executeStatement('PRAGMA foreign_keys = ON');
$connection->executeStatement('PRAGMA busy_timeout = 5000');
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Ops;
use Symfony\Component\Dotenv\Dotenv;
/** One installation file, with an explicit, recoverable transition from older releases. */
final class Environment
{
public static function prepare(string $root): void
{
if (is_file($root.'/.env') && !is_file($root.'/.env.local')) {
return;
}
self::locked($root, static function () use ($root): void {
$file = $root.'/.env';
if (!is_file($file)) {
$template = is_file($root.'/.env.example') ? (string) file_get_contents($root.'/.env.example') : '';
self::replace($file, $template);
}
$legacy = $root.'/.env.local';
if (is_file($legacy)) {
// Preserve the previously effective configuration, not a release's default DB.
$base = (string) file_get_contents($file);
$old = (string) file_get_contents($legacy);
$values = (new Dotenv())->parse($base."\n".$old, $legacy);
if (preg_match('/^DATABASE_URL=/m', $old)) {
$values['KANT_DATABASE_CONFIGURED'] = '1';
}
$directory = $root.'/var/backups/environment-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(4));
if (!@mkdir($directory, 0o700, true)) {
throw new \RuntimeException('Die bisherige Konfiguration konnte nicht gesichert werden. Bitte die Schreibrechte auf var/backups prüfen.');
}
self::replace($directory.'/.env', $base);
self::replace($directory.'/.env.local', $old);
self::replace($file, self::encode($values));
if (!@unlink($legacy)) {
throw new \RuntimeException('Die Einstellungen sind in .env gesichert, aber .env.local konnte nicht entfernt werden. Bitte deren Schreibrechte prüfen.');
}
}
});
}
public static function write(string $root, array $values): void
{
self::locked($root, static function () use ($root, $values): void {
$file = $root.'/.env';
$existing = is_file($file) ? (new Dotenv())->parse((string) file_get_contents($file), $file) : [];
self::replace($file, self::encode(array_replace($existing, $values)));
});
}
/** A file lock serializes concurrent installer requests and secret generation. */
private static function locked(string $root, callable $operation): void
{
$lock = @fopen($root.'/.env.lock', 'c');
if (false === $lock || !flock($lock, LOCK_EX)) {
if (is_resource($lock)) {
fclose($lock);
}
throw new \RuntimeException('.env ist nicht beschreibbar. Bitte die Schreibrechte im Projektstamm prüfen.');
}
try {
$operation();
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
public static function ensureSecret(string $root): string
{
$secret = '';
self::locked($root, static function () use ($root, &$secret): void {
$file = $root.'/.env';
$values = (new Dotenv())->parse((string) file_get_contents($file), $file);
$secret = $values['APP_SECRET'] ?? '';
if ('' === $secret) {
$secret = bin2hex(random_bytes(32));
self::replace($file, self::encode([...$values, 'APP_SECRET' => $secret]));
}
});
return $secret;
}
private static function encode(array $values): string
{
$lines = ['# Konfiguration dieser Installation. Updates erhalten diese Datei.'];
foreach ($values as $key => $value) {
if (1 !== preg_match('/^[A-Z_][A-Z0-9_]*$/D', $key)) {
throw new \InvalidArgumentException('Ungültiger Name einer Konfigurationseinstellung.');
}
$lines[] = $key.'="'.str_replace(['\\', '"', '$', "\n", "\r"], ['\\\\', '\\"', '\\$', '\\n', '\\r'], (string) $value).'"';
}
return implode("\n", $lines)."\n";
}
private static function replace(string $file, string $content): void
{
$temporary = $file.'.'.bin2hex(random_bytes(6)).'.tmp';
if (false === @file_put_contents($temporary, $content)) {
throw new \RuntimeException('.env ließ sich nicht schreiben. Bitte die Schreibrechte im Projektstamm prüfen.');
}
@chmod($temporary, 0o600);
if (!@rename($temporary, $file)) {
@unlink($temporary);
throw new \RuntimeException('.env ließ sich nicht ersetzen. Die bisherige Konfiguration bleibt erhalten.');
}
}
}
+8 -27
View File
@@ -22,7 +22,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
* Everything the browser installer does, without a browser.
*
* The order is the order of the screens, and every step writes its result immediately instead of
* collecting answers in a session: after the database step there is a `.env.local`, after the
* collecting answers in a session: after the database step there is a `.env`, after the
* migration step there are tables, after the website step there is content. That way a reload, a
* timeout or a closed tab never loses a half filled form - the installer simply sees what is
* already there and continues at the next missing thing.
@@ -123,7 +123,7 @@ final class Installation
$checks[] = $this->check('ZIP-Erweiterung', class_exists(\ZipArchive::class), 'Wird für Sicherungen als Archiv gebraucht. Ohne sie bleibt die Sicherung ein Verzeichnis.', false);
$checks[] = $this->check('proc_open', \function_exists('proc_open') && !\in_array('proc_open', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true), 'Ohne proc_open kann das Update im Browser nicht laufen. Ein Update über das Releasearchiv geht trotzdem.', false);
foreach ([$this->root => 'Projektstamm (für .env.local)', $this->root.'/var' => 'var/ (Datenbank, Cache, Sicherungen)', $this->mediaDirectory => 'Medienordner'] as $path => $label) {
foreach ([$this->root => 'Projektstamm (für .env)', $this->root.'/var' => 'var/ (Datenbank, Cache, Sicherungen)', $this->mediaDirectory => 'Medienordner'] as $path => $label) {
$checks[] = $this->check('Schreibrecht: '.$label, $this->writable($path), sprintf('%s ist nicht beschreibbar. Im FTP-Programm die Rechte auf 0775 setzen (oder 0755, wenn PHP als Eigentümer läuft).', $path), true, $path);
}
@@ -138,7 +138,7 @@ final class Installation
false,
$documentRoot ?: null,
);
$checks[] = $this->check('Geheimnis (APP_SECRET)', '' !== $this->secret && !str_contains($this->secret, 'nicht-gespeichert'), 'Das Geheimnis konnte nicht in .env.local geschrieben werden. Ohne Schreibrecht im Projektstamm bleibt jede Anmeldung nach einem Neustart ungültig.');
$checks[] = $this->check('Geheimnis (APP_SECRET)', '' !== $this->secret && !str_contains($this->secret, 'nicht-gespeichert'), 'Das Geheimnis konnte nicht in .env geschrieben werden. Ohne Schreibrecht im Projektstamm bleibt jede Anmeldung nach einem Neustart ungültig.');
return $checks;
}
@@ -151,7 +151,7 @@ final class Installation
public function environmentWritten(): bool
{
return is_file($this->root.'/.env.local') && str_contains((string) file_get_contents($this->root.'/.env.local'), 'DATABASE_URL=');
return is_file($this->root.'/.env') && str_contains((string) file_get_contents($this->root.'/.env'), 'KANT_DATABASE_CONFIGURED="1"');
}
public function schemaReady(): bool
@@ -206,32 +206,13 @@ final class Installation
return ['url' => $url, 'error' => null];
}
/**
* Writes `.env.local`, never `.env`.
*
* `.env` belongs to the release and is replaced by the next update; `.env.local` belongs to
* this installation and survives. Values that are already there stay unless they are given
* again, so the website step can add the address without losing the database.
*/
/** Save installation settings in the single persistent configuration file. */
public function writeEnvironment(array $values): void
{
$file = $this->root.'/.env.local';
$lines = [];
foreach (is_file($file) ? (preg_split('/\R/', (string) file_get_contents($file)) ?: []) : [] as $line) {
if ('' !== trim($line) && 1 === preg_match('/^([A-Z_]+)=/', $line, $match) && !\array_key_exists($match[1], $values)) {
$lines[$match[1]] = $line;
}
if (isset($values['DATABASE_URL'])) {
$values['KANT_DATABASE_CONFIGURED'] = '1';
}
foreach ($values as $key => $value) {
$lines[$key] = $key.'="'.str_replace(['\\', '"', "\n"], ['\\\\', '\"', ''], (string) $value).'"';
}
$content = "# Diese Datei gehört zu dieser Installation und wird von keinem Update überschrieben.\n".implode("\n", $lines)."\n";
$temporary = $file.'.'.bin2hex(random_bytes(4));
if (false === file_put_contents($temporary, $content) || !rename($temporary, $file)) {
@unlink($temporary);
throw new \RuntimeException('.env.local ließ sich nicht schreiben. Bitte das Schreibrecht im Projektstamm prüfen.');
}
@chmod($file, 0o600);
Environment::write($this->root, $values);
}
/**
+54 -5
View File
@@ -19,6 +19,8 @@ use Doctrine\DBAL\Connection;
*/
final class Updater
{
private array $environmentBefore = [];
private ?string $backupDirectory = null;
public const MAINTENANCE_FILE = 'var/maintenance';
public const LOG_FILE = 'var/updates.log';
@@ -231,8 +233,22 @@ final class Updater
$step($steps, 'Wartungszustand', true, 'Die Website antwortet mit 503, die Verwaltung bleibt erreichbar.');
$migrated = false;
foreach (['.env', '.env.local'] as $file) {
if (is_file($this->root.'/'.$file)) {
$this->environmentBefore[$file] = (string) file_get_contents($this->root.'/'.$file);
}
}
try {
// Older repositories tracked this file. Snapshot above, clear legacy index flags,
// then let Git replace its own copy; restore the installation before booting PHP.
if ($this->git(['ls-files', '--error-unmatch', '.env'])['ok']) {
$this->git(['update-index', '--no-assume-unchanged', '--no-skip-worktree', '.env']);
if (!$this->git(['checkout', '--', '.env'])['ok']) {
throw new \RuntimeException('Die alte Git-Konfiguration konnte nicht für den Updatewechsel vorbereitet werden.');
}
}
$checkout = $this->git(['checkout', '--force', $target]);
$this->restoreEnvironment();
if (!$step($steps, 'Code wechseln', $checkout['ok'], $checkout['ok'] ? 'Auf '.$target.' gewechselt.' : $checkout['output'])) {
throw new \RuntimeException('checkout');
}
@@ -320,15 +336,27 @@ final class Updater
private function rollback(array &$steps, string $commit, ?string $backup, callable $step): void
{
$back = $this->git(['checkout', '--force', $commit]);
try {
$this->restoreEnvironment();
} catch (\RuntimeException $e) {
$step($steps, 'Konfiguration wiederherstellen', false, $e->getMessage());
return;
}
$step($steps, 'Zurück auf den alten Stand', $back['ok'], $back['ok'] ? 'Auf '.substr($commit, 0, 8).' zurückgesetzt.' : $back['output']);
if (null !== $backup && null !== $this->databaseFile()) {
$file = $this->databaseFile();
$copy = dirname($backup).'/'.basename((string) $file);
$copy = $this->backupDirectory.'/'.basename((string) $file);
$restored = is_readable($copy) && @copy($copy, (string) $file);
$step($steps, 'Datenbank zurückschreiben', $restored, $restored
? 'Die Sicherung von vor der Migration ist wieder eingespielt.'
: 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.dirname((string) $backup).'.');
: 'Die Sicherung ließ sich nicht zurückschreiben. Die Datenbank liegt unter '.$this->backupDirectory.'.');
if (!$restored) {
$step($steps, 'Wartungszustand bleibt', false, 'Die Datenbank konnte nicht wiederhergestellt werden. Der Betrieb bleibt angehalten.');
return;
}
}
$composer = $this->runner->resolve('composer', $this->root);
@@ -346,11 +374,27 @@ final class Updater
$step($steps, 'Wartungszustand bleibt', false, 'Auch der alte Stand antwortet nicht. Die Website bleibt im Wartungszustand, damit niemand eine kaputte Seite sieht. Ausgabe: '.$this->lastLine($health['output']));
}
/** Legacy tags tracked .env; preserve the installation even when checking out those tags. */
private function restoreEnvironment(): void
{
foreach ($this->environmentBefore as $file => $content) {
$path = $this->root.'/'.$file;
if (is_file($path) && file_get_contents($path) === $content) {
continue;
}
if (false === @file_put_contents($path, $content, LOCK_EX)) {
throw new \RuntimeException('Die Installationskonfiguration konnte nicht wiederhergestellt werden. Sie liegt in der Updatesicherung unter var/backups.');
}
@chmod($path, 0o600);
}
}
/** Copies the database and the environment files, and returns a sentence about it. */
private function backup(): ?string
{
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His');
if (!is_dir($directory) && !@mkdir($directory, 0o775, true)) {
$this->backupDirectory = null;
$directory = $this->root.'/var/backups/update-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(4));
if (!@mkdir($directory, 0o700, true)) {
return null;
}
$file = $this->databaseFile();
@@ -361,10 +405,15 @@ final class Updater
}
foreach (['.env', '.env.local'] as $environment) {
if (is_readable($this->root.'/'.$environment)) {
@copy($this->root.'/'.$environment, $directory.'/'.$environment);
if (!@copy($this->root.'/'.$environment, $directory.'/'.$environment)) {
return null;
}
@chmod($directory.'/'.$environment, 0o600);
}
}
$this->backupDirectory = $directory;
return sprintf(
'%s (%s). Der Medienordner ist nicht dabei – er ist zu groß, und kein Update schreibt darin.',
str_replace('\\', '/', substr($directory, strlen($this->root) + 1)),
+2 -1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="admin-base" content="{{ path('admin_pages') }}">
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
{% include 'favicon.html.twig' %}
<link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}">
@@ -17,7 +18,7 @@
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
nicht mehr. #}
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews'] %}
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
{%- endfor %}
}}</script>
+11
View File
@@ -0,0 +1,11 @@
{% set selected = selected|default(null) %}
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}">
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
<div class="picker-input">
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
<button type="button" class="subtle picker-clear" aria-label="{{ label|default('Seite') }}: Auswahl entfernen">✕</button>
</div>
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? selected.id : '' }}">
<div id="{{ id }}-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
<p class="picker-status field-help" role="status"></p>
</div>
+221
View File
@@ -0,0 +1,221 @@
{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %}
<div class="section-head"><h2>{{ status == 'trash' ? 'Papierkorb' : plural }} <span class="count">{{ result.total }}</span></h2><span class="muted">{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}</span></div>
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
<div class="rows">
{% for page in pages %}
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
an attribute here would simply be dropped and the tree would look flat. #}
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
<div class="row-line">
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
<div class="row-main">
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
</div>
{% if page.published_revision_id is null %}
<span class="pill pill-off">Nicht veröffentlicht</span>
{% elseif page.published_revision_id == page.draft_revision_id %}
<span class="pill pill-live">Veröffentlicht</span>
{% else %}
<span class="pill pill-draft">Entwurf geändert</span>
{% endif %}
<div class="row-actions">
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
</div>
</div>
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
{# One disclosure per row instead of six popovers next to each other: everything that is
not the daily „bearbeiten“ lives here and opens in place. #}
<details class="sheet">
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
<div class="sheet-body">
{% if is_granted('ROLE_EDITOR') %}
<div class="sheet-part">
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="fields">
<div>
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
</div>
<div>
<label for="robots-{{ page.id }}">Suchmaschinen</label>
<select id="robots-{{ page.id }}" name="robots">
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
</select>
<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">
</div>
{% if page.type == 'post' %}
<div>
<label for="date-{{ page.id }}">Veröffentlicht am</label>
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
</div>
<div>
<label for="author-{{ page.id }}">Autor</label>
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
</div>
<div>
<label for="categories-{{ page.id }}">Kategorien</label>
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
</div>
<div>
<label for="tags-{{ page.id }}">Schlagwörter</label>
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
<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">
</div>
<div>
<label for="excerpt-{{ page.id }}">Auszug</label>
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
</div>
{% endif %}
</div>
<button class="primary" type="submit">Speichern</button>
</form>
</div>
{% endif %}
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
<div class="sheet-part">
<h4>Adresse</h4>
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="fields">
<div>
<label for="slug-{{ page.id }}">Adresssegment</label>
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
</div>
<div>
{% include 'admin/content-picker.html.twig' with {id: 'parent-' ~ page.id, name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true, selected: parentChoices[page.parent_id]|default(null)} %}
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
</div>
</div>
<button class="secondary" type="submit">Adresse ändern</button>
</form>
</div>
{% endif %}
{% if is_granted('ROLE_EDITOR') %}
<div class="sheet-part">
<h4>Kopf- und Fußbereich</h4>
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="fields">
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
<div>
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
{% endif %}{% endfor %}
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
</select>
</div>
{% endfor %}
</div>
<button class="secondary" type="submit">Bereiche speichern</button>
</form>
</div>
{% endif %}
{% if is_granted('ROLE_PUBLISHER') %}
<div class="sheet-part">
<h4>Veröffentlichung planen</h4>
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="field">
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
</div>
<button class="secondary" type="submit">Zeitpunkt speichern</button>
</form>
</div>
{% endif %}
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
<div class="sheet-part">
<h4>Entfernen</h4>
<div class="sheet-actions">
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<button class="secondary" type="submit">In den Papierkorb</button>
</form>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
{# The class is what the security test looks for: an editor must not even be
offered this. #}
<div class="delete-panel">
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
</form>
</div>
{% endif %}
</div>
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
</div>
{% endif %}
</div>
</details>
{% endif %}
</article>
{% else %}
{% if status != 'trash' %}<div class="empty"><h3>{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}</h3>{% if result.total == 0 %}<p>{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}</p>{% endif %}</div>{% endif %}
{% endfor %}
</div>
{% if trash is not empty %}
<div class="rows">
{% for entry in trash %}
<article class="row">
<span class="row-icon" aria-hidden="true">🗑</span>
<div class="row-main">
<h3>/{{ entry.path }}</h3>
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
</div>
{% if is_granted('ROLE_EDITOR') %}
<div class="row-actions">
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<button class="secondary" type="submit">Wiederherstellen</button>
</form>
</div>
{% endif %}
</article>
{% endfor %}
</div>
{% endif %}
{% if status == 'trash' and trash is empty %}<div class="empty"><h3>Der Papierkorb ist leer.</h3></div>{% endif %}
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %}
+6 -5
View File
@@ -1,6 +1,7 @@
{% extends 'admin/base.html.twig' %}
{% set area = partial|default(null) %}
{% set subject = area ? area.document : page.document %}
{% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %}
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
{% block body_class %}editor-body{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}">{% endblock %}
@@ -8,12 +9,12 @@
{% block body %}
{# The API base decides what is edited. An area is shared, so it has no draft and no
publication: saving is live, and the publish button is left out. #}
<div id="editor" data-api="{{ area ? '/admin/partials/' ~ area.id : '/admin/api/pages/' ~ page.id }}" data-kind="{{ area ? area.kind : 'page' }}" data-can-script="{{ is_granted('ROLE_ADMIN') ? 'true' : 'false' }}" data-can-publish="{{ not area and is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
<div id="editor" data-api="{{ area ? path('admin_partial_editor', {id: area.id}) : path('admin_document', {id: page.id}) }}" data-kind="{{ area ? area.kind : 'page' }}" data-can-script="{{ is_granted('ROLE_ADMIN') ? 'true' : 'false' }}" data-can-publish="{{ not area and is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
<header class="editor-header">
<a class="back-link" href="{{ area ? path('admin_partials') : path('admin_pages') }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : 'Zur Seitenübersicht' }}">← <span>{{ area ? 'Bereiche' : 'Seiten' }}</span></a>
<a class="back-link" href="{{ path(listRoute) }}" aria-label="{{ area ? 'Zur Bereichsübersicht' : (page.type == 'post' ? 'Zur Beitragsübersicht' : 'Zur Seitenübersicht') }}">← <span>{{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }}</span></a>
<div class="editor-page-name"><h1>{{ subject.title }}</h1><span>{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}</span></div>
<div class="history-actions"><button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled>↶</button><button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled>↷</button></div>
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>Entwurf speichern</button>{% if is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>{{ area ? 'Bereich speichern' : 'Entwurf speichern' }}</button>{% if not area and is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
</header>
<div id="message" class="editor-message" role="alert" hidden></div>
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
@@ -36,13 +37,13 @@
</aside>
<div class="panel-resizer" data-resize="left" role="separator" tabindex="0" aria-label="Linke Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="240" aria-valuemax="560" aria-valuenow="280" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
<section class="canvas-panel" aria-label="Live-Vorschau">
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><a href="{{ page.slug == 'home' ? '/' : '/' ~ page.slug }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a></div>
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><button id="preview-motion" type="button" class="text-button" aria-pressed="false">Bewegung testen</button>{% if not area %}<a href="{{ page.path == 'home' ? '/' : '/' ~ page.path }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a>{% endif %}</div>
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav>
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
</section>
<div class="panel-resizer" data-resize="right" role="separator" tabindex="0" aria-label="Rechte Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="280" aria-valuemax="560" aria-valuenow="320" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.</div></aside>
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">{{ area ? 'Speichern aktualisiert diesen Bereich auf allen Seiten, die ihn verwenden.' : 'Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.' }}</div></aside>
</main>
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
+8 -8
View File
@@ -40,9 +40,9 @@
<input id="reply-{{ loop.index }}" name="replyToField" maxlength="32" value="{{ new ? '' : form.settings.replyToField }}" placeholder="email">
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
</div>
<div>
<div class="field-wide">
<label for="success-{{ loop.index }}">Meldung nach dem Absenden</label>
<input id="success-{{ loop.index }}" name="success" maxlength="300" value="{{ new ? '' : form.settings.success }}">
<textarea id="success-{{ loop.index }}" name="success" maxlength="300" rows="3" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ new ? '' : form.settings.success }}</textarea>
</div>
</div>
@@ -53,16 +53,16 @@
{% for row in rows|merge([{name: '', type: 'text', label: '', required: false, help: '', options: []}, {name: '', type: 'text', label: '', required: false, help: '', options: []}]) %}
{% set n = loop.index0 %}
<tr{% if row.label == '' %} class="table-new"{% endif %}>
<td><input name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" aria-label="Technischer Name von Feld {{ n + 1 }}" placeholder="vorname"></td>
<td>
<td data-label="Technischer Name"><input name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" aria-label="Technischer Name von Feld {{ n + 1 }}" placeholder="vorname"></td>
<td data-label="Typ">
<select name="fields[{{ n }}][type]" aria-label="Typ von Feld {{ n + 1 }}">
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
</select>
</td>
<td><input name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" aria-label="Beschriftung von Feld {{ n + 1 }}"></td>
<td class="check-cell"><input type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} aria-label="Feld {{ n + 1 }} ist ein Pflichtfeld"></td>
<td><input name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" aria-label="Hilfetext von Feld {{ n + 1 }}"></td>
<td><input name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" aria-label="Optionen von Feld {{ n + 1 }}" placeholder="nur bei Auswahl"></td>
<td data-label="Beschriftung"><input name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" aria-label="Beschriftung von Feld {{ n + 1 }}"></td>
<td class="check-cell" data-label="Pflichtfeld"><input type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} aria-label="Feld {{ n + 1 }} ist ein Pflichtfeld"></td>
<td data-label="Hilfetext"><input name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" aria-label="Hilfetext von Feld {{ n + 1 }}"></td>
<td data-label="Optionen"><input name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" aria-label="Optionen von Feld {{ n + 1 }}" placeholder="nur bei Auswahl"></td>
</tr>
{% endfor %}
</tbody>
+59
View File
@@ -0,0 +1,59 @@
<div class="section-head">
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
<span class="muted">{{ library.total ? (library.page - 1) * library.perPage + 1 : 0 }}–{{ min(library.page * library.perPage, library.total) }} von {{ library.total }}</span>
</div>
{% if library.items is empty %}
<div class="empty">
<h3>{{ query ? 'Keine passenden Bilder.' : (archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.') }}</h3>
<p>{{ query ? 'Ändere oder leere die Suche.' : (is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.') }}</p>
</div>
{% else %}
<ul class="library-grid">
{% for item in library.items %}
<li class="library-tile">
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
</a>
<div class="library-body">
<strong>{{ item.title }}</strong>
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
<code>{{ item.id }}</code>
</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_media_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="title-{{ item.id }}">Bildtitel</label>
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
<label for="alt-{{ item.id }}">Alternativtext</label>
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
<button class="primary" type="submit">Angaben speichern</button>
</form>
</div>
</details>
{% endif %}
</li>
{% endfor %}
</ul>
{% if library.pages > 1 %}
<nav class="pager" aria-label="Seiten der Mediathek">
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null, q: query}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null, q: query}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
</nav>
{% endif %}
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p>
+10 -64
View File
@@ -1,7 +1,7 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'media' %}
{% block title %}Mediathek{% endblock %}
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script>{% 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 content %}
@@ -9,7 +9,7 @@
{% if is_granted('ROLE_EDITOR') %}
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
<input type="hidden" name="seite" value="{{ library.page }}">
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
{# A drop area rather than the browser's file widget. The input stays a real file input and
stays focusable, so the form works by keyboard and without JavaScript; the script only adds
@@ -30,66 +30,12 @@
</form>
{% endif %}
<div class="section-head">
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
<span class="muted">
{% if archived %}<a href="{{ path('admin_media') }}">Archivierte ausblenden</a>{% else %}<a href="{{ path('admin_media', {archiv: 1}) }}">Archivierte anzeigen</a>{% endif %}
{% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %}
</span>
</div>
{% if library.items is empty %}
<div class="empty">
<h3>{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}</h3>
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}</p>
</div>
{% else %}
<ul class="library-grid">
{% for item in library.items %}
<li class="library-tile">
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
</a>
<div class="library-body">
<strong>{{ item.title }}</strong>
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
<code>{{ item.id }}</code>
</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_media_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="archiv" value="{{ archived ? '1' : '' }}">
<label for="title-{{ item.id }}">Bildtitel</label>
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
<label for="alt-{{ item.id }}">Alternativtext</label>
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
<button class="primary" type="submit">Angaben speichern</button>
</form>
</div>
</details>
{% endif %}
</li>
{% endfor %}
</ul>
{% if library.pages > 1 %}
<nav class="pager" aria-label="Seiten der Mediathek">
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
</nav>
{% endif %}
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p>
<form method="get" action="{{ path('admin_media') }}" class="list-toolbar" role="search" data-live-search>
<div class="list-search"><label for="media-list-search">Bilder durchsuchen</label><input id="media-list-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel oder Alternativtext …"></div>
<div><label for="media-list-archive">Status</label><select id="media-list-archive" name="archiv" data-search-default="0"><option value="0"{{ not archived ? ' selected' : '' }}>Aktive Bilder</option><option value="1"{{ archived ? ' selected' : '' }}>Mit archivierten Bildern</option></select></div>
<button class="secondary search-submit" type="submit">Anwenden</button>
<a class="text-button" data-search-reset href="{{ path('admin_media') }}"{{ not query and not archived ? ' hidden' : '' }}>Zurücksetzen</a>
</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>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
<li class="menu-entry{{ row.level == 2 ? ' menu-child' : '' }}" data-level="{{ row.level }}">
<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 }}">
<details>
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="muted">{{ row.type == 'page' ? 'Inhalt' : 'Eigener Link' }}</span></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">
{% endif %}
</div>
</details>
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
<button class="text-button" type="button" data-menu-action="up" aria-label="{{ row.label }} nach oben">↑</button>
<button class="text-button" type="button" data-menu-action="down" aria-label="{{ row.label }} nach unten">↓</button>
<button class="text-button" type="button" data-menu-action="indent">Als Unterpunkt</button>
<button class="text-button" type="button" data-menu-action="outdent">Hauptebene</button>
<button class="text-button danger-text" type="button" data-menu-action="remove">Entfernen</button>
</div>
</li>
+29 -50
View File
@@ -2,55 +2,34 @@
{% set nav = 'menus' %}
{% block title %}Menüs{% endblock %}
{% block heading %}Menüs{% endblock %}
{% block lead %}Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.{% endblock %}
{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %}
{% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %}
{% for slot, slotName in slots %}
{% set menu = menus[slot]|default(null) %}
<section class="stack-section">
<div class="section-head"><h2>{{ slotName }} <span class="count">{{ rows[slot]|length }}</span></h2><span class="muted">Platz <code>{{ slot }}</code></span></div>
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" class="menu-form card card-body">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}">
<input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
<label for="name-{{ slot }}">Name des Menüs</label>
<input id="name-{{ slot }}" name="name" maxlength="120" required value="{{ menu ? menu.name : slotName }}">
<table class="table menu-table">
<caption class="visually-hidden">Einträge im Menü {{ slotName }}. Eine Beschriftung zu leeren entfernt den Eintrag.</caption>
<thead><tr><th scope="col">Position</th><th scope="col">Ebene</th><th scope="col">Beschriftung</th><th scope="col">Ziel</th><th scope="col">Seite</th><th scope="col">Sprungziel</th><th scope="col">Adresse</th></tr></thead>
<tbody>
{% for index, row in rows[slot]|merge([{label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}, {label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}, {label: '', type: 'page', level: 1, pageId: '', anchor: '', url: ''}]) %}
{% set n = loop.index0 %}
<tr{% if row.label == '' %} class="table-new"{% endif %}>
<td><input type="number" name="items[{{ n }}][position]" value="{{ (n + 1) * 10 }}" min="0" max="9999" step="1" aria-label="Position von Eintrag {{ n + 1 }}"></td>
<td>
<select name="items[{{ n }}][level]" aria-label="Ebene von Eintrag {{ n + 1 }}">
<option value="1"{{ row.level == 1 ? ' selected' : '' }}>1 — oben</option>
<option value="2"{{ row.level == 2 ? ' selected' : '' }}>2 — Untereintrag</option>
</select>
</td>
<td><input name="items[{{ n }}][label]" maxlength="80" value="{{ row.label }}" aria-label="Beschriftung von Eintrag {{ n + 1 }}" placeholder="{{ row.label == '' ? 'Neuer Eintrag' : '' }}"></td>
<td>
<select name="items[{{ n }}][type]" aria-label="Zielart von Eintrag {{ n + 1 }}">
<option value="page"{{ row.type == 'page' ? ' selected' : '' }}>Seite</option>
<option value="url"{{ row.type == 'url' ? ' selected' : '' }}>Adresse</option>
</select>
</td>
<td>
<select name="items[{{ n }}][pageId]" aria-label="Seite von Eintrag {{ n + 1 }}">
<option value="">— Seite wählen —</option>
{% for page in pages %}<option value="{{ page.id }}"{{ row.pageId == page.id ? ' selected' : '' }}>{{ page.title }} (/{{ page.path == 'home' ? '' : page.path }})</option>{% endfor %}
</select>
</td>
<td><input name="items[{{ n }}][anchor]" maxlength="65" value="{{ row.anchor }}" aria-label="Sprungziel von Eintrag {{ n + 1 }}" placeholder="kontakt"></td>
<td><input name="items[{{ n }}][url]" maxlength="2048" value="{{ row.url }}" aria-label="Adresse von Eintrag {{ n + 1 }}" placeholder="https://…"></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="field-help">Ein Sprungziel gilt zusammen mit der gewählten Seite und führt zu <code>/seite#sprungziel</code>. Als Adresse sind HTTPS-Adressen, lokale Pfade sowie <code>mailto:</code> und <code>tel:</code> erlaubt. Die letzten drei Zeilen sind für neue Einträge; beim Speichern werden sie neu durchnummeriert.</p>
<button class="primary" type="submit">{{ slotName }} speichern</button>
</form>
</section>
{% endfor %}
<p class="footnote">KantSites · Arbeitsstand</p>
<nav class="menu-slots" aria-label="Menü auswählen">{% for key, label in slots %}<a href="{{ path('admin_menus', {slot: key}) }}"{{ key == slot ? ' aria-current="page"' : '' }}>{{ label }}</a>{% endfor %}</nav>
<form action="{{ path('admin_menu_save', {slot: slot}) }}" method="post" id="menu-editor" class="menu-workspace menu-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('menus') }}"><input type="hidden" name="expectedVersion" value="{{ menu ? menu.version : 0 }}">
<aside class="menu-add" aria-label="Menüeinträge hinzufügen">
<section class="card card-body"><h2>Seiten und Beiträge</h2>
{% include 'admin/content-picker.html.twig' with {id: 'menu-search', name: 'selected_content', label: 'Inhalte suchen'} %}
<button type="button" class="secondary full-width" id="menu-add-page" disabled>Zum Menü hinzufügen</button>
</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">
<p class="field-help">Auch mailto: und tel: sind möglich.</p>
<button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
</div></details>
<p class="field-help">Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.</p>
</aside>
<section class="card menu-structure" aria-label="Menüstruktur">
<div class="card-head"><h2>{{ slotName }}</h2><span class="count" id="menu-count">{{ rows|length }} Einträge</span></div>
<div class="card-body">
<label for="menu-name">Name des Menüs</label><input id="menu-name" name="name" maxlength="120" required value="{{ menu ? menu.name : slotName }}">
<ol id="menu-items" class="menu-items">{% for row in rows %}{% include 'admin/menu-entry.html.twig' with {row: row, n: loop.index0} %}{% endfor %}</ol>
<p id="menu-empty" class="empty"{{ rows is not empty ? ' hidden' : '' }}>Dieses Menü hat noch keine Einträge. Füge links eine Seite oder einen Link hinzu.</p>
<div class="menu-save"><button class="primary" type="submit">Menü speichern</button><button type="button" class="secondary" id="menu-undo" disabled>Rückgängig</button><span id="menu-status" role="status">Gespeicherter Stand</span></div>
<p id="menu-error" class="notice error" role="alert" hidden></p>
</div>
</section>
</form>
{% endblock %}
+2 -2
View File
@@ -3,8 +3,8 @@
A fixed upper limit shows the first entries and says nothing about the rest; this says both. #}
{% if result.pages > 1 %}
<nav class="pager" aria-label="{{ label|default('Seiten') }}">
{% if result.page > 1 %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page - 1})) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
{% if result.page > 1 %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page - 1})) }}" rel="prev"><span aria-hidden="true">←</span> {{ previousLabel|default('Neuere') }}</a>{% endif %}
<span class="muted">Seite {{ result.page }} von {{ result.pages }} · {{ result.total }} Einträge</span>
{% if result.page < result.pages %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page + 1})) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
{% if result.page < result.pages %}<a class="secondary" href="{{ path(route, params|default({})|merge({seite: result.page + 1})) }}" rel="next">{{ nextLabel|default('Ältere') }} <span aria-hidden="true">→</span></a>{% endif %}
</nav>
{% endif %}
+24 -239
View File
@@ -1,14 +1,18 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'pages' %}
{% block title %}Seiten{% endblock %}
{% block heading %}Seiten und Beiträge{% endblock %}
{% block lead %}Wähle eine Seite, gestalte deinen Entwurf, veröffentliche, wenn alles passt. Entwürfe bleiben bis dahin privat.{% endblock %}
{% set nav = contentType == 'post' ? 'posts' : 'pages' %}
{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %}
{% 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 lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
{% block content %}
{% if is_granted('ROLE_EDITOR') %}
<details class="card"{% if app.flashes is not empty %} open{% endif %}>
<summary>Neue Seite oder neuen Beitrag anlegen</summary>
<summary>{{ contentType == 'post' ? 'Neuen Beitrag' : 'Neue Seite' }} anlegen</summary>
<form action="{{ path('admin_create') }}" method="post" class="create-form card-body">
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<div class="fields">
<div>
@@ -21,250 +25,31 @@
<input id="new-slug" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" autocomplete="off" aria-describedby="new-slug-help">
<p class="field-help" id="new-slug-help">Kleinbuchstaben, Ziffern und Bindestriche, zum Beispiel <code>ueber-uns</code>. Leer lassen, um sie aus dem Titel zu bilden.</p>
</div>
<input type="hidden" id="new-type" name="type" value="{{ contentType }}">
{% if contentType == 'post' %}
<div>
<label for="new-type">Art</label>
<select id="new-type" name="type">
<option value="page">Seite</option>
<option value="post">Beitrag</option>
</select>
<p class="field-help">Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.</p>
</div>
<div>
<label for="new-layout">Aufbau (nur für Beiträge)</label>
<label for="new-layout">Beitragsvorlage</label>
<select id="new-layout" name="layout">
{% for key, layout in postLayouts %}<option value="{{ key }}"{{ key == 'bildkopf' ? ' selected' : '' }}>{{ layout.name }}</option>{% endfor %}
</select>
<p class="field-help">{% for key, layout in postLayouts %}<strong>{{ layout.name }}:</strong> {{ layout.description }}{% if not loop.last %} <br>{% endif %}{% endfor %} Jeder Block lässt sich danach ändern.</p>
</div>
<div>
<label for="new-parent">Übergeordnete Seite</label>
<select id="new-parent" name="parent_id">
<option value="">— keine, direkt unter der Startseite —</option>
{% for parent in parents %}<option value="{{ parent.id }}">{{ parent.title }} (/{{ parent.path }})</option>{% endfor %}
</select>
<p class="field-help">Bestimmt die Adresse, zum Beispiel <code>/leistungen/webdesign</code>.</p>
</div>
{% else %}
{% include 'admin/content-picker.html.twig' with {id: 'new-parent', name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true} %}
{% endif %}
</div>
<button class="primary" type="submit">Anlegen und bearbeiten</button>
</form>
</details>
{% endif %}
<div class="section-head"><h2>Alle Seiten <span class="count">{{ pages|length }}</span></h2><span class="muted">Untergeordnete Seiten stehen eingerückt.</span></div>
<div class="rows">
{% for page in pages %}
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
an attribute here would simply be dropped and the tree would look flat. #}
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
<div class="row-line">
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
<div class="row-main">
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
</div>
{% if page.published_revision_id is null %}
<span class="pill pill-off">Nicht veröffentlicht</span>
{% elseif page.published_revision_id == page.draft_revision_id %}
<span class="pill pill-live">Veröffentlicht</span>
{% else %}
<span class="pill pill-draft">Entwurf geändert</span>
{% endif %}
<div class="row-actions">
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
</div>
</div>
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
{# One disclosure per row instead of six popovers next to each other: everything that is
not the daily „bearbeiten“ lives here and opens in place. #}
<details class="sheet">
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
<div class="sheet-body">
{% if is_granted('ROLE_EDITOR') %}
<div class="sheet-part">
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="fields">
<div>
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
</div>
<div>
<label for="robots-{{ page.id }}">Suchmaschinen</label>
<select id="robots-{{ page.id }}" name="robots">
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
</select>
<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">
</div>
{% if page.type == 'post' %}
<div>
<label for="date-{{ page.id }}">Veröffentlicht am</label>
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
</div>
<div>
<label for="author-{{ page.id }}">Autor</label>
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
</div>
<div>
<label for="categories-{{ page.id }}">Kategorien</label>
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
</div>
<div>
<label for="tags-{{ page.id }}">Schlagwörter</label>
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
<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">
</div>
<div>
<label for="excerpt-{{ page.id }}">Auszug</label>
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
</div>
{% endif %}
</div>
<button class="primary" type="submit">Speichern</button>
</form>
</div>
{% endif %}
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
<div class="sheet-part">
<h4>Adresse</h4>
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="fields">
<div>
<label for="slug-{{ page.id }}">Adresssegment</label>
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
</div>
<div>
<label for="parent-{{ page.id }}">Übergeordnete Seite</label>
<select id="parent-{{ page.id }}" name="parent_id">
<option value="">— keine, direkt unter der Startseite —</option>
{% for parent in parents|filter(candidate => candidate.id != page.id) %}
<option value="{{ parent.id }}"{{ page.parent_id == parent.id ? ' selected' : '' }}>{{ parent.title }} (/{{ parent.path }})</option>
{% endfor %}
</select>
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
</div>
</div>
<button class="secondary" type="submit">Adresse ändern</button>
</form>
</div>
{% endif %}
{% if is_granted('ROLE_EDITOR') %}
<div class="sheet-part">
<h4>Kopf- und Fußbereich</h4>
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="fields">
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
<div>
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
{% endif %}{% endfor %}
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
</select>
</div>
{% endfor %}
</div>
<button class="secondary" type="submit">Bereiche speichern</button>
</form>
</div>
{% endif %}
{% if is_granted('ROLE_PUBLISHER') %}
<div class="sheet-part">
<h4>Veröffentlichung planen</h4>
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<div class="field">
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
</div>
<button class="secondary" type="submit">Zeitpunkt speichern</button>
</form>
</div>
{% endif %}
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
<div class="sheet-part">
<h4>Entfernen</h4>
<div class="sheet-actions">
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<button class="secondary" type="submit">In den Papierkorb</button>
</form>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
{# The class is what the security test looks for: an editor must not even be
offered this. #}
<div class="delete-panel">
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
</form>
</div>
{% endif %}
</div>
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
</div>
{% endif %}
</div>
</details>
{% endif %}
</article>
{% else %}
<div class="empty"><h3>Noch keine Seiten vorhanden.</h3><p>Lege oben deine erste Seite an.</p></div>
{% endfor %}
</div>
{% if trash is not empty %}
<div class="section-head"><h2>Papierkorb <span class="count">{{ trash|length }}</span></h2><span class="muted">Nicht öffentlich erreichbar, alle Fassungen erhalten.</span></div>
<div class="rows">
{% for entry in trash %}
<article class="row">
<span class="row-icon" aria-hidden="true">🗑</span>
<div class="row-main">
<h3>/{{ entry.path }}</h3>
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
</div>
{% if is_granted('ROLE_EDITOR') %}
<div class="row-actions">
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
<button class="secondary" type="submit">Wiederherstellen</button>
</form>
</div>
{% endif %}
</article>
{% endfor %}
</div>
{% endif %}
<p class="footnote">KantSites · Arbeitsstand</p>
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search" data-live-search>
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
<div><label for="page-status">Status</label><select id="page-status" name="status" data-search-default="all">{% for value, label in {all: 'Alle aktiven', draft: 'Entwürfe', published: 'Veröffentlicht', changed: 'Entwurf geändert', trash: 'Papierkorb'} %}<option value="{{ value }}"{{ status == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>
<div><label for="page-limit">Pro Seite</label><select id="page-limit" name="limit" data-search-default="20">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
<button class="secondary search-submit" type="submit">Anwenden</button>
<a class="text-button" data-search-reset href="{{ path(listRoute) }}"{{ not query and status == 'all' and result.limit == 20 ? ' hidden' : '' }}>Zurücksetzen</a>
</form>
{% include 'admin/search-feedback.html.twig' %}
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/content-results.html.twig' %}</div>
{% endblock %}
+1 -1
View File
@@ -19,7 +19,7 @@
<select id="new-kind" name="kind">
{% for value, label in kinds %}<option value="{{ value }}"{{ value == 'section' ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
</select>
<p class="field-help">Kopf- und Fußbereich gibt es je einmal.</p>
<p class="field-help">Kopf- und Fußbereiche können je Seite zugewiesen werden. Sektionen lassen sich auf mehreren Seiten verknüpfen.</p>
</div>
<div>
<label for="new-slug">Adresse</label>
@@ -0,0 +1,2 @@
<p id="search-status" class="field-help search-status" role="status" aria-live="polite" aria-atomic="true"></p>
<div id="search-error" class="notice error" hidden><p role="alert"></p><button id="search-retry" class="secondary" type="button">Erneut suchen</button></div>
+3 -3
View File
@@ -20,7 +20,7 @@
<summary>
<svg class="shell-menu-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
<span class="shell-menu-label">{{ {
'pages': 'Seiten', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
'pages': 'Seiten', 'posts': 'Beiträge', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design',
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer',
'account': 'Mein Konto'
@@ -31,6 +31,7 @@
<ul class="nav-list" aria-labelledby="nav-content">
{% set entries = [
{id: 'pages', route: 'admin_pages', icon: 'pages', label: 'Seiten'},
{id: 'posts', route: 'admin_posts', icon: 'pages', label: 'Beiträge'},
{id: 'partials', route: 'admin_partials', icon: 'partials', label: 'Bereiche'},
{id: 'menus', route: 'admin_menus', icon: 'menus', label: 'Menüs'},
{id: 'media', route: 'admin_media', icon: 'media', label: 'Mediathek'},
@@ -49,13 +50,12 @@
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
<li><a class="nav-item nav-out" href="/">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Website ansehen</a></li>
<li><a class="nav-item nav-out" href="/beitraege">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Beitragsübersicht</a></li>
</ul>
</nav>
</details>
<div class="shell-foot">
<p class="shell-version">Fassung {{ kant_version() }}</p>
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
<a class="shell-user" href="{{ path('admin_account') }}" aria-label="Mein Konto: {{ app.user.userIdentifier }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
<form action="{{ path('admin_logout') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
<button class="subtle" type="submit">Abmelden</button>
+12
View File
@@ -22,6 +22,18 @@
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
{% endif %}
</dd>
<dt>Datenbank</dt>
<dd>
<strong>{{ database.art }}</strong>
{% if database.datei %}
<code>{{ database.datei }}</code>
{% if database.bytes %}<span class="muted">· {{ (database.bytes / 1048576)|number_format(1, ',', '.') }} MB</span>{% endif %}
{% if not database.schreibbar %}<br><span class="check-bad"><strong>Nicht beschreibbar.</strong> Nach einem Upload per FTP gehört die Datei dem FTP-Benutzer; PHP läuft als ein anderer. SQLite braucht Schreibrechte auf die Datei <em>und</em> auf den Ordner darüber.</span>{% endif %}
{% else %}
<code>{{ database.adresse }}</code>
{% endif %}
<br><span class="muted">Eingestellt in <code>{{ database.quelle }}</code>. Die Installationskonfiguration steht in <code>.env</code>. Explizite 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>
{% else %}
+2 -1
View File
@@ -47,7 +47,8 @@
{% if hasPosts|default(false) %} <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% endif %}
{% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsSlider|default(false) %}<script src="/assets/slider.js?v={{ kant_version() }}" defer></script>{% endif %}
{% 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 needsZoom|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% 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. #}
+63 -36
View File
@@ -85,6 +85,43 @@ final class CmsTest extends TestCase
}
}
public function testEditorPaginationAndChoicesKeepTypesFiltersAndParentLookup(): void
{
$ids = [];
for ($i = 0; $i < 43; ++$i) {
$document = DemoContent::pages()['home'];
$document['title'] = 'Eintrag '.sprintf('%02d', $i);
$ids[] = $this->pages->createPage('eintrag-'.sprintf('%02d', $i), null, 'post', $document);
}
$first = $this->pages->editorPage('post');
$second = $this->pages->editorPage('post', 2);
self::assertSame(43, $first['total']);
self::assertCount(20, $first['items']);
self::assertCount(20, $second['items']);
self::assertSame([], array_intersect(array_column($first['items'], 'id'), array_column($second['items'], 'id')));
self::assertCount(3, $this->pages->editorPage('post', 999)['items']);
self::assertSame(3, $this->pages->editorPage('post', 999)['page']);
self::assertCount(43, $this->pages->editorPage('post', -1, 50)['items']);
self::assertSame(2, $this->pages->editorPage('page')['total']);
self::assertSame(1, $this->pages->editorPage('post', query: 'Eintrag 42')['total']);
self::assertSame(0, $this->pages->editorPage('post', query: '%')['total'], 'LIKE wildcards are literal search text.');
self::assertSame(0, $this->pages->editorPage('post', query: '_')['total']);
$saved = $this->pages->findForEditor($ids[0]);
$this->pages->publish($ids[0], $saved['draft_revision_id'], $saved['version']);
self::assertSame(1, $this->pages->editorPage('post', status: 'published')['total']);
self::assertSame(42, $this->pages->editorPage('post', status: 'draft')['total']);
$this->pages->saveDraft($ids[0], DemoContent::pages()['home'], $saved['version'] + 1);
self::assertSame(1, $this->pages->editorPage('post', status: 'changed')['total']);
$this->db->update('page', ['deleted_at' => gmdate('Y-m-d H:i:s')], ['id' => $ids[42]]);
self::assertSame(42, $this->pages->editorPage('post')['total']);
self::assertSame(1, $this->pages->editorPage('post', status: 'trash')['total']);
self::assertCount(20, $this->pages->choices(type: 'post'));
self::assertSame([], $this->pages->choices(id: $ids[42]));
self::assertCount(2, $this->pages->choices(parentsOnly: true));
self::assertSame('page-2', $this->pages->choices(id: 'page-2', parentsOnly: true)[0]['id']);
self::assertSame([], $this->pages->choices(id: $ids[1], parentsOnly: true));
}
public function testDocumentRoundTripAndDraftPrivacy(): void
{
$document = DemoContent::pages()['home'];
@@ -690,11 +727,8 @@ final class CmsTest extends TestCase
}
}
public function testASliderAdvancesByItselfOnlyWhenItWasAskedTo(): void
public function testSliderControlsLoadOnlyForPagesWithSliders(): void
{
// Autorotation braucht ein Skript - das einzige auf einer öffentlichen Seite außer den
// Tabs. Es kommt nur auf Seiten, die einen solchen Slider tatsächlich enthalten, und die
// Richtlinie bleibt sonst bei script-src 'none'.
$document = DemoContent::starter('Mit Slider');
$document['schemaVersion'] = 17;
$document['blocks'][] = ['id' => 'slider', 'type' => 'slider', 'props' => [
@@ -703,22 +737,26 @@ final class CmsTest extends TestCase
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$ohne = $this->request('/');
self::assertStringNotContainsString('slider.js', $ohne->getContent());
self::assertStringContainsString("script-src 'none'", (string) $ohne->headers->get('Content-Security-Policy'));
self::assertStringContainsString('slider.js?v=', $ohne->getContent());
self::assertStringNotContainsString('data-autoplay=', $ohne->getContent());
self::assertStringContainsString("script-src 'self'", (string) $ohne->headers->get('Content-Security-Policy'));
$document['blocks'][array_key_last($document['blocks'])]['props']['autoplay'] = 7;
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$mit = $this->request('/');
self::assertStringContainsString('<script src="/assets/slider.js?v=', $mit->getContent());
self::assertStringContainsString('<script type="module" src="/assets/slider.js?v=', $mit->getContent());
self::assertStringContainsString('data-autoplay="7"', $mit->getContent());
self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy'));
// Das Skript hält sich an die Bewegungsschranke und hört beim ersten Eingriff auf.
$skript = (string) file_get_contents(__DIR__.'/../public/assets/slider.js');
self::assertStringContainsString("matchMedia('(prefers-reduced-motion: reduce)')", $skript);
self::assertStringContainsString("'pointerdown', 'keydown', 'touchstart', 'wheel'", $skript);
// Removing the block removes the public dependency and closes the script policy again.
array_pop($document['blocks']);
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$plain = $this->request('/');
self::assertStringNotContainsString('slider.js', $plain->getContent());
self::assertStringContainsString("script-src 'none'", (string) $plain->headers->get('Content-Security-Policy'));
}
public function testTheSamePostsCanBeShownInThreeWays(): void
@@ -770,30 +808,19 @@ final class CmsTest extends TestCase
(new DocumentValidator())->validate($document);
}
public function testEntranceEffectsRunOnScrollWhereTheBrowserCanAndOnLoadOtherwise(): void
public function testEntranceEffectsLoadOnlyWhenUsedAndKeepTheirSelectionInHtml(): void
{
// Umut: „Effekte gibt es keine“. Es gab sie - aber nur beim Zeigen, nicht beim Laden, und
// eine Einblendung beim Laden ist unterhalb der Falz wertlos: sie ist vorbei, bevor
// jemand hinsieht. Mit einer Scroll-Zeitachse läuft dieselbe Animation, wenn der
// Abschnitt in den Blick kommt - ohne eine Zeile JavaScript.
$css = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
self::assertStringContainsString('@supports (animation-timeline: view())', $css);
self::assertStringContainsString('animation-timeline: view()', $css);
// Beides steht hinter der Bewegungsschranke, und der Rückfall ist die Fassung von vorher:
// ein Browser ohne Scroll-Zeitachse zeigt alles, nur eben beim Laden animiert.
$geschuetzt = explode('@media (prefers-reduced-motion: no-preference) {', $css);
self::assertStringNotContainsString('animation-timeline', $geschuetzt[0], 'Keine Bewegung außerhalb der Schranke.');
self::assertSame(1, substr_count($css, '@supports (animation-timeline: view())'));
// Der wichtigste Teil, und er ist mit Messen bezahlt: auf dem Scrollweg wird **keine
// Deckkraft** animiert. Eine Scroll-Zeitachse ist umkehrbar, also stünde alles unterhalb
// der Falz dauerhaft am Anfang der Kurve - bei Deckkraft 0,85 wird aus #3d4d5c ein
// #78838d, und damit 3,86:1 statt 7,1:1. axe hat genau das gemeldet.
self::assertStringContainsString('@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }', $css);
self::assertStringContainsString('@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }', $css);
$scrollteil = explode('@supports (animation-timeline: view())', $css)[1];
self::assertStringNotContainsString('opacity', explode('.fx-delay-long', $scrollteil)[0], 'Auf dem Scrollweg bleibt der Text voll deckend.');
$document = DemoContent::starter('Mit Bewegung');
$document['schemaVersion'] = 19;
$document['blocks'][0]['layout']['effects']['entrance'] = 'left';
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$response = $this->request('/');
self::assertStringContainsString('fx-entrance-left', $response->getContent());
self::assertStringContainsString('motion.js?v=', $response->getContent());
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
unset($document['blocks'][0]['layout']['effects']['entrance']);
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 2), 3);
self::assertStringNotContainsString('motion.js?v=', $this->request('/')->getContent());
// Und die drei Beitragsvorlagen bringen die Einblendung von sich aus mit.
foreach (array_keys(PostLayouts::all()) as $key) {
@@ -3307,8 +3334,8 @@ final class CmsTest extends TestCase
foreach ($expected as $needle) {
self::assertStringContainsString($needle, $html, $type);
}
// No script is needed for any of them: the public page still forbids scripting.
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
// Sliders receive controls; the other blocks keep a script-free public page.
self::assertStringContainsString('slider' === $type ? "script-src 'self'" : "script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
}
/** @return iterable<string, array{string, array<string, mixed>}> */
+105 -1
View File
@@ -82,6 +82,34 @@ final class EditorSecurityTest extends WebTestCase
return $client;
}
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
{
$client = $this->login();
$partials = new PartialRepository($this->db, new DocumentValidator());
$partials->create('section', 'shared-test', 'Gemeinsamer Abschnitt', DemoContent::areas()['footer']['document']);
foreach ($partials->all() as $partial) {
$base = '/admin/partials/'.$partial['id'];
$client->request('GET', $base);
self::assertResponseIsSuccessful();
self::assertSelectorExists('#editor[data-api="'.$base.'"]');
self::assertSelectorNotExists('#publish');
$client->request('GET', $base.'/document');
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
$document = $data['document'];
$document['title'] .= ' bearbeitet';
$headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']];
$body = json_encode(['document' => $document, 'expectedVersion' => $data['version']], JSON_THROW_ON_ERROR);
$client->request('POST', $base.'/preview', server: $headers, content: $body);
self::assertResponseIsSuccessful();
$client->request('POST', $base.'/draft', server: $headers, content: $body);
self::assertResponseIsSuccessful();
$client->request('POST', $base.'/draft', server: $headers, content: $body);
self::assertResponseStatusCodeSame(409);
$client->request('GET', $base.'/document');
self::assertSame($document['title'], json_decode($client->getResponse()->getContent(), true)['document']['title']);
}
}
private function data(KernelBrowser $client): array
{
$client->request('GET', '/admin/api/pages/p1');
@@ -114,6 +142,47 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
}
public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void
{
$client = self::createClient();
foreach (['/admin', '/admin/posts', '/admin/media'] as $url) {
$client->request('GET', $url.'?q=secret', server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', 'HTTP_ACCEPT' => 'application/json']);
self::assertResponseRedirects('http://localhost/admin/login');
self::assertStringNotContainsString('"html":', $client->getResponse()->getContent());
}
}
public function testLiveSearchReturnsFilteredEscapedResultsWithTheSamePermissionsAsTheFullScreen(): void
{
$client = $this->login('editor');
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
foreach (['page', 'post'] as $type) {
$document = DemoContent::pages()['home'];
$document['title'] = 'Suchprobe Über uns <img src=x onerror=alert(1)> '.$type;
$pages->createPage('suchprobe-'.$type, null, $type, $document);
}
foreach (['/admin' => 'page', '/admin/posts' => 'post'] as $url => $type) {
$headers = ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'];
$client->request('GET', $url.'?q=suchprobe&status=draft&seite=99&limit=50', server: $headers);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'application/json');
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame(1, $data['total']);
self::assertSame(['q' => 'suchprobe', 'status' => 'draft', 'limit' => 50, 'seite' => 1], $data['parameters']);
self::assertStringContainsString('suchprobe-'.$type, $data['html']);
self::assertStringNotContainsString('suchprobe-'.('page' === $type ? 'post' : 'page'), $data['html']);
self::assertStringNotContainsString('<img src=x', $data['html']);
self::assertStringContainsString('&lt;img src=x', $data['html']);
self::assertStringNotContainsString('delete-panel', $data['html']);
self::assertStringNotContainsString('<html', $data['html']);
self::assertStringContainsString('name="_list_query" value="suchprobe"', $data['html']);
$client->request('GET', $url.'?q='.rawurlencode('über uns'), server: $headers);
self::assertSame(1, json_decode($client->getResponse()->getContent(), true)['total']);
$client->request('GET', $url.'?q=suchprobe&status=published', server: $headers);
self::assertSame(0, json_decode($client->getResponse()->getContent(), true)['total']);
}
}
public function testCreatingAPageNeedsACsrfTokenAndStartsAsAPrivateDraft(): void
{
$client = $this->login('editor');
@@ -525,7 +594,7 @@ final class EditorSecurityTest extends WebTestCase
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Nochmal', 'alt' => '', 'version' => 1]);
self::assertResponseRedirects('/admin/media');
self::assertSame('Bergblick', $this->db->fetchOne('SELECT title FROM media WHERE id = ?', [$id]));
self::assertCount(1, $client->followRedirect()->filter('.notice.error'));
self::assertCount(1, $client->followRedirect()->filter('.notice.error:not([hidden])'));
} finally {
unlink($path);
}
@@ -565,6 +634,41 @@ final class EditorSecurityTest extends WebTestCase
self::assertResponseRedirects('/admin/media?seite=2');
}
public function testMediaLiveSearchCoversAllPagesAltTextArchiveAndLiteralWildcards(): void
{
$client = $this->login('editor');
for ($number = 1; $number <= 27; ++$number) {
$this->db->insert('media', [
'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT),
'title' => 1 === $number ? 'Seltenes Motiv 100%_!' : 'Bild '.$number,
'alt' => 2 === $number ? 'Einzigartiger Alternativtext Äußerer Bereich' : '',
'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]),
'archived' => 27 === $number ? 1 : 0,
'version' => 1,
'created_at' => gmdate('Y-m-d H:i:s', 1700000000 + $number),
]);
}
foreach ([['SELTENES', false, 1], ['ALTERNATIVTEXT', false, 1], ['äußerer', false, 1], ['%_!', false, 1], ['Bild', false, 25], ['Bild', true, 26], ['fehlt', false, 0]] as [$query, $archived, $total]) {
$parameters = ['q' => $query, 'archiv' => $archived ? 1 : 0, 'seite' => 99];
$client->request('GET', '/admin/media?'.http_build_query($parameters), server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']);
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame($total, $data['total'], $query);
self::assertSame($total > 24 ? 2 : 1, $data['parameters']['seite']);
self::assertStringNotContainsString('<html', $data['html']);
}
// The HTML fallback uses the same global search, and saving retains its context.
$crawler = $client->request('GET', '/admin/media?q=Seltenes');
self::assertCount(1, $crawler->filter('.library-tile'));
$form = $crawler->filter('.library-tile form')->form(['alt' => 'Geändert']);
$client->submit($form);
self::assertResponseRedirects('/admin/media?q=Seltenes');
self::assertCount(1, $client->followRedirect()->filter('.library-tile'));
$crawler = $client->request('GET', '/admin/media?q=0');
$client->submit($crawler->filter('.library-tile form')->first()->form(['alt' => 'Ziffernsuche']));
self::assertResponseRedirects('/admin/media?q=0');
}
public function testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
{
$client = $this->login('editor');
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Tests;
use App\Ops\Environment;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Dotenv\Dotenv;
final class EnvironmentTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = sys_get_temp_dir().'/kant-environment-'.bin2hex(random_bytes(6));
mkdir($this->root);
file_put_contents($this->root.'/.env.example', "APP_ENV=prod\nDATABASE_URL=sqlite:///default.db\nMAILER_DSN=null://null\n");
}
protected function tearDown(): void
{
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($this->root);
}
public function testNewInstallationUsesOnlyEnvAndKeepsSecretAcrossRequests(): void
{
Environment::prepare($this->root);
$secret = Environment::ensureSecret($this->root);
Environment::write($this->root, ['DATABASE_URL' => 'sqlite:///customer.db']);
Environment::prepare($this->root);
self::assertSame($secret, Environment::ensureSecret($this->root));
self::assertSame('sqlite:///customer.db', $this->values()['DATABASE_URL']);
self::assertFileDoesNotExist($this->root.'/.env.local');
}
public function testLegacySettingsAreConsolidatedOnceWithBothOriginalsBackedUp(): void
{
Environment::prepare($this->root);
$legacy = "DATABASE_URL=sqlite:///live.db\nAPP_SECRET=existing-secret\nCUSTOM_OPTION=kept\n";
file_put_contents($this->root.'/.env.local', $legacy);
Environment::prepare($this->root);
self::assertSame('sqlite:///live.db', $this->values()['DATABASE_URL']);
self::assertSame('existing-secret', Environment::ensureSecret($this->root));
self::assertSame('kept', $this->values()['CUSTOM_OPTION']);
self::assertFileDoesNotExist($this->root.'/.env.local');
$backups = glob($this->root.'/var/backups/environment-*/.env.local');
self::assertCount(1, $backups);
self::assertSame($legacy, file_get_contents($backups[0]));
Environment::write($this->root, ['DATABASE_URL' => 'sqlite:///chosen.db']);
Environment::prepare($this->root);
self::assertSame('sqlite:///chosen.db', $this->values()['DATABASE_URL'], 'No legacy override after editing .env.');
self::assertCount(1, glob($this->root.'/var/backups/environment-*'));
}
public function testSecretsAndCustomSettingsRoundTripWithoutInterpolation(): void
{
Environment::prepare($this->root);
$value = 'a$NOT_AN_ENV_VALUE"b\\c';
Environment::write($this->root, ['APP_SECRET' => $value, 'CUSTOM_OPTION' => "one\ntwo"]);
Environment::write($this->root, ['SITE_ORIGIN' => 'https://example.org']);
self::assertSame($value, $this->values()['APP_SECRET']);
self::assertSame("one\ntwo", $this->values()['CUSTOM_OPTION']);
self::assertSame('null://null', $this->values()['MAILER_DSN']);
}
public function testFailedMigrationPreservesBothOriginalFiles(): void
{
Environment::prepare($this->root);
$before = file_get_contents($this->root.'/.env');
$legacy = "APP_SECRET=preserved\nDATABASE_URL=sqlite:///live.db\n";
file_put_contents($this->root.'/.env.local', $legacy);
unlink($this->root.'/.env.lock');
mkdir($this->root.'/.env.lock'); // An unwritable lock target, reproducible on Windows/Linux.
try {
Environment::prepare($this->root);
self::fail('Migration must report failure.');
} catch (\RuntimeException $error) {
self::assertStringContainsString('Schreibrechte', $error->getMessage());
}
self::assertSame($before, file_get_contents($this->root.'/.env'));
self::assertSame($legacy, file_get_contents($this->root.'/.env.local'));
}
public function testBootstrapIgnoresOtherOverlaysButHonorsExplicitServerVariables(): void
{
mkdir($this->root.'/config');
mkdir($this->root.'/vendor');
copy(dirname(__DIR__).'/config/bootstrap.php', $this->root.'/config/bootstrap.php');
file_put_contents($this->root.'/vendor/autoload.php', '<?php require '.var_export(dirname(__DIR__).'/vendor/autoload.php', true).';');
file_put_contents($this->root.'/.env', "APP_ENV=prod\nAPP_DEBUG=0\nAPP_SECRET=fixture-secret\nDATABASE_URL=sqlite:///chosen.db\n");
file_put_contents($this->root.'/.env.prod', "DATABASE_URL=sqlite:///wrong.db\n");
file_put_contents($this->root.'/.env.prod.local', "DATABASE_URL=sqlite:///also-wrong.db\n");
file_put_contents($this->root.'/.env.local.php', '<?php return ["DATABASE_URL" => "sqlite:///cached-wrong.db"];');
file_put_contents($this->root.'/boot.php', '<?php require __DIR__."/config/bootstrap.php"; echo $_SERVER["DATABASE_URL"];');
foreach ([null, 'sqlite:///server.db'] as $override) {
$environment = getenv();
unset($environment['DATABASE_URL']);
$environment['APP_ENV'] = 'prod';
if (null !== $override) {
$environment['DATABASE_URL'] = $override;
}
$process = proc_open([PHP_BINARY, $this->root.'/boot.php'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $this->root, $environment);
self::assertIsResource($process);
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $error);
self::assertSame($override ?? 'sqlite:///chosen.db', $output);
self::assertFileDoesNotExist($this->root.'/.env.local');
}
}
private function values(): array
{
return (new Dotenv())->parse((string) file_get_contents($this->root.'/.env'));
}
}
+2 -2
View File
@@ -67,7 +67,7 @@ final class InstallFlowTest extends TestCase
$database = $this->request('/install/datenbank');
$response = $this->submit('/install/datenbank', $database, ['driver' => 'sqlite', 'name' => 'cms']);
self::assertSame('/install/migration', $response->headers->get('Location'));
$environment = (string) file_get_contents($this->root.'/.env.local');
$environment = (string) file_get_contents($this->root.'/.env');
self::assertStringContainsString('DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"', $environment);
self::assertStringContainsString('APP_ENV="prod"', $environment);
@@ -115,7 +115,7 @@ final class InstallFlowTest extends TestCase
// And a form without a valid signature changes nothing: the page comes back, unchanged.
$response = $this->request('/install/datenbank', 'POST', ['driver' => 'sqlite', 'name' => 'cms', 'issued' => time(), 'signature' => 'erfunden']);
self::assertSame(200, $response->getStatusCode());
self::assertFileDoesNotExist($this->root.'/.env.local');
self::assertFileDoesNotExist($this->root.'/.env');
}
/** Posts a form with the signature the rendered page carries, the way a browser would. */
+3 -3
View File
@@ -89,7 +89,7 @@ final class InstallationTest extends TestCase
$bad = $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'z']);
self::assertNotNull($bad['error']);
self::assertStringNotContainsString('geheimespasswort', $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'geheimespasswort'])['error'], 'Das Passwort gehört in keine Fehlermeldung.');
self::assertFalse(is_file($this->root.'/.env.local'), 'Eine gescheiterte Prüfung schreibt nichts.');
self::assertFalse(is_file($this->root.'/.env'), 'Eine gescheiterte Prüfung schreibt nichts.');
}
public function testTheEnvironmentFileKeepsWhatIsAlreadyThere(): void
@@ -99,7 +99,7 @@ final class InstallationTest extends TestCase
// The website step adds the address later and must not lose the database doing it.
$this->installation->writeEnvironment(['SITE_ORIGIN' => 'https://example.de']);
$content = (string) file_get_contents($this->root.'/.env.local');
$content = (string) file_get_contents($this->root.'/.env');
self::assertStringContainsString('APP_SECRET="geheim"', $content);
self::assertStringContainsString('DATABASE_URL="sqlite:///x.db"', $content);
self::assertStringContainsString('SITE_ORIGIN="https://example.de"', $content);
@@ -220,7 +220,7 @@ final class InstallationTest extends TestCase
private function settingsOrigin(): string
{
preg_match('/SITE_ORIGIN="([^"]*)"/', (string) file_get_contents($this->root.'/.env.local'), $match);
preg_match('/SITE_ORIGIN="([^"]*)"/', (string) file_get_contents($this->root.'/.env'), $match);
return $match[1] ?? '';
}
+20
View File
@@ -104,6 +104,26 @@ final class SystemScreenTest extends WebTestCase
self::assertStringContainsString('Sicherung', $html);
}
public function testTheScreenNamesTheDatabaseItActuallyUses(): void
{
// Umut hat `var/cms.db` gelöscht, weil in `.env` eine andere Datei stand - und die
// Website war weg. Der Grund: `.env.local` übersteuert `.env`, und der Installer schreibt
// nur `.env.local`. Wer nicht weiß, dass es diese Datei gibt, liest die falsche und
// handelt danach. Eine Verwaltung, die das verschweigt, lädt genau dazu ein.
$client = $this->login();
$client->request('GET', '/admin/system');
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('SQLite', $html);
self::assertStringContainsString(
htmlspecialchars(str_replace('\\', '/', $this->path), ENT_QUOTES),
$html,
'Die Datei, die wirklich benutzt wird, muss dastehen - nicht die, die irgendwo konfiguriert ist.',
);
self::assertStringContainsString('Server-Umgebung', $html, 'Der Test übergibt die Verbindung ausdrücklich als Umgebungsvariable.');
self::assertStringNotContainsString('übersteuert <code>.env</code>', $html);
}
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
{
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
+32 -2
View File
@@ -115,6 +115,22 @@ final class UpdaterTest extends TestCase
self::assertStringContainsString('OK v1.1.0', $log[0]);
}
public function testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings(): void
{
file_put_contents($this->author.'/.env', "DATABASE_URL=sqlite:///release-default.db\n");
$this->release('1.1.0', true);
$this->git(['fetch', '--tags', 'origin'], $this->work);
$this->git(['checkout', 'v1.1.0'], $this->work);
$settings = "DATABASE_URL=sqlite:///customer.db\nAPP_SECRET=customer-secret\n";
file_put_contents($this->work.'/.env', $settings);
$this->git(['update-index', '--assume-unchanged', '.env'], $this->work);
unlink($this->author.'/.env');
$this->release('1.2.0', true);
$result = $this->updater()->update();
self::assertTrue($result['ok'], $this->explain($result));
self::assertSame($settings, file_get_contents($this->work.'/.env'));
}
public function testAFailingSmokeTestRollsBackTheCodeAndTheDatabase(): void
{
// The new version ships a console that fails - as a broken release would.
@@ -126,6 +142,7 @@ final class UpdaterTest extends TestCase
$steps = array_column($result['steps'], 'step');
self::assertContains('Zurück auf den alten Stand', $steps);
self::assertContains('Datenbank zurückschreiben', $steps);
self::assertTrue($this->named($result['steps'], 'Datenbank zurückschreiben', 'step')['ok']);
// The migration is where it broke, so the database had to go back too.
self::assertSame('Migration', $this->firstFailed($result)['step']);
self::assertSame('1.0.0', $updater->version(), 'A failed update leaves the previous version installed.');
@@ -136,6 +153,16 @@ final class UpdaterTest extends TestCase
self::assertStringContainsString('FEHLER v1.2.0', $updater->log()[0]);
}
public function testAFailedDatabaseRestoreKeepsTheSiteInMaintenance(): void
{
$this->release('1.2.0', false, loseBackup: true);
$result = $this->updater()->update();
self::assertFalse($result['ok']);
self::assertFalse($this->named($result['steps'], 'Datenbank zurückschreiben', 'step')['ok']);
self::assertFileExists($this->work.'/'.Updater::MAINTENANCE_FILE);
self::assertNotContains('Wieder online', array_column($result['steps'], 'step'));
}
public function testAnUpdateThatChangesDependenciesStopsWhenComposerIsMissing(): void
{
// The installation carries the lock of 1.0.0; the new version brings a different one. On
@@ -217,7 +244,7 @@ final class UpdaterTest extends TestCase
* Publishes a release: VERSION, a .gitignore that keeps the installation out of Git, and the
* two stub commands the updater calls. `$healthy` decides whether they succeed.
*/
private function release(string $version, bool $healthy, string $lock = 'alt'): void
private function release(string $version, bool $healthy, string $lock = 'alt', bool $loseBackup = false): void
{
file_put_contents($this->author.'/composer.lock', json_encode(['packages' => [$lock]]));
file_put_contents($this->author.'/VERSION', $version."\n");
@@ -234,8 +261,11 @@ final class UpdaterTest extends TestCase
// Stubs, not the real console: this test is about the updater's order and its rollback.
$stub = $healthy
? "<?php\nfwrite(STDOUT, 'OK '.implode(' ', array_slice(\$argv, 1)).PHP_EOL);\nexit(0);\n"
: "<?php\nfwrite(STDOUT, 'Migration gescheitert'.PHP_EOL);\nexit(1);\n";
: "<?php\nfile_put_contents(__DIR__.'/../../var/cms.db', 'Unvollständige Migration');\nfwrite(STDOUT, 'Migration gescheitert'.PHP_EOL);\nexit(1);\n";
file_put_contents($this->author.'/bin/console', $healthy ? $stub : "<?php\nfwrite(STDOUT, 'OK'.PHP_EOL);\nexit(0);\n");
if ($loseBackup) {
$stub = str_replace('exit(1);', "foreach (glob(__DIR__.'/../../var/backups/update-*/cms.db') ?: [] as \$backup) { unlink(\$backup); } exit(1);", $stub);
}
file_put_contents($this->author.'/vendor/bin/doctrine-migrations', $stub);
$this->commit($this->author, 'Fassung '.$version);
$this->git(['tag', 'v'.$version], $this->author);
+29
View File
@@ -0,0 +1,29 @@
<?php
// Read the local pilot only to make a consistent, isolated copy. Never write to its database.
require dirname(__DIR__).'/vendor/autoload.php';
use App\Infrastructure\ConnectionFactory;
$root = dirname(__DIR__);
$source = $argv[1] ?? $root.'/var/kantit.db';
if (!is_file($source)) {
throw new RuntimeException('Die Pilotdatenbank fehlt. Pfad als erstes Argument angeben.');
}
$directory = $root.'/var/browser-tests/pilot-'.bin2hex(random_bytes(6));
mkdir($directory, 0o700, true);
$path = $directory.'/pilot.db';
$original = new SQLite3($source, SQLITE3_OPEN_READONLY);
$copy = new SQLite3($path);
if (!$original->backup($copy)) {
throw new RuntimeException('Die Pilotdatenbank konnte nicht kopiert werden.');
}
$copy->close();
$original->close();
$url = 'sqlite:///'.str_replace('\\', '/', $path);
$db = ConnectionFactory::create($url);
$db->executeStatement('UPDATE scheduled_task SET enabled = 0');
$username = 'qa-'.bin2hex(random_bytes(6));
$password = bin2hex(random_bytes(20));
$db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => '["ROLE_ADMIN"]']);
echo json_encode(['databaseUrl' => $url, 'mediaDirectory' => $root.'/var/kantit-media', 'directory' => $directory, 'username' => $username, 'password' => $password, 'homeId' => $db->fetchOne("SELECT id FROM page WHERE path = 'home'")], JSON_THROW_ON_ERROR);