Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
495b26896e | ||
|
|
b164c4edfe | ||
|
|
6a044e59e9 | ||
|
|
149e1fe520 | ||
|
|
b783d9d1e5 | ||
|
|
05ea703655 | ||
|
|
d1ac9d760b |
No files matched your search
@@ -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
@@ -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
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console lint:twig templates && php bin/console lint:yaml config && php bin/console lint:container
|
||||
- run: php bin/console lint:twig templates && php bin/console lint:yaml config --parse-tags && php bin/console lint:container
|
||||
- run: composer build
|
||||
browser:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
/var/
|
||||
/node_modules/
|
||||
/.env.local
|
||||
/.env
|
||||
/.env.lock
|
||||
/.env.*.tmp
|
||||
/.env.*.local
|
||||
/phpunit.cache/
|
||||
/reports/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -42,7 +42,17 @@ Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern u
|
||||
|
||||
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 650 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
|
||||
|
||||
Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durch Skalierung an den verfügbaren Platz an. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
Die Vorschau rendert auf Desktop mit mindestens 1240 px Layoutbreite und wächst bei mehr Platz mit. Tablet/Mobil verwenden 768/390 px; bei weniger Platz wird die Vorschau passend skaliert. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
|
||||
### 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.
|
||||
|
||||
Text und Überschriften nutzen die Containerbreite. Die Sektion wählt unter **Inhalt → Inhaltsbreite** zwischen Websitebreite und randloser Ausgabe. **Design → Größe → Maximale Breite** begrenzt ein einzelnes Element ausdrücklich, etwa auf `60ch`; „Ohne Begrenzung“ hebt einen eigenen Grenzwert auf. Der Feinschliff zeigt passende Optionen für Text, Bild oder Container zuerst. **Alle Eigenschaften anzeigen** macht die übrigen Optionen zugänglich. Bereits gespeicherte Werte bleiben sichtbar.
|
||||
|
||||
## Produktionsmodus lokal prüfen
|
||||
|
||||
@@ -61,7 +71,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.
|
||||
|
||||
@@ -90,7 +100,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.
|
||||
|
||||
@@ -104,6 +114,25 @@ Die Formate v1/v2/v3 und erlaubten Blöcke stehen in `src/Content/DocumentValida
|
||||
|
||||
Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-draft PAGE_ID PFAD_ZUR_DATEI ERWARTETE_VERSION` speichern. Der Befehl gibt die neue Revisions-ID aus. Die redaktionelle Bearbeitung und Veröffentlichung erfolgen jetzt über den geschützten Editor.
|
||||
|
||||
## Wiederkehrende Aufgaben
|
||||
|
||||
Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln.
|
||||
|
||||
Vier Auslöser, in aufsteigender Verlässlichkeit:
|
||||
|
||||
1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an.
|
||||
2. **Geöffnete Verwaltung** – das Backend fragt jede Minute nach, solange der Reiter sichtbar ist. Damit läuft der Plan auch auf einer Website ohne Besucher. Standardmäßig an.
|
||||
3. **URL-Cronjob** – wenn der Hoster nur das Aufrufen einer Adresse anbietet: die Adresse steht auf dem Bildschirm „Aufgaben“ (`/cms-cron/<Schlüssel>`, aus `APP_SECRET` abgeleitet), zum Beispiel alle fünf Minuten.
|
||||
4. **Konsole** – `php bin/console cms:tasks:run`, für einen echten Cronjob jede Minute:
|
||||
|
||||
```
|
||||
* * * * * cd /pfad/zur/installation && php bin/console cms:tasks:run
|
||||
```
|
||||
|
||||
`cms:tasks` zeigt denselben Plan wie die Verwaltung (`--log` zusätzlich das Protokoll), `cms:tasks:run --task=backup` startet eine bestimmte Aufgabe sofort, `cms:tasks:run --loop=60 --max-time=3600` läuft als Dauerläufer unter einem Prozessverwalter. Die alten Einzelbefehle `cms:backup`, `cms:stats:prune` und `cms:publish-due` gibt es weiterhin; wer sie in einen Cronjob einträgt, schaltet die entsprechende Aufgabe im Plan besser ab, damit nicht beides läuft.
|
||||
|
||||
**Die Grenze gehört dazu:** ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst beim nächsten Aufruf, vielleicht um 07:15 Uhr. Wer Minutengenauigkeit braucht, richtet Weg 3 oder 4 ein und schaltet den Besuchs-Auslöser ab.
|
||||
|
||||
## PHP-Webhosting
|
||||
|
||||
Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||
@@ -114,8 +143,24 @@ 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 `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` gehören danach in einen täglichen Cronjob – 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.
|
||||
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.
|
||||
|
||||
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/`.
|
||||
+8
-8
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -15,6 +15,12 @@ services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
# Jede Aufgabe traegt sich selbst in den Plan ein: eine Klasse, die App\Ops\Tasks\Task
|
||||
# implementiert, bekommt das Etikett und liegt beim naechsten Abgleich in der Tabelle. Eine
|
||||
# neue Aufgabe ist damit eine Datei und keine Liste, die man an drei Stellen nachfuehrt.
|
||||
_instanceof:
|
||||
App\Ops\Tasks\Task:
|
||||
tags: ['kant.task']
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
@@ -57,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%'
|
||||
@@ -86,6 +95,25 @@ services:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
|
||||
# jedes Mal mitwaechst.
|
||||
App\Ops\Scheduler:
|
||||
arguments:
|
||||
$tasks: !tagged_iterator kant.task
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Ops\Tasks\CleanupTask:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\TaskWorkerSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Controller\TaskController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Controller\CronController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Infrastructure\QueryCounter: ~
|
||||
App\EventSubscriber\QueryHeaderSubscriber:
|
||||
arguments:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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.
|
||||
+80
-3
@@ -1,6 +1,52 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## 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)
|
||||
|
||||
Textblöcke sowie H1/H2 hatten im Basis-CSS feste Höchstbreiten von 690/900/760 px. Sie folgen jetzt der Breite ihres Containers; ausdrücklich gespeicherte `maxWidth`-Werte bleiben wirksam. Sektionsbreiten und Theme-Container bleiben bewusste Layoutentscheidungen. Das verändert gegebenenfalls den Zeilenumbruch bestehender Seiten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Quelle. Kein Dokument wird dafür umgeschrieben, keine Schema- oder Datenbankmigration ist erforderlich. Rückweg: vorheriges Basis-CSS oder eine explizite maximale Breite am betreffenden Block.
|
||||
|
||||
Ein Klick auf „Sektion“ erzeugt eine neue Sektion nach dem obersten Block der aktuellen Auswahl. Die dauerhaft sichtbare Aktion „Neue Sektion auf Seitenebene“ verwirft zuvor gesetzte Einfügepunkte. Bewusst gesetzte Plus-Einfügepunkte und Drag-and-drop dürfen weiterhin verschachteln. Die Einfügeauskunft und die Modellaktion verwenden denselben Zielermittler; Änderungen bleiben einzelne Undo-Schritte.
|
||||
|
||||
Die Seitenleisten sind unabhängig zwischen 240/280 und 560 px verstellbar, soweit wenigstens 280 px für die Vorschau bleiben. Ziehen, Pfeiltasten, Home/End und Doppelklick sind unterstützt. Präferenzen liegen lokal im Browser, außerhalb von Inhalt, Revisionen und Undo. Schmalere Fenster reduzieren die sichtbaren Breiten vorübergehend; bis 850 px folgen die Panels der Vorschau, bis 560 px stehen sie untereinander. Die Vorschau hat keinen zusätzlichen Außenabstand; Desktop wächst oberhalb von 1240 px mit dem verfügbaren Platz, Tablet/Mobil behalten ihre definierten Layoutbreiten.
|
||||
|
||||
Der Feinschliff filtert nach Blocktyp und ordnet passende Gruppen zuerst: Bildausschnitt bei Bildern, Typografie bei Text, Layout bei Containern. „Alle Eigenschaften anzeigen“ öffnet den vollständigen Vertrag. Bereits gespeicherte Eigenschaften bleiben auch außerhalb des üblichen Blockprofils auffindbar; Validierung und gespeichertes Schema ändern sich nicht. Beim Auswahlwechsel beginnt das Panel oben. `auto` beziehungsweise `none` sind für die passenden Größenfelder explizit auswählbar. VERSION 1.6.1 erneuert die Cachekennung aller Editor-Module.
|
||||
|
||||
## ADR 001 – PHP-Webhosting, Symfony 7.4
|
||||
|
||||
@@ -281,7 +327,7 @@ Migration `20260912000900` ergänzt `page.publish_at`, `page.deleted_at` und `re
|
||||
|
||||
`revision.seq` behebt einen echten Fehler, den erst ein Test gezeigt hat: `created_at` hat Sekundengenauigkeit, und mehrere Speichervorgänge fallen in dieselbe Sekunde. Die Reihenfolge im Verlauf war dadurch **nicht eindeutig** – sie richtete sich nach der zufälligen Revisions-ID. Jetzt trägt jede Fassung einen je Seite streng aufsteigenden Zähler; die Migration füllt ihn aus den vorhandenen Zeitstempeln.
|
||||
|
||||
**Geplante Veröffentlichung** setzt `publish_at`. Ein Cron-Aufruf von `cms:publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt. Ohne Cron bleibt die Seite ein Entwurf – die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
|
||||
**Geplante Veröffentlichung** setzt `publish_at`. Die Aufgabe `publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt (ADR 073); ohne einen Auslöser – Besuch, geöffnetes Backend, URL-Cron oder Konsole – bleibt die Seite ein Entwurf, die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
|
||||
|
||||
**Eine frei wählbare Startseite ist nicht umgesetzt.** Der Pfad `home` gilt an elf Stellen als Wurzel der Website – öffentliche Routen, Canonical, Sitemap, `llms.txt`, Menüauflösung. Das zu einer Einstellung zu machen ist ein eigener Schritt; bis dahin behält die Startseite ihre Adresse, statt dass die Hälfte des Systems der Änderung folgt und die andere nicht.
|
||||
|
||||
@@ -438,7 +484,38 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Der Preis steht auf dem Bildschirm.** Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe `statistics` dafür gibt es schon, sie wäre dann ein eigener Baustein.
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides; im Normalbetrieb erledigt das die Aufgabe `stats-prune` des Plans (ADR 073).
|
||||
|
||||
## ADR 073 – Wiederkehrende Aufgaben ohne Cronjob: die Anfrage ist der Auslöser (14.09.2026)
|
||||
|
||||
Bis hierher stand in vier Dokumenten derselbe Satz: „gehört in einen Cronjob". Das ist richtig – und auf dem Hosting, für das dieses CMS gebaut ist, oft nicht einlösbar. Kein SSH, kein Supervisor, manchmal nicht einmal ein Cron-Eintrag im Kundenmenü. Die Folge war nicht „läuft seltener", sondern **läuft nie**: keine Sicherung, keine geplante Veröffentlichung, eine Statistiktabelle, die nur wächst.
|
||||
|
||||
**Die Entscheidung: Was ein Hosting immer hat, sind Anfragen.** Also arbeitet eine Anfrage die Aufgaben ab – in `kernel.terminate`, nach der ausgelieferten Antwort. Unter PHP-FPM ruft Symfony dort `fastcgi_finish_request()` auf: der Browser ist fertig, während hier noch gearbeitet wird. Das ist derselbe Weg, den WordPress (`wp-cron`) und Shopware gehen, und aus demselben Grund.
|
||||
|
||||
Vier Auslöser, eine Mechanik:
|
||||
|
||||
| Auslöser | Wer stößt an | Grenzen je Durchlauf |
|
||||
| --- | --- | --- |
|
||||
| Besuch | jeder Seitenaufruf | 2 Aufgaben, 20 s |
|
||||
| Verwaltung | das geöffnete Backend, im Minutentakt | 3 Aufgaben, 45 s |
|
||||
| URL-Cron | der Hoster ruft `/cms-cron/<Schlüssel>` | 20 Aufgaben, 240 s |
|
||||
| Konsole | `cms:tasks:run`, auch mit `--loop` | frei |
|
||||
|
||||
**Was das nicht kann, steht auf dem Bildschirm und nicht im Kleingedruckten:** Ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr. Für Sitemap, Aufräumen, Updateprüfung und Sicherung ist das brauchbar; für etwas Minutengenaues ist es das nicht, und dann ist einer der beiden unteren Wege die Antwort. Genau dieser Satz steht im Bildschirm „Aufgaben", weil die Alternative ist, dass jemand später an der falschen Stelle sucht.
|
||||
|
||||
Drei Eigenschaften entscheiden darüber, ob das im Betrieb trägt:
|
||||
|
||||
**1. Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht, dazu die zwei Schalter. Liegt der Zeitpunkt in der Zukunft, ist der Abonnent nach einem `file_get_contents` fertig. Fehlt die Datei – frische Installation, geleertes `var/`, kein Schreibrecht –, wird die Datenbank gefragt und die Datei neu geschrieben: der teure Weg ist der Ausnahmefall, nicht der Normalfall, und ein schreibgeschütztes `var/` macht das System langsamer, aber nicht falsch.
|
||||
|
||||
**2. Zwei Besucher führen eine Aufgabe nicht zweimal aus.** Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE … WHERE next_run_at <= :jetzt AND (locked_until IS NULL OR locked_until <= :jetzt)`. Das ist auf MySQL wie auf SQLite atomar; wer keine Zeile verändert hat, war zu spät und geht weiter. Deshalb ist der Plan eine **Tabelle** und keine Datei: eine Dateisperre ist auf geteiltem Hosting nicht zuverlässig, ein bedingtes UPDATE ist es. Die Sperre verfällt nach 15 Minuten von selbst, denn ein Prozess, den das Hosting mitten im Lauf abschießt, kann sie nicht mehr aufräumen.
|
||||
|
||||
**3. Eine Aufgabe darf niemals die Website mitreißen.** Der Abonnent fängt alles. Ein Fehlschlag wird zum sichtbaren Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden) – sonst fängt eine Aufgabe, die nicht laufen *kann*, bei jedem Seitenaufruf neu an. Fehlt die Tabelle, weil Dateien hochgeladen und Migrationen noch nicht ausgeführt wurden, passiert schlicht nichts.
|
||||
|
||||
**Die Sicherung läuft mit, obwohl sie die schwerste Aufgabe ist.** Sie kopiert Datenbank und Medien, und unter Apache mit mod_php belegt sie dabei einen Prozess, weil es dort kein `fastcgi_finish_request()` gibt – die Seite steht längst im Browser, der Prozess bleibt. Der Handel ist trotzdem eindeutig: eine tägliche Sicherung auf einem Hosting ohne Cron ist mehr wert als gar keine. Wer einen echten Cronjob hat, schaltet den Besuchs-Auslöser ab; der Bildschirm sagt auch das.
|
||||
|
||||
**Der Schlüssel des URL-Cronjobs steht in keiner Datenbank.** Er wird aus `APP_SECRET` abgeleitet (`hash_hmac`, 40 Zeichen), ist damit auf jeder Installation ein anderer, überlebt jedes Update und wechselt genau dann, wenn das Geheimnis wechselt. Ein falscher Schlüssel bekommt 404 und nicht 403: wer probiert, soll nicht erfahren, dass es die Stelle gibt.
|
||||
|
||||
**Eine neue Aufgabe ist eine Datei.** Wer `App\Ops\Tasks\Task` implementiert, bekommt über `_instanceof` ein Etikett, liegt beim nächsten Abgleich in der Tabelle und steht im Bildschirm – ohne eine Liste, die an drei Stellen nachzuführen wäre. Verschwindet die Klasse, verschwindet die Zeile. Mitgeliefert sind fünf: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich).
|
||||
|
||||
## ADR 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
|
||||
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
|
||||
5. **Cronjobs.** Täglich `php bin/console cms:backup` und `php bin/console cms:stats:prune`. Ohne den ersten gibt es keine Sicherung außer der von Hand angelegten.
|
||||
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
|
||||
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
|
||||
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
|
||||
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 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.
|
||||
|
||||
`PHP_BINARY=C:\php84\php.exe`, `npm.cmd run test:editor -- --audit`: Lighthouse **13.4.1**, lokaler Symfony-Produktionsmodus mit isolierter Datenbank und den vom Editortest erstellten Inhalten/Bildern. Je **drei** Läufe im Mobil- und Desktopprofil aus `scripts/audit.mjs`. Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing in jedem Lauf und damit in beiden Medianen **100**. Vollständige Geräte-/Drosselungs- und Umgebungseinstellungen sowie Ergebnisse stehen in `reports/lighthouse-editor/summary.json` und den sechs Einzelberichten; funktionale Ergebnisse in `reports/editor/result.json`.
|
||||
|
||||
Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der festen Text-/Überschriftenbreiten. Sie ist keine Abnahme aller Pilot- oder Kundenseiten. Felddaten zu LCP/INP/CLS wurden nicht erhoben; INP wird aus diesem Ladelauf nicht abgeleitet. GEO/Auffindbarkeit ist durch den Agentic-Browsing-Score nicht nachgewiesen.
|
||||
|
||||
## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026
|
||||
|
||||
- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag.
|
||||
|
||||
+100
-1
@@ -1,6 +1,46 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 14.09.2026, Fassung **1.5.4**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
## 1.7.0 – Installation, Verwaltungsabläufe und Bewegung (14.09.2026)
|
||||
|
||||
**Umgesetzt, Veröffentlichung wird geprüft:** `.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.
|
||||
|
||||
**Geprüft:** `npm.cmd test`: 30/30; PHP 8.4.22 `vendor/bin/phpunit`: 452/452, 3476 Assertions. JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig; Produktionscache erfolgreich aufgewärmt, `git diff --check` sauber. `npm.cmd run test:editor -- --audit` vollständig bestanden: Seitenebenen/Undo/Redo, Panelbreiten per Maus/Tastatur und nach Neuladen, 390/850/1100/1920-px-Fenster, kontextbezogene Eigenschaften und bewusst gesetzte Textbreiten; auch Speichern, Neuladen, Veröffentlichen und Konfliktbehandlung. Sämtliche enthaltenen axe-Prüfungen ohne Verstoß. Desktop-, Bild- und Telefonansicht anhand der Browser-Screenshots visuell geprüft. Lighthouse **13.4.1**, isolierte öffentliche Editortestseite im Produktionsmodus: alle fünf Kategorien **100**, jeweils drei Läufe mobil und Desktop, auch jeder Einzellauf 100. Kein Nachweis für beliebige Kundeninhalte oder Feldwerte. Berichte: `reports/editor/result.json`, `reports/lighthouse-editor/summary.json`; Ansichten: `reports/editor/flexible-workspace.png`, `context-image.png`, `editor-phone.png`.
|
||||
|
||||
**Bekannte Grenzen:** Die geänderten Standardbreiten können den Zeilenumbruch bestehender Seiten verändern. Sektions- und ausdrücklich gespeicherte Elementbreiten bleiben wirksam. Kein neuer Screenreader- oder Felddatennachweis. **Nächster Schritt:** Umuts tatsächliche Editorabläufe mit realen Seiten durchgehen, danach den verbliebenen Ausbau gemäß Entwicklungsplan fortsetzen.
|
||||
|
||||
## Vorheriger Stand und bisherige Arbeitspakete
|
||||
|
||||
Stand: 14.09.2026, Fassung **1.6.0**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -14,6 +54,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
|
||||
**Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser.
|
||||
|
||||
**Wiederkehrende Aufgaben ohne Cronjob (14.09.2026, ADR 073):** Bis hierher stand in vier Dokumenten „gehört in einen Cronjob“ – auf geteiltem Hosting oft nicht einlösbar, und die Folge war nicht „läuft seltener“, sondern **läuft nie**. Jetzt gibt es einen Aufgabenplan mit vier Auslösern: **jeder Seitenaufruf** (nach der ausgelieferten Antwort, in `kernel.terminate`), das **geöffnete Backend** (im Minutentakt, nur solange der Reiter sichtbar ist), ein **URL-Cronjob** unter `/cms-cron/<Schlüssel>` für Hoster, die nur eine Adresse aufrufen können, und die **Konsole** (`cms:tasks:run`, auch als Dauerläufer mit `--loop`). Fünf Aufgaben sind angeschlossen: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich). Der Bildschirm **Aufgaben** zeigt Plan, letztes Ergebnis, Dauer, Auslöser und Protokoll, schaltet jede Aufgabe einzeln und ändert ihren Abstand. **Die Einschränkung steht auf diesem Bildschirm:** ohne Besuch und ohne geöffnetes Backend passiert nichts – eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr.
|
||||
|
||||
## Erledigt
|
||||
|
||||
- `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch.
|
||||
@@ -36,11 +78,13 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
|
||||
- **Aufgabenplan ohne Cronjob** (ADR 073, Migration `20260914000100`): eine Zeile je Aufgabe mit Fälligkeit, Sperre und letztem Ergebnis. Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE` – auf MySQL wie auf SQLite atomar, damit zwei gleichzeitige Besucher eine Aufgabe nicht zweimal starten; die Sperre verfällt nach 15 Minuten von selbst. Der Normalfall kostet **keine Datenbankabfrage**: in `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht. Ein Fehlschlag ist ein sichtbarer Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden), und eine scheiternde Aufgabe reißt nie eine Seite mit. Neue Aufgaben sind eine Datei: wer `App\Ops\Tasks\Task` implementiert, steht beim nächsten Abgleich im Plan. Befehle: `cms:tasks`, `cms:tasks:run` (`--task`, `--limit`, `--budget`, `--loop`, `--max-time`).
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||
- **„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`.
|
||||
@@ -141,6 +185,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
|
||||
- **Aufgabenplan geprüft** (14.09.2026): 12 Tests auf die Mechanik (nur Fälliges läuft; eine gesperrte Aufgabe wird übergangen, eine abgelaufene Sperre übernommen; ein Fehlschlag wird festgehalten und der nächste Versuch rückt weiter weg; eine abgeschaltete Aufgabe läuft nicht, von Hand gestartet doch; eine Anfrage arbeitet nicht alles auf einmal ab; eine fehlende Tabelle ist kein Fehler) und 5 über HTTP (der Bildschirm nennt jede Aufgabe und die Adresse des URL-Cronjobs; anonym führt niemand etwas aus, eine Redakteurin bekommt 403, ohne gültiges Formular passiert nichts; der Takt des Backends antwortet mit JSON; ein falscher Cron-Schlüssel bekommt 404). Von Hand auf der Entwicklungsinstallation **alle vier Auslöser ausgeführt**: alle fünf Aufgaben über die Konsole (Sicherung 36 ms, Updateprüfung 318 ms gegen das echte Repository), ein Seitenaufruf hat eine fällige Aufgabe abgearbeitet (Auslöser `besuch` im Protokoll), das geöffnete Backend hat getaktet (`POST /admin/aufgaben/takt` → 200), und der URL-Cronjob hat mit dem richtigen Schlüssel Text geliefert und mit einem falschen 404. Der Bildschirm wurde im Browser bedient (Abstand und Schalter gespeichert und wieder zurückgesetzt); dabei fiel eine Inline-Stilangabe auf, die die CSP der Verwaltung blockiert hat – sie ist jetzt eine Klasse.
|
||||
- Ladeverhalten geprüft: drei Tests – 30 Fassungen ergeben zwei Seiten mit richtiger Reihenfolge und richtigem Rest, eine Seite jenseits des Endes zeigt die letzte statt einer leeren; das erste Bild trägt `loading="eager"` und `fetchpriority="high"`, das zweite `lazy`; und die Startseite bleibt unter zwölf Abfragen. Dazu das Ladebudget in der Browserprüfung.
|
||||
- **Wiederherstellung nachgewiesen**, nicht nur gebaut – das war der älteste offene Punkt aus Arbeitspaket I. Acht Tests gegen eine wegwerfbare Installation, darunter der eigentliche: Sicherung anlegen, Seite und Bilddatei löschen, zurückspielen, beides ist wieder da. Dazu von Hand auf der Entwicklungsinstallation: Testseite angelegt (5 Seiten), gesichert, gelöscht (4), zurückgespielt (5, Seite mit ihrer Adresse wieder da), Testseite entfernt (4). Ebenfalls geprüft: eine manipulierte Sicherung wird abgewiesen, eine aus einer neueren Fassung nur mit `--force`, Rotation behält die neuesten, und eine nach der Sicherung hochgeladene Datei überlebt die Wiederherstellung.
|
||||
- Seitenbereiche geprüft: Standard, anderer Bereich, „keiner“ (dann steht weder `site-header` noch `site-footer` im HTML, die Seite selbst aber schon), ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück, eine veraltete Fassungsnummer und eine ungültige Adresse werden abgewiesen. Menüarten: drei angenommen, eine erfundene abgewiesen, Burger als `details` – und die Seite behält `script-src 'none'`. Seitenbreite: die Regel in `site.css` nimmt den Theme-Wert, und ein größerer Wert erscheint als `--k-container` im Seiten-Stylesheet.
|
||||
@@ -199,6 +244,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
|
||||
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
|
||||
- **Aus Umuts erweitertem Auftrag noch offen** (Paket G2 im Ausbauplan): die Neugestaltung der Verwaltung.
|
||||
- Eigenes CSS kennt keine Gerätevarianten – dafür sind die Vorschaugrößen im Designbereich da. Eigenes Skript hat keine Fassungsverwaltung außer der Seitenhistorie und keine Prüfung auf Syntaxfehler; ein fehlerhaftes Skript schreibt in die Browserkonsole und lässt die Seite sonst unangetastet.
|
||||
@@ -444,6 +490,59 @@ gibt es nicht. Dafür braucht es erst ein Zwölf-Spalten-Modell im Designtoken-M
|
||||
berührt die öffentliche CSS und damit die Messwerte aus AGENTS.md. Das ist ein eigener Schritt,
|
||||
kein Nebenbei.
|
||||
|
||||
## 1.5.5: die Verwaltungsnavigation war am Schreibtisch unerreichbar
|
||||
|
||||
**Der Fehler.** In der ausgelieferten Fassung fehlte in der Verwaltung am Schreibtisch die
|
||||
gesamte linke Navigation – Marke oben, Fußzeile unten, dazwischen nichts. Kein Weg zu Seiten,
|
||||
Mediathek, Einstellungen außer über die Adresszeile.
|
||||
|
||||
Die Navigation steckt in einem `<details>`: auf dem Telefon ein Klappmenü, am Schreibtisch die
|
||||
stehende Spalte. Am Schreibtisch wurde der Schalter mit `.shell-menu > summary { display: none }`
|
||||
ausgeblendet – aber das Element trug kein `open`, und ein geschlossenes `details` versteckt seinen
|
||||
Inhalt selbst. Schalter weg **und** Inhalt weg.
|
||||
|
||||
**Die Behebung.** `::details-content` holt den Inhalt am Schreibtisch zurück, während der
|
||||
Schalter ausgeblendet bleibt. Kennt ein Browser die Regel nicht, fällt über `@supports` die ganze
|
||||
Gruppe weg: dann steht der Schalter da und die Liste ist einen Klick entfernt – statt gar nicht
|
||||
da. Auf dem Telefon bleibt es beim Klappmenü.
|
||||
|
||||
Der Code dafür lag bereits im Arbeitsverzeichnis: eine parallel laufende Sitzung hatte ihn
|
||||
geschrieben, aber nicht committet, und damit war er in keinem Release. Übernommen ist hier nur
|
||||
diese Korrektur, nicht die übrige Arbeit jener Sitzung.
|
||||
|
||||
**Wie das durchrutschen konnte – und was dagegen getan ist.** Beim vorigen Bericht wurde die
|
||||
Navigation für in Ordnung erklärt, nachdem eine **Nachbildung** der Regel in einer leeren Seite
|
||||
geprüft worden war. Die Nachbildung enthielt die Korrektur, die ausgelieferte Datei nicht; der
|
||||
Befund war damit wertlos. Jetzt prüft der Browsertest die **echte** Verwaltungsseite: am
|
||||
Schreibtisch kein Schalter, Navigation sichtbar, der Eintrag „Seiten“ mit messbarer Fläche; auf
|
||||
390 px Breite ein Schalter, Liste zunächst zu, ein Klick öffnet sie. Gegenprobe gemacht – gegen
|
||||
die ausgelieferte `admin.css` schlägt der Test fehl.
|
||||
|
||||
**Geprüft.** `npm run test:editor` vollständig, `npm test` 28 von 28,
|
||||
`php vendor/bin/phpunit` 452 von 452.
|
||||
|
||||
## 1.6.0: der Aufgabenplan wird veröffentlicht
|
||||
|
||||
Diese Fassung bringt den **Aufgabenplan ohne Cronjob** (ADR 073, oben beschrieben) in ein
|
||||
Release. Er entstand in einer parallel laufenden Sitzung und lag bis hierher unveröffentlicht im
|
||||
Arbeitsverzeichnis – die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut
|
||||
und enthielten ihn deshalb nicht.
|
||||
|
||||
**Vor der Veröffentlichung geprüft, nicht bloß eingepackt:**
|
||||
|
||||
- `php vendor/bin/phpunit` 452 von 452, `npm test` 28 von 28, `npm run test:editor` vollständig.
|
||||
- Die Migration `20260914000100` legt **nur** die Tabelle `scheduled_task` an und nimmt sie beim
|
||||
Zurückrollen wieder weg. Sie fasst keine bestehenden Daten an – das ist die risikoarme Sorte.
|
||||
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr; der Stand ist ruhig.
|
||||
|
||||
**Dabei zwei Fehler behoben:**
|
||||
|
||||
- `config/services.yaml` verwendet `!tagged_iterator`. Das ist gültiges Symfony, aber
|
||||
`php bin/console lint:yaml config` kennt eigene Tags nur mit `--parse-tags` und gab 1 zurück –
|
||||
der CI-Lauf in `.github/workflows/ci.yml` wäre daran gescheitert. Das Flag ist ergänzt.
|
||||
- `src/EventSubscriber/AdminHeadersSubscriber.php` (aus 1.5.3) entsprach nicht dem Stil von
|
||||
`php-cs-fixer`; `composer lint` gab 8 zurück. Behoben, jetzt 0 von 128 Dateien zu ändern.
|
||||
|
||||
## Nächster konkreter Schritt
|
||||
|
||||
Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260914000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Der Aufgabenplan: eine Zeile je wiederkehrender Aufgabe, mit Faelligkeit, Sperre und letztem Ergebnis.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Warum eine Tabelle und keine Datei: die Sperre. Zwei Besucher koennen im selben
|
||||
// Moment dieselbe Aufgabe faellig sehen, und nur einer darf sie ausfuehren. Ein
|
||||
// bedingtes UPDATE ist auf MySQL wie auf SQLite atomar; zwei Prozesse, die eine Datei
|
||||
// sperren, sind es auf geteiltem Hosting nicht zuverlaessig.
|
||||
//
|
||||
// Die Tabelle bleibt klein - eine Zeile je registrierter Aufgabe, heute fuenf. Deshalb
|
||||
// steht hier kein Index: ein voller Durchlauf ueber fuenf Zeilen ist billiger als ein
|
||||
// Index, den jede Aenderung mitpflegen muss.
|
||||
$tasks = $schema->createTable('scheduled_task');
|
||||
// Der technische Name aus dem Code, zum Beispiel `publish-due`. Er ist der Schluessel:
|
||||
// verschwindet eine Aufgabe aus dem Code, verschwindet ihre Zeile beim naechsten Abgleich.
|
||||
$tasks->addColumn('name', 'string', ['length' => 64]);
|
||||
// Der Abstand zwischen zwei Laeufen. Kommt als Vorgabe aus dem Code und ist in der
|
||||
// Verwaltung aenderbar, weil eine taegliche Sicherung auf der einen Installation
|
||||
// richtig und auf der naechsten zu selten ist.
|
||||
$tasks->addColumn('interval_seconds', 'integer', ['default' => 3600]);
|
||||
$tasks->addColumn('enabled', 'boolean', ['default' => true]);
|
||||
// Alle Zeitpunkte in UTC, als Zeichenkette verglichen - wie ueberall sonst im Projekt.
|
||||
$tasks->addColumn('next_run_at', 'datetime_immutable');
|
||||
$tasks->addColumn('last_run_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// Bis wann diese Aufgabe als "laeuft gerade" gilt. Danach darf sie ein anderer
|
||||
// Durchlauf uebernehmen: ein Prozess, den das Hosting nach 30 Sekunden abgeschossen
|
||||
// hat, kann seine Sperre nicht mehr selbst aufraeumen.
|
||||
$tasks->addColumn('locked_until', 'datetime_immutable', ['notnull' => false]);
|
||||
$tasks->addColumn('last_status', 'string', ['length' => 16, 'notnull' => false]);
|
||||
$tasks->addColumn('last_message', 'string', ['length' => 500, 'default' => '']);
|
||||
$tasks->addColumn('last_duration_ms', 'integer', ['default' => 0]);
|
||||
// Wodurch der letzte Lauf ausgeloest wurde: besuch, verwaltung, cron, konsole, hand.
|
||||
// Steht hier, weil die erste Frage bei einer Stoerung immer dieselbe ist: laeuft der
|
||||
// Ausloeser ueberhaupt, den der Betreiber eingerichtet zu haben glaubt?
|
||||
$tasks->addColumn('last_trigger', 'string', ['length' => 16, 'default' => '']);
|
||||
// Aufeinanderfolgende Fehlschlaege. Sie verlaengern den Abstand bis zum naechsten
|
||||
// Versuch, damit eine Aufgabe, die nicht laufen kann, nicht bei jedem Seitenaufruf
|
||||
// wieder anfaengt.
|
||||
$tasks->addColumn('failures', 'integer', ['default' => 0]);
|
||||
$tasks->setPrimaryKey(['name']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Faellt die Tabelle, faellt nur der Plan: Zeitpunkte und letzte Meldungen. Kein Inhalt,
|
||||
// kein Konto, keine Datei. Beim naechsten Start legt der Abgleich sie neu an.
|
||||
$schema->dropTable('scheduled_task');
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
+53
-10
@@ -446,6 +446,11 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.theme-table td input[type=color] { width: 52px }
|
||||
.theme-table .weights-cell { white-space: nowrap }
|
||||
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
|
||||
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
|
||||
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
|
||||
.tasks-table td input[type=number] { width: 92px }
|
||||
.tasks-table td .field-help { margin-top: 4px }
|
||||
.tasks-table .row-actions, .tasks-table td:last-child { white-space: nowrap }
|
||||
/* Headings inside a card separate its parts without a second border. */
|
||||
.card-body .section-head { margin: 28px 0 12px }
|
||||
.card-body .section-head:first-child { margin-top: 14px }
|
||||
@@ -665,21 +670,33 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein
|
||||
Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit
|
||||
Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */
|
||||
.shell-menu > summary { display: none }
|
||||
/* Der Schalter traegt seine Gestalt hier, nicht erst im Telefonabschnitt: am Schreibtisch
|
||||
wird er ausgeblendet, aber nur dort, wo der Inhalt sicher sichtbar ist. */
|
||||
.shell-menu > summary {
|
||||
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
|
||||
min-height: 44px; padding: 0 14px; border-radius: 10px;
|
||||
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
|
||||
}
|
||||
.shell-menu > summary::-webkit-details-marker { display: none }
|
||||
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
|
||||
.shell-menu[open] > summary { margin-bottom: 8px }
|
||||
.shell-menu-icon { font-size: 17px }
|
||||
/* Am Schreibtisch steht die Liste dauerhaft da. Ein geschlossenes details-Element versteckt
|
||||
seinen Inhalt aber von sich aus - der Schalter allein auszublenden loescht die Navigation.
|
||||
Erst ::details-content holt sie zurueck. Kennt ein Browser die Regel nicht, faellt die
|
||||
ganze Gruppe weg und der Schalter bleibt stehen: die Liste ist dann einen Klick entfernt
|
||||
statt gar nicht da. */
|
||||
@supports selector(::details-content) {
|
||||
.shell-menu > summary { display: none }
|
||||
.shell-menu::details-content { content-visibility: visible; block-size: auto }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
|
||||
.shell-menu { flex: 1 1 100%; order: 3 }
|
||||
.shell-menu > summary {
|
||||
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
|
||||
min-height: 44px; padding: 0 14px; border-radius: 10px;
|
||||
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
|
||||
}
|
||||
.shell-menu > summary::-webkit-details-marker { display: none }
|
||||
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
|
||||
.shell-menu-icon { font-size: 17px }
|
||||
.shell-menu[open] > summary { margin-bottom: 8px }
|
||||
.shell-menu > summary { display: flex }
|
||||
.shell-menu nav { display: block }
|
||||
.shell-menu:not([open]) nav { display: none }
|
||||
.shell-menu:not([open])::details-content { content-visibility: hidden; block-size: 0 }
|
||||
.nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
@@ -688,3 +705,29 @@ 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}
|
||||
.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)}}
|
||||
@@ -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,7 +5,7 @@
|
||||
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
|
||||
* appear here without touching this file.
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
export const GROUPS = {
|
||||
@@ -122,6 +122,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
const linkedSides = new Map();
|
||||
// Lives as long as the editor session, so a look can travel between blocks and pages.
|
||||
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) => {
|
||||
@@ -227,7 +230,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const lengthControl = (id, value, apply, placeholder) => {
|
||||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||
const wrap = element('div', 'length-control');
|
||||
const parts = splitLength(value);
|
||||
const number = document.createElement('input');
|
||||
@@ -245,6 +248,17 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
unit.addEventListener('change', () => { if (number.value !== '') push(); });
|
||||
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
|
||||
wrap.append(number, unit);
|
||||
if (keywords.length) {
|
||||
const mode = document.createElement('select');
|
||||
mode.className = 'length-mode';
|
||||
mode.setAttribute('aria-label', `${schema.properties[id.replace('style-', '')]?.label ?? 'Länge'} Vorgabe`);
|
||||
mode.append(new Option('Eigener Zahlenwert / geerbt', ''));
|
||||
for (const keyword of keywords) mode.append(new Option(keyword === 'none' ? 'Ohne Begrenzung' : 'Automatisch', keyword));
|
||||
mode.value = ['auto', 'none'].includes(value) ? value : '';
|
||||
number.hidden = unit.hidden = mode.value !== '';
|
||||
mode.addEventListener('change', () => apply(mode.value));
|
||||
wrap.prepend(mode);
|
||||
}
|
||||
return wrap;
|
||||
};
|
||||
|
||||
@@ -446,14 +460,22 @@ 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;
|
||||
if (lastBlockId !== block.id) { showAll = false; lastBlockId = block.id; }
|
||||
|
||||
const scope = element('p', 'design-scope');
|
||||
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
|
||||
panel.append(scope);
|
||||
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
|
||||
const all = element('button', 'secondary design-context-toggle', showAll ? 'Passende Eigenschaften anzeigen' : 'Alle Eigenschaften anzeigen');
|
||||
all.type = 'button'; all.setAttribute('aria-pressed', String(showAll));
|
||||
all.addEventListener('click', () => { showAll = !showAll; onChange(); panel.querySelector('.design-context-toggle')?.focus(); });
|
||||
panel.append(all);
|
||||
|
||||
// A filter over the properties. With more than ninety of them across eight collapsed groups,
|
||||
// "where is the border thickness" is a fair question - so it can be typed instead of hunted.
|
||||
@@ -484,11 +506,16 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
});
|
||||
panel.append(searchLabel, search, found);
|
||||
|
||||
for (const [group, groupLabel] of Object.entries(GROUPS)) {
|
||||
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
|
||||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||||
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
|
||||
for (const group of orderedGroups) {
|
||||
const groupLabel = GROUPS[group];
|
||||
const properties = Object.entries(schema.properties).filter(([key, p]) => p.group === group && (showAll || relevantProperty(block, key, p)));
|
||||
if (properties.length === 0) continue;
|
||||
const details = element('details', 'design-group');
|
||||
if (group === 'text' || group === 'spacing') details.open = true;
|
||||
const groupKey = `${block.type}:${group}`;
|
||||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing');
|
||||
renderedGroups.set(groupKey, details);
|
||||
details.append(element('summary', null, groupLabel));
|
||||
const body = element('div', 'design-group-body');
|
||||
|
||||
@@ -510,7 +537,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||||
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
|
||||
@@ -141,6 +141,11 @@ export function upgradeForType(document, type) {
|
||||
* @return {{parentId: string|null, index: number}|null}
|
||||
*/
|
||||
export function insertionTarget(document, selected, type) {
|
||||
// A section starts a page area. Explicit insertion points and drag/drop can still nest it.
|
||||
if (type === 'section') {
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
return { parentId: null, index: top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length };
|
||||
}
|
||||
const probe = { type };
|
||||
let item = locate(document.blocks, selected);
|
||||
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
|
||||
@@ -533,3 +538,13 @@ export function copyStyle(document, fromId, toId) {
|
||||
target.style = structuredClone(source.style);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** UI relevance only. Existing values remain editable, including on older documents. */
|
||||
export function relevantProperty(block, key, property) {
|
||||
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined)) return true;
|
||||
const container = isContainer(block);
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact'].includes(block.type);
|
||||
return true;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -281,3 +282,32 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
|
||||
#library-search{font-size:11px;min-height:36px;padding:9px;margin-bottom:4px}
|
||||
#library-found{margin:0 0 10px;min-height:0}
|
||||
|
||||
/* Flexible workspace: both panels use their full width, with accessible resize handles. */
|
||||
.editor-layout{grid-template-columns:var(--panel-left,280px) 6px minmax(280px,1fr) 6px var(--panel-right,320px)}
|
||||
.left-panel,.right-panel{min-width:0;overflow-x:hidden}
|
||||
.left-panel{container-type:inline-size}
|
||||
.left-panel>.section-launch{flex:none;padding:12px;border-bottom:1px solid var(--line)}
|
||||
.section-launch button{white-space:normal;font-size:12px;min-height:40px}
|
||||
.section-launch .field-help{margin:6px 0 0}
|
||||
.left-panel>.panel-section:not(.structure-section){max-height:55%;overflow:auto;flex-shrink:0}
|
||||
.panel-section{padding:16px 12px}
|
||||
.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.library-buttons button{min-width:0;white-space:normal;overflow-wrap:anywhere;gap:6px}
|
||||
.library-name{overflow-wrap:anywhere}
|
||||
.library-preview{width:28px}
|
||||
@container(max-width:270px){.library-buttons{grid-template-columns:minmax(0,1fr)}.library-buttons button[data-row]{grid-column:auto}}
|
||||
.canvas-scroll{padding:0}
|
||||
.panel-resizer{background:#e4e9df;cursor:col-resize;touch-action:none;position:relative;z-index:4}
|
||||
.panel-resizer::after{content:'';position:absolute;top:45%;left:1px;width:4px;height:36px;border-radius:4px;background:#7b8d80}
|
||||
.panel-resizer:hover,.panel-resizer:focus-visible{background:#b7cbbd;outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.resizing-panels{cursor:col-resize;user-select:none}
|
||||
.resizing-panels iframe{pointer-events:none}
|
||||
.length-control,.number-control{grid-template-columns:minmax(0,1fr) 74px}
|
||||
.right-panel input,.right-panel select,.right-panel textarea{min-width:0;max-width:100%}
|
||||
.length-mode{grid-column:1/-1}
|
||||
.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}
|
||||
+148
-15
@@ -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;
|
||||
@@ -23,6 +27,23 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d
|
||||
// same factor, so it is kept here instead of being recomputed from the transform string.
|
||||
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;
|
||||
@@ -53,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;
|
||||
@@ -72,7 +93,9 @@ function controls() {
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#select-page').disabled = !ready || busy;
|
||||
$('#add-root-section').disabled = !ready || busy;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
$('#preview-motion').disabled = !ready || busy;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
@@ -112,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';
|
||||
@@ -131,6 +155,9 @@ function highlight(scroll = false) {
|
||||
function renderProperties() {
|
||||
const block = locate(history.document.blocks, selected)?.block;
|
||||
if (!block) propertyPanel = 'content';
|
||||
const context = `${selected ?? 'page'}:${propertyPanel}`;
|
||||
if (context !== renderedProperties) $('.right-panel').scrollTop = 0;
|
||||
renderedProperties = context;
|
||||
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
|
||||
const panel = $('#properties'); panel.replaceChildren();
|
||||
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
|
||||
@@ -325,7 +352,7 @@ function renderProperties() {
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal (1180 px)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos heißt ohne Rand und ohne Begrenzung – für ein Bild oder eine Farbfläche von Kante zu Kante.' });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,7 +459,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',
|
||||
}],
|
||||
@@ -445,7 +472,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) {
|
||||
@@ -545,9 +572,9 @@ function stopInlineEdit() {
|
||||
|
||||
function resizePreview() {
|
||||
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
|
||||
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
|
||||
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
|
||||
const available = Math.max(100, container.clientWidth - padding);
|
||||
const width = { desktop: Math.max(1240, available), tablet: 768, mobile: 390 }[device];
|
||||
const scale = Math.min(1, available / width);
|
||||
const height = Math.max(550, (container.clientHeight - padding) / scale);
|
||||
previewScale = scale;
|
||||
@@ -590,7 +617,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.
|
||||
@@ -602,6 +635,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);
|
||||
@@ -613,15 +662,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.
|
||||
@@ -649,12 +711,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({
|
||||
@@ -722,12 +785,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.
|
||||
//
|
||||
@@ -830,6 +904,13 @@ $('#library').addEventListener('click', event => {
|
||||
addElement(button.dataset.add);
|
||||
});
|
||||
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
|
||||
$('#add-root-section').addEventListener('click', () => {
|
||||
if (busy || !history) return;
|
||||
stopInlineEdit();
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => insertBlock(doc, 'section', selected));
|
||||
propertyPanel = 'content'; changed();
|
||||
});
|
||||
$('#presets').addEventListener('click', event => {
|
||||
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||
if (!name || busy || !history) return;
|
||||
@@ -964,7 +1045,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 }),
|
||||
@@ -1003,6 +1084,58 @@ function setViewport(name) {
|
||||
}
|
||||
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
|
||||
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
||||
// Panel sizes are workspace preferences, independent of document history and publication.
|
||||
const panelSizes = { left: 280, right: 320 };
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('kantsites.panel-widths'));
|
||||
for (const side of ['left', 'right']) if (Number.isFinite(saved?.[side])) panelSizes[side] = saved[side];
|
||||
} catch { /* Storage can be unavailable; resizing still works for this session. */ }
|
||||
const panelMinimum = { left: 240, right: 280 };
|
||||
function layoutPanels() {
|
||||
let left = Math.max(240, Math.min(560, panelSizes.left));
|
||||
let right = Math.max(280, Math.min(560, panelSizes.right));
|
||||
const budget = Math.max(520, $('.editor-layout').clientWidth - 292);
|
||||
const excess = Math.max(0, left + right - budget);
|
||||
const spare = left + right - 520;
|
||||
if (excess && spare) { left -= excess * (left - 240) / spare; right -= excess * (right - 280) / spare; }
|
||||
for (const [side, size] of Object.entries({ left, right })) {
|
||||
root.style.setProperty(`--panel-${side}`, `${size}px`);
|
||||
const handle = $(`[data-resize="${side}"]`);
|
||||
handle.setAttribute('aria-valuenow', String(Math.round(size)));
|
||||
handle.setAttribute('aria-valuemax', String(Math.floor(Math.min(560, budget - (side === 'left' ? right : left)))));
|
||||
handle.setAttribute('aria-valuetext', `${Math.round(size)} Pixel`);
|
||||
}
|
||||
}
|
||||
const persistPanels = () => { try { localStorage.setItem('kantsites.panel-widths', JSON.stringify(panelSizes)); } catch {} };
|
||||
for (const handle of document.querySelectorAll('[data-resize]')) {
|
||||
const side = handle.dataset.resize, direction = side === 'left' ? 1 : -1;
|
||||
let drag = null;
|
||||
const setSize = value => {
|
||||
panelSizes[side] = Math.max(panelMinimum[side], Math.min(Number(handle.getAttribute('aria-valuemax')), value));
|
||||
layoutPanels();
|
||||
};
|
||||
handle.addEventListener('pointerdown', event => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); handle.focus();
|
||||
drag = { x: event.clientX, size: Number(handle.getAttribute('aria-valuenow')) };
|
||||
handle.setPointerCapture(event.pointerId); root.classList.add('resizing-panels');
|
||||
});
|
||||
handle.addEventListener('pointermove', event => { if (drag) setSize(drag.size + direction * (event.clientX - drag.x)); });
|
||||
const stop = () => { drag = null; root.classList.remove('resizing-panels'); persistPanels(); };
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', stop);
|
||||
handle.addEventListener('lostpointercapture', stop);
|
||||
handle.addEventListener('dblclick', () => { panelSizes[side] = side === 'left' ? 280 : 320; layoutPanels(); persistPanels(); });
|
||||
handle.addEventListener('keydown', event => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const current = Number(handle.getAttribute('aria-valuenow'));
|
||||
setSize(event.key === 'Home' ? panelMinimum[side] : event.key === 'End' ? 560 : current + (event.key === 'ArrowRight' ? 1 : -1) * direction * (event.shiftKey ? 40 : 10));
|
||||
persistPanels();
|
||||
});
|
||||
}
|
||||
new ResizeObserver(layoutPanels).observe($('.editor-layout'));
|
||||
layoutPanels();
|
||||
window.addEventListener('keydown', event => {
|
||||
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
@@ -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% }
|
||||
}
|
||||
}
|
||||
@@ -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}).`);
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,7 +6,7 @@ h1,h2,h3,h4,h5,h6{hyphens:manual}
|
||||
p{hyphens:auto}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
@@ -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
@@ -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();
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Der Takt des geöffneten Backends.
|
||||
*
|
||||
* Auf einer Website ohne Besucher gibt es keinen Auslöser für wiederkehrende Aufgaben - außer
|
||||
* dem Menschen, der gerade in der Verwaltung arbeitet. Dieses Skript klopft in seinem Namen alle
|
||||
* 60 Sekunden an; der Server entscheidet, ob überhaupt etwas fällig ist.
|
||||
*
|
||||
* Drei Eigenschaften, die es unauffällig machen:
|
||||
*
|
||||
* - Es fragt nur, während der Reiter sichtbar ist. Ein vergessener Hintergrundreiter soll keine
|
||||
* Sitzung endlos offen halten und keine Anfragen erzeugen, die niemand sehen will.
|
||||
* - Es fragt nie zwei Mal gleichzeitig: eine laufende Anfrage blockiert die nächste.
|
||||
* - Ein Fehlschlag ist kein Ereignis. Kein Hinweis, keine Meldung - beim nächsten Takt wieder.
|
||||
*/
|
||||
(function () {
|
||||
var endpoint = document.querySelector('meta[name="kant-tick"]');
|
||||
var token = document.querySelector('meta[name="kant-tick-token"]');
|
||||
if (!endpoint || !token || !window.fetch) {
|
||||
return;
|
||||
}
|
||||
var busy = false;
|
||||
|
||||
function tick() {
|
||||
if (busy || document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
var body = new FormData();
|
||||
body.append('_csrf_token', token.content);
|
||||
fetch(endpoint.content, { method: 'POST', body: body, credentials: 'same-origin' })
|
||||
.catch(function () {})
|
||||
.then(function () {
|
||||
busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Der erste Takt kommt verzögert: beim Seitenaufbau hat der Browser Wichtigeres zu tun.
|
||||
window.setTimeout(tick, 5000);
|
||||
window.setInterval(tick, 60000);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
tick();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,233 @@
|
||||
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();
|
||||
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.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
||||
assert.equal(await rowLinks().count(), 29);
|
||||
await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff');
|
||||
await page.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
||||
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(); }
|
||||
@@ -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) {
|
||||
|
||||
+221
-1
@@ -64,6 +64,30 @@ try {
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin');
|
||||
console.log('Authenticated in isolated test installation.');
|
||||
|
||||
// --- Die Verwaltungsnavigation ------------------------------------------------------------
|
||||
// Sie steckt in einem details-Element: auf dem Telefon ein Klappmenue, am Schreibtisch die
|
||||
// stehende Spalte. Am Schreibtisch ist der Schalter ausgeblendet - und ein geschlossenes
|
||||
// details versteckt seinen Inhalt von sich aus. Ohne eine Regel, die ihn zurueckholt, ist die
|
||||
// Navigation dort nicht bloss zugeklappt, sondern unerreichbar. Genau so ist 1.5.4 ausgeliefert
|
||||
// worden, und genau das faellt hier auf.
|
||||
const navigation = page.getByRole('navigation', { name: 'Verwaltung' });
|
||||
const schalter = page.locator('.shell-menu > summary');
|
||||
assert.equal(await schalter.isVisible(), false, 'am Schreibtisch gibt es keinen Menueschalter');
|
||||
assert.equal(await navigation.isVisible(), true, 'am Schreibtisch steht die Navigation da');
|
||||
const eintrag = navigation.getByRole('link', { name: 'Seiten', exact: true });
|
||||
const flaeche = await eintrag.boundingBox();
|
||||
assert.ok(flaeche && flaeche.height > 0 && flaeche.width > 0, 'ihre Eintraege sind anklickbar');
|
||||
|
||||
// Auf dem Telefon dagegen: ein Schalter, zunaechst zu, ein Klick oeffnet.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.equal(await schalter.isVisible(), true, 'auf dem Telefon gibt es den Schalter');
|
||||
assert.equal(await navigation.isVisible(), false, 'und die Liste ist zunaechst zu');
|
||||
await schalter.click();
|
||||
assert.equal(await navigation.isVisible(), true, 'ein Klick oeffnet sie');
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
checks.push({ name: 'the administration navigation is reachable on both sizes', passed: true });
|
||||
console.log('Administration navigation passed.');
|
||||
await axe('page list');
|
||||
await page.screenshot({ path: 'reports/editor/pages.png', fullPage: true });
|
||||
await page.locator('a[href="/admin/pages/test-home"]').first().click();
|
||||
@@ -80,6 +104,178 @@ try {
|
||||
const frame = page.frameLocator('#preview');
|
||||
await frame.locator('h1').waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
// Flexible panels, contextual fields and a root insertion from a deeply selected element.
|
||||
const leftHandle = page.locator('[data-resize="left"]'), rightHandle = page.locator('[data-resize="right"]');
|
||||
await leftHandle.focus(); await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
|
||||
const rightBox = await rightHandle.boundingBox();
|
||||
await page.mouse.move(rightBox.x + 3, rightBox.y + 100); await page.mouse.down();
|
||||
await page.mouse.move(rightBox.x - 57, rightBox.y + 100, { steps: 6 }); await page.mouse.up();
|
||||
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
|
||||
await page.reload();
|
||||
await frame.locator('h1').waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
|
||||
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
|
||||
for (const width of [1920, 1100, 850, 390]) {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await page.waitForTimeout(100);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
|
||||
for (const selector of ['.left-panel', '.right-panel', '.library-buttons']) {
|
||||
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await frame.locator('h1').click();
|
||||
const sectionCount = await frame.locator('main > .block-section').count();
|
||||
await page.locator('#add-root-section').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
||||
await page.locator('#undo').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
|
||||
await page.locator('#redo').click();
|
||||
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
||||
await page.locator('#undo').click();
|
||||
await frame.locator('h1').click();
|
||||
await page.locator('#library [data-add="image"]').click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
assert.equal(await page.locator('#style-fontSize').count(), 0);
|
||||
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.screenshot({ path: 'reports/editor/context-image.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Alle Eigenschaften anzeigen', exact: true }).click();
|
||||
assert.equal(await page.locator('#style-fontSize').count(), 1);
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await frame.locator('main .block-text').first().click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
assert.equal(await page.locator('#style-objectFit').count(), 0);
|
||||
assert.equal(await page.locator('#style-fontSize').isVisible(), true);
|
||||
const textWidth = await frame.locator('main .block-text').first().evaluate(el => ({ width: el.getBoundingClientRect().width, parent: el.parentElement.clientWidth }));
|
||||
assert.ok(Math.abs(textWidth.width - textWidth.parent) < 2, 'text uses its container width');
|
||||
const sizeGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Größe$/ }) });
|
||||
await sizeGroup.locator('summary').click();
|
||||
await page.locator('#style-maxWidth').locator('xpath=following-sibling::select').selectOption('ch');
|
||||
await page.locator('#style-maxWidth').fill('60');
|
||||
await page.locator('#style-maxWidth').blur();
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
|
||||
return Boolean(el && el.getBoundingClientRect().width < el.parentElement.clientWidth - 100);
|
||||
});
|
||||
await page.getByLabel('Maximale Breite Vorgabe', { exact: true }).selectOption('none');
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
|
||||
return Boolean(el && getComputedStyle(el).maxWidth === 'none');
|
||||
});
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#undo').click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.locator('.right-panel').evaluate(el => el.scrollTop = 0);
|
||||
await page.locator('.left-panel > .panel-section').first().evaluate(el => el.scrollTop = 0);
|
||||
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
|
||||
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');
|
||||
@@ -463,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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Der dritte Weg: ein echter Cronjob oder ein Dauerläufer.
|
||||
*
|
||||
* Für Installationen, die eine Konsole haben. Er tut dasselbe wie der Besuchs-Auslöser, nur ohne
|
||||
* dessen enge Grenzen – und er läuft auch nachts um drei, wenn die Website leer ist.
|
||||
*
|
||||
* * * * * * cd /pfad && php bin/console cms:tasks:run (jede Minute, Standardweg)
|
||||
* php bin/console cms:tasks:run --loop=60 --max-time=3600 (Dauerläufer, z. B. unter Supervisor)
|
||||
* php bin/console cms:tasks:run --task=backup (eine bestimmte, sofort)
|
||||
*/
|
||||
#[AsCommand(name: 'cms:tasks:run', description: 'Führt fällige Aufgaben aus. Für einen echten Cronjob oder als Dauerläufer.')]
|
||||
final class RunTasksCommand extends Command
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('task', null, InputOption::VALUE_REQUIRED, 'Nur diese Aufgabe, unabhängig vom Zeitplan.')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Aufgaben je Durchlauf.', '10')
|
||||
->addOption('budget', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Sekunden je Durchlauf.', '300')
|
||||
->addOption('loop', null, InputOption::VALUE_REQUIRED, 'Als Dauerläufer: Sekunden zwischen zwei Durchläufen.')
|
||||
->addOption('max-time', null, InputOption::VALUE_REQUIRED, 'Der Dauerläufer endet nach so vielen Sekunden.', '3600');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
if (null !== ($name = $input->getOption('task'))) {
|
||||
$result = $this->scheduler->runNow((string) $name, 'konsole');
|
||||
if (null === $result) {
|
||||
$io->error('Diese Aufgabe gibt es nicht oder sie läuft gerade schon. `cms:tasks` zeigt, welche es gibt.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$this->report($io, [$result]);
|
||||
|
||||
return 'ok' === $result['status'] ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
$limit = max(1, (int) $input->getOption('limit'));
|
||||
$budget = max(1, (int) $input->getOption('budget'));
|
||||
$loop = null !== $input->getOption('loop') ? max(5, (int) $input->getOption('loop')) : null;
|
||||
$deadline = microtime(true) + max(0, (int) $input->getOption('max-time'));
|
||||
|
||||
$failed = false;
|
||||
while (true) {
|
||||
$results = $this->scheduler->runDue('konsole', $limit, $budget);
|
||||
$failed = $failed || [] !== array_filter($results, static fn (array $r): bool => 'ok' !== $r['status']);
|
||||
$this->report($io, $results);
|
||||
if (null === $loop) {
|
||||
break;
|
||||
}
|
||||
// Ein Dauerläufer, der nie endet, sammelt Speicher und hält eine Datenbankverbindung
|
||||
// über Tage. Er endet nach `--max-time`; der Prozessverwalter startet ihn neu.
|
||||
if (microtime(true) + $loop > $deadline) {
|
||||
$io->writeln(sprintf('<comment>Ende nach %d Sekunden – neu starten lassen.</comment>', (int) $input->getOption('max-time')));
|
||||
break;
|
||||
}
|
||||
sleep($loop);
|
||||
}
|
||||
|
||||
return $failed ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param list<array{name: string, label: string, status: string, message: string, ms: int}> $results */
|
||||
private function report(SymfonyStyle $io, array $results): void
|
||||
{
|
||||
if ([] === $results) {
|
||||
$io->writeln(sprintf('<info>%s</info> nichts fällig.', gmdate('H:i:s')));
|
||||
|
||||
return;
|
||||
}
|
||||
foreach ($results as $result) {
|
||||
$io->writeln(sprintf(
|
||||
'<info>%s</info> %s %s (%d ms): %s',
|
||||
gmdate('H:i:s'),
|
||||
'ok' === $result['status'] ? '<info>ok</info> ' : '<error>fehler</error>',
|
||||
$result['name'],
|
||||
$result['ms'],
|
||||
$result['message'],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Was der Plan vorhat und was zuletzt passiert ist.
|
||||
*
|
||||
* Dieselbe Auskunft wie der Bildschirm „Aufgaben“, nur für den, der ohnehin auf der Konsole
|
||||
* steht – und für den Fall, dass die Verwaltung gerade nicht erreichbar ist.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:tasks', description: 'Zeigt den Aufgabenplan: Abstand, Fälligkeit und das letzte Ergebnis.')]
|
||||
final class TasksCommand extends Command
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('log', null, InputOption::VALUE_NONE, 'Zusätzlich die letzten Protokollzeilen.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
// Erst der Abgleich (er legt fehlende Zeilen an und schreibt die Zustandsdatei neu), dann
|
||||
// die Auskunft daraus - sonst zeigt der erste Aufruf nach einem Update einen Zeitpunkt,
|
||||
// der eine Sekunde spaeter schon nicht mehr stimmt.
|
||||
$tasks = $this->scheduler->overview();
|
||||
$state = $this->scheduler->fastState();
|
||||
$io->writeln(sprintf(
|
||||
'Auslöser: Besuch <info>%s</info>, Verwaltung <info>%s</info>. Konsole und URL-Cron laufen immer.',
|
||||
$state['traffic'] ? 'an' : 'aus',
|
||||
$state['admin'] ? 'an' : 'aus',
|
||||
));
|
||||
$io->writeln(sprintf(
|
||||
'Jetzt ist %s UTC; das nächste Mal steht %s an.',
|
||||
gmdate('Y-m-d H:i:s'),
|
||||
null === $state['next'] ? 'unbekannt' : gmdate('Y-m-d H:i:s', $state['next']),
|
||||
));
|
||||
|
||||
$rows = [];
|
||||
foreach ($tasks as $task) {
|
||||
$rows[] = [
|
||||
$task['name'],
|
||||
$task['enabled'] ? ($task['due'] ? 'fällig' : 'an') : 'aus',
|
||||
$this->minutes($task['interval']),
|
||||
$task['next'],
|
||||
$task['running'] ? 'läuft' : ($task['status'] ?? '–'),
|
||||
mb_substr($task['message'], 0, 60),
|
||||
];
|
||||
}
|
||||
if ([] === $rows) {
|
||||
$io->warning('Keine Aufgaben. Fehlt die Migration? `php vendor/bin/doctrine-migrations migrate`');
|
||||
} else {
|
||||
$io->table(['Name', 'Zustand', 'Abstand', 'Nächster Lauf (UTC)', 'Zuletzt', 'Meldung'], $rows);
|
||||
}
|
||||
|
||||
if ($input->getOption('log')) {
|
||||
$io->section('Protokoll');
|
||||
$lines = $this->scheduler->protocol(30);
|
||||
$io->writeln([] === $lines ? 'Noch nichts protokolliert.' : $lines);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function minutes(int $seconds): string
|
||||
{
|
||||
return $seconds >= 86400 && 0 === $seconds % 86400
|
||||
? sprintf('%d Tag(e)', intdiv($seconds, 86400))
|
||||
: sprintf('%d Min.', max(1, intdiv($seconds, 60)));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\EventSubscriber\TaskWorkerSubscriber;
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Der Cronjob für Hostings, die nur eine URL aufrufen können.
|
||||
*
|
||||
* Viele Tarife bieten keinen Shell-Cron, aber einen „URL-Cronjob": der Hoster ruft alle paar
|
||||
* Minuten eine Adresse auf. Das genügt – und ist gegenüber dem Besuchs-Auslöser der bessere Weg,
|
||||
* weil er auch dann läuft, wenn niemand auf der Website ist.
|
||||
*
|
||||
* Drei Entscheidungen:
|
||||
*
|
||||
* 1. **Der Schlüssel steht in der Adresse**, weil ein URL-Cronjob nichts anderes mitschicken
|
||||
* kann als eine Adresse. Er ist aus dem Anwendungsgeheimnis abgeleitet, 40 Zeichen lang und
|
||||
* auf jeder Installation ein anderer.
|
||||
* 2. **Ein falscher Schlüssel bekommt 404**, nicht 403. Wer probiert, soll nicht erfahren, dass
|
||||
* es hier überhaupt etwas gibt.
|
||||
* 3. **Die Antwort ist Text**, nicht HTML: sie landet im Protokoll des Hosters, und dort ist
|
||||
* eine Zeile je Aufgabe das Nützlichste, was ankommen kann.
|
||||
*/
|
||||
final class CronController extends AbstractController
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler, private string $secret)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/cms-cron/{token}', name: 'cms_cron', requirements: ['token' => '[a-f0-9]{40}'], methods: ['GET', 'POST'])]
|
||||
public function run(Request $request, string $token): Response
|
||||
{
|
||||
if (!hash_equals(Scheduler::cronToken($this->secret), $token)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
|
||||
// Hier wartet kein Mensch: ein Aufruf darf alles abarbeiten, was ansteht.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
$began = microtime(true);
|
||||
$results = $this->scheduler->runDue('cron', 20, 240);
|
||||
|
||||
$lines = [sprintf('%s UTC', gmdate('Y-m-d H:i:s'))];
|
||||
foreach ($results as $result) {
|
||||
$lines[] = sprintf('%-14s %-7s %6d ms %s', $result['name'], $result['status'], $result['ms'], $result['message']);
|
||||
}
|
||||
$lines[] = sprintf('%d Aufgabe(n) in %.1f s.', count($results), microtime(true) - $began);
|
||||
|
||||
return new Response(implode("\n", $lines)."\n", Response::HTTP_OK, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
'X-Robots-Tag' => 'noindex, nofollow',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,31 +28,53 @@ 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.
|
||||
'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')],
|
||||
]);
|
||||
}
|
||||
|
||||
#[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'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
@@ -222,7 +245,7 @@ final class EditorController extends AbstractController
|
||||
|
||||
return $this->back('' === trim($request->request->getString('publishAt'))
|
||||
? 'Die geplante Veröffentlichung wurde entfernt.'
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success');
|
||||
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
|
||||
@@ -317,8 +340,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'])]
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\EventSubscriber\TaskWorkerSubscriber;
|
||||
use App\Ops\Scheduler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Der Bildschirm „Aufgaben“ und der Takt des geöffneten Backends.
|
||||
*
|
||||
* Zwei verschiedene Dinge in einer Datei, weil sie dasselbe Thema haben: hier sieht der Betreiber,
|
||||
* was der Plan vorhat und was zuletzt passiert ist – und hier klopft die geöffnete Verwaltung im
|
||||
* Minutentakt an, damit auf einer Website ohne Besucher trotzdem etwas läuft.
|
||||
*/
|
||||
final class TaskController extends AbstractController
|
||||
{
|
||||
public function __construct(private Scheduler $scheduler, private string $secret)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/aufgaben', name: 'admin_tasks', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
$state = $this->scheduler->fastState();
|
||||
|
||||
return $this->render('admin/tasks.html.twig', [
|
||||
'tasks' => $this->scheduler->overview(),
|
||||
'traffic' => $state['traffic'],
|
||||
'admin' => $state['admin'],
|
||||
'next' => $state['next'],
|
||||
'now' => gmdate('Y-m-d H:i:s'),
|
||||
'protocol' => $this->scheduler->protocol(30),
|
||||
// Der Schlüssel für einen URL-Cronjob. Er steht in keiner Datenbank: er wird aus dem
|
||||
// Anwendungsgeheimnis abgeleitet, ist damit auf jedem Server ein anderer und ändert
|
||||
// sich, wenn das Geheimnis gewechselt wird.
|
||||
'cronUrl' => $this->generateUrl('cms_cron', ['token' => Scheduler::cronToken($this->secret)], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/aufgaben/ausfuehren', name: 'admin_tasks_run', methods: ['POST'])]
|
||||
public function run(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
$result = $this->scheduler->runNow($request->request->getString('name'));
|
||||
if (null === $result) {
|
||||
return $this->back('Diese Aufgabe gibt es nicht oder sie läuft gerade schon.');
|
||||
}
|
||||
$this->addFlash('ok' === $result['status'] ? 'success' : 'error', sprintf(
|
||||
'%s: %s (%d ms)',
|
||||
$result['label'],
|
||||
$result['message'],
|
||||
$result['ms'],
|
||||
));
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstand, Ein/Aus je Aufgabe und die beiden Auslöser, die in Anfragen stecken.
|
||||
*
|
||||
* Ein Formular für alles, weil es eine Entscheidung ist: wie dieser Server seine Aufgaben
|
||||
* abarbeitet. Zwei getrennte Formulare hätten zwei Speicherknöpfe und dieselbe Wirkung.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/aufgaben/einstellen', name: 'admin_tasks_configure', methods: ['POST'])]
|
||||
public function configure(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$this->scheduler->setWorkers(
|
||||
$request->request->getBoolean('traffic'),
|
||||
$request->request->getBoolean('admin'),
|
||||
);
|
||||
$enabled = $request->request->all('enabled');
|
||||
foreach ($request->request->all('interval') as $name => $minutes) {
|
||||
$name = (string) $name;
|
||||
$this->scheduler->setInterval($name, max(1, (int) $minutes) * 60);
|
||||
$this->scheduler->enable($name, isset($enabled[$name]));
|
||||
}
|
||||
$this->addFlash('success', 'Der Plan ist gespeichert.');
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/** Eine Sperre, die stehen geblieben ist, weil das Hosting den Prozess mitten im Lauf beendet hat. */
|
||||
#[Route('/%kant.admin_path%/aufgaben/freigeben', name: 'admin_tasks_release', methods: ['POST'])]
|
||||
public function release(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$this->scheduler->release($request->request->getString('name'));
|
||||
$this->addFlash('success', 'Die Sperre ist aufgehoben. Die Aufgabe darf wieder starten.');
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Takt der geöffneten Verwaltung.
|
||||
*
|
||||
* Damit läuft der Plan auch auf einer Website, die gerade niemand besucht – solange jemand im
|
||||
* Backend arbeitet. Das Skript fragt nur, während der Reiter sichtbar ist; ein im Hintergrund
|
||||
* vergessener Reiter soll keine Sitzung endlos offen halten.
|
||||
*
|
||||
* Die Grenzen sind hier großzügiger als beim Besuch: hier wartet niemand auf eine Seite.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/aufgaben/takt', name: 'admin_tasks_tick', methods: ['POST'])]
|
||||
public function tick(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('tasks-tick', $request->request->getString('_csrf_token'))) {
|
||||
return new JsonResponse(['error' => 'token'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
|
||||
$state = $this->scheduler->fastState();
|
||||
if (!$state['admin']) {
|
||||
return new JsonResponse(['off' => true, 'ran' => []]);
|
||||
}
|
||||
@set_time_limit(120);
|
||||
$ran = $this->scheduler->runDue('verwaltung', 3, 45);
|
||||
|
||||
return new JsonResponse([
|
||||
'ran' => $ran,
|
||||
'next' => $this->scheduler->fastState()['next'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function back(string $error): Response
|
||||
{
|
||||
$this->addFlash('error', $error);
|
||||
|
||||
return $this->redirectToRoute('admin_tasks');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Ops\Scheduler;
|
||||
use App\Ops\Updater;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Der Auslöser, den ein Hosting ohne Cronjob hat: die Anfrage selbst.
|
||||
*
|
||||
* Nach `kernel.terminate`, also nachdem die Antwort das Haus verlassen hat. Unter PHP-FPM ruft
|
||||
* Symfony dafür `fastcgi_finish_request()` auf - der Browser ist fertig, während hier noch
|
||||
* gearbeitet wird. Unter Apache mit mod_php gibt es das nicht: die Seite steht dann zwar schon
|
||||
* im Browser, der Prozess bleibt aber belegt, bis die Aufgabe fertig ist. Deshalb sind die
|
||||
* Grenzen klein - zwei Aufgaben, zwanzig Sekunden -, und deshalb lässt sich dieser Auslöser
|
||||
* abschalten, wenn ein echter Cronjob die Arbeit übernimmt.
|
||||
*
|
||||
* Der Normalfall kostet nichts: eine Datei lesen, Zeitpunkt vergleichen, fertig. Erst wenn
|
||||
* wirklich etwas ansteht, wird die Datenbank gefragt.
|
||||
*/
|
||||
final class TaskWorkerSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/** Anfragen, hinter denen kein Mensch und keine Seite steht. */
|
||||
private const IGNORED = '#^/(assets/|media/|favicon)#';
|
||||
|
||||
/** Ein Aufruf, der die Aufgaben schon selbst abgearbeitet hat, setzt dieses Attribut. */
|
||||
public const HANDLED = 'kant_tasks_handled';
|
||||
|
||||
public function __construct(
|
||||
private Scheduler $scheduler,
|
||||
private string $projectDir,
|
||||
private string $adminPath,
|
||||
private int $limit = 2,
|
||||
private int $budget = 20,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// Nach dem Zähler der Statistik (Standardpriorität 0): der schreibt eine Zeile, das hier
|
||||
// kann Sekunden dauern.
|
||||
return [KernelEvents::TERMINATE => ['onTerminate', -64]];
|
||||
}
|
||||
|
||||
public function onTerminate(TerminateEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
if ($request->attributes->getBoolean(self::HANDLED)) {
|
||||
return;
|
||||
}
|
||||
$path = $request->getPathInfo();
|
||||
if (1 === preg_match(self::IGNORED, $path)) {
|
||||
return;
|
||||
}
|
||||
// Während eines Updates läuft nichts: die Dateien sind vielleicht schon die neuen und
|
||||
// die Datenbank noch die alte. Und vor der Installation gibt es keine Tabelle.
|
||||
if (is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE) || !is_file($this->projectDir.'/'.InstallSubscriber::MARKER)) {
|
||||
return;
|
||||
}
|
||||
$admin = str_starts_with($path, '/'.$this->adminPath);
|
||||
$state = $this->scheduler->fastState();
|
||||
if (!($admin ? $state['admin'] : $state['traffic'])) {
|
||||
return;
|
||||
}
|
||||
if (null !== $state['next'] && $state['next'] > time()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Die Antwort ist raus; ein abgebrochener Aufruf soll die Arbeit trotzdem zu Ende
|
||||
// bringen, sonst bliebe eine Aufgabe auf halbem Weg mit gesetzter Sperre liegen.
|
||||
@ignore_user_abort(true);
|
||||
@set_time_limit($this->budget + 60);
|
||||
$this->scheduler->runDue($admin ? 'verwaltung' : 'besuch', $this->limit, $this->budget);
|
||||
} catch (\Throwable) {
|
||||
// Eine Aufgabe darf niemals eine Website kaputtmachen. Was schiefging, steht in der
|
||||
// Zeile der Aufgabe und in var/tasks.log - hier ist niemand mehr, dem man es sagen
|
||||
// könnte: die Antwort ist längst verschickt.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ final class ArchiveUpdate
|
||||
* ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -,
|
||||
* bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg.
|
||||
*/
|
||||
private const MARKE = 'var/update/offen.json';
|
||||
public const MARKE = 'var/update/offen.json';
|
||||
|
||||
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
|
||||
private const PROTOKOLL = 'var/updates.log';
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops;
|
||||
|
||||
use App\Ops\Tasks\Task;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
|
||||
/**
|
||||
* Wiederkehrende Aufgaben auf einem Hosting, das keinen Cronjob hat.
|
||||
*
|
||||
* Das Problem ist nicht, *was* zu tun ist, sondern *wer es anstoesst*. Auf geteiltem Webhosting
|
||||
* gibt es oft weder SSH noch Cron; es gibt nur Anfragen. Also uebernehmen die Anfragen die
|
||||
* Arbeit - nach der Antwort, in `kernel.terminate`, damit kein Besucher darauf wartet. Vier
|
||||
* Ausloeser, dieselbe Mechanik:
|
||||
*
|
||||
* - **Besuch:** jeder Seitenaufruf schaut nach, ob etwas faellig ist (`TaskWorkerSubscriber`).
|
||||
* - **Verwaltung:** das geoeffnete Backend fragt im Takt nach, auch ohne Besucher.
|
||||
* - **URL-Cron:** ein Aufruf von `/cms-cron/<Schluessel>`, wenn der Hoster das anbietet.
|
||||
* - **Konsole:** `cms:tasks:run`, auch als Dauerlaeufer mit `--loop`.
|
||||
*
|
||||
* Was das *nicht* kann, und das gehoert an diese Stelle und nicht ins Kleingedruckte: **ohne
|
||||
* Anfrage passiert nichts.** Eine Aufgabe fuer 03:00 Uhr laeuft beim ersten Aufruf danach, auf
|
||||
* einer stillen Website also vielleicht um 07:15 Uhr. Fuer Sitemap, Aufraeumen, Updatepruefung
|
||||
* und Sicherung ist das brauchbar; fuer etwas, das minutengenau sein muss, ist es das nicht.
|
||||
*
|
||||
* Zwei Eigenschaften machen das im Betrieb tragfaehig:
|
||||
*
|
||||
* 1. **Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann das
|
||||
* naechste Mal ueberhaupt etwas ansteht. Solange dieser Zeitpunkt in der Zukunft liegt, ist
|
||||
* ein Seitenaufruf mit einem `file_get_contents` fertig.
|
||||
* 2. **Zwei Besucher fuehren eine Aufgabe nicht zweimal aus.** Wer sie ausfuehren darf, wird mit
|
||||
* einem bedingten UPDATE entschieden - das ist auf MySQL wie auf SQLite atomar. Wer keine
|
||||
* Zeile veraendert hat, war zu spaet und geht weiter.
|
||||
*/
|
||||
final class Scheduler
|
||||
{
|
||||
public const STATE_FILE = 'var/scheduler.json';
|
||||
public const LOG_FILE = 'var/tasks.log';
|
||||
|
||||
/**
|
||||
* Wie lange eine begonnene Aufgabe als laufend gilt.
|
||||
*
|
||||
* Es gibt keinen Weg, das Ende eines Prozesses zu erfahren, den das Hosting mitten im Lauf
|
||||
* abgeschossen hat. Also verfaellt die Sperre von selbst. Fuenfzehn Minuten sind lang genug
|
||||
* fuer eine Sicherung und kurz genug, dass eine haengengebliebene Aufgabe denselben Tag
|
||||
* wieder mitmacht.
|
||||
*/
|
||||
public const LOCK_SECONDS = 900;
|
||||
|
||||
/** Obergrenze fuer den wachsenden Abstand nach Fehlschlaegen: sechs Stunden. */
|
||||
private const MAX_BACKOFF = 21600;
|
||||
|
||||
/** So viele Zeilen behaelt das Protokoll beim Kuerzen. */
|
||||
private const LOG_LINES = 200;
|
||||
|
||||
/** @var array<string, Task>|null */
|
||||
private ?array $registry = null;
|
||||
|
||||
/** @param iterable<Task> $tasks */
|
||||
public function __construct(
|
||||
private Connection $db,
|
||||
private iterable $tasks,
|
||||
private string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ausfuehren
|
||||
|
||||
/**
|
||||
* Fuehrt aus, was faellig ist - hoechstens `$limit` Aufgaben und hoechstens `$budget` Sekunden.
|
||||
*
|
||||
* Beide Grenzen sind da, weil dieser Aufruf in einer Webanfrage stecken kann. Sie werden
|
||||
* *zwischen* zwei Aufgaben geprueft, nicht innerhalb einer: eine laufende Sicherung
|
||||
* abzubrechen waere schlimmer, als sie zu Ende laufen zu lassen.
|
||||
*
|
||||
* @return list<array{name: string, label: string, status: string, message: string, ms: int}>
|
||||
*/
|
||||
public function runDue(string $trigger, int $limit = 3, int $budget = 20, ?\DateTimeImmutable $now = null): array
|
||||
{
|
||||
$now ??= self::now();
|
||||
$registry = $this->registry();
|
||||
$rows = $this->rows();
|
||||
// Nach einem Update kennt der Code eine Aufgabe mehr oder eine weniger als die Datenbank.
|
||||
// Das faellt hier auf, und zwar ohne eine zusaetzliche Abfrage.
|
||||
if (array_keys($rows) !== array_keys($registry)) {
|
||||
if (!$this->sync($now)) {
|
||||
return [];
|
||||
}
|
||||
$rows = $this->rows();
|
||||
}
|
||||
$started = microtime(true);
|
||||
$results = [];
|
||||
foreach ($this->due($rows, $now) as $row) {
|
||||
if (count($results) >= $limit || microtime(true) - $started > $budget) {
|
||||
break;
|
||||
}
|
||||
if (!$this->claim((string) $row['name'], $now)) {
|
||||
// Ein anderer Prozess war schneller. Das ist kein Fehler, das ist der Zweck.
|
||||
continue;
|
||||
}
|
||||
$results[] = $this->execute($registry[(string) $row['name']], $row, $trigger, self::now());
|
||||
}
|
||||
$this->refreshState();
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt eine Aufgabe sofort aus, unabhaengig von ihrem Zeitplan.
|
||||
*
|
||||
* Der Knopf "Jetzt ausfuehren" in der Verwaltung. Abgeschaltete Aufgaben laufen so trotzdem -
|
||||
* wer sie von Hand startet, meint sie auch. Die Sperre gilt aber weiter: zweimal derselbe
|
||||
* Klick soll nicht zwei Sicherungen anlegen.
|
||||
*
|
||||
* Gibt null zurueck, wenn es die Aufgabe nicht gibt oder sie gerade schon laeuft.
|
||||
*
|
||||
* @return array{name: string, label: string, status: string, message: string, ms: int}|null
|
||||
*/
|
||||
public function runNow(string $name, string $trigger = 'hand'): ?array
|
||||
{
|
||||
$now = self::now();
|
||||
$registry = $this->registry();
|
||||
if (!isset($registry[$name])) {
|
||||
return null;
|
||||
}
|
||||
$this->sync($now);
|
||||
$rows = $this->rows();
|
||||
if (!isset($rows[$name]) || !$this->claim($name, $now, false)) {
|
||||
return null;
|
||||
}
|
||||
$result = $this->execute($registry[$name], $rows[$name], $trigger, self::now());
|
||||
$this->refreshState();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sichert sich eine Aufgabe.
|
||||
*
|
||||
* Der ganze Schutz gegen doppelte Ausfuehrung steckt in dieser einen Anweisung: die
|
||||
* Bedingungen stehen im WHERE, nicht in PHP. Zwischen einem `SELECT ... WHERE faellig` und
|
||||
* einem `UPDATE` liegt ein Moment, in dem ein zweiter Prozess dasselbe liest - hier nicht.
|
||||
* Wer 1 zurueckbekommt, hat sie; wer 0 zurueckbekommt, war zu spaet.
|
||||
*/
|
||||
private function claim(string $name, \DateTimeImmutable $now, bool $onlyWhenDue = true): bool
|
||||
{
|
||||
$stamp = $now->format('Y-m-d H:i:s');
|
||||
$until = $now->modify('+'.self::LOCK_SECONDS.' seconds')->format('Y-m-d H:i:s');
|
||||
$sql = 'UPDATE scheduled_task SET locked_until = ?, last_run_at = ? WHERE name = ? AND (locked_until IS NULL OR locked_until <= ?)';
|
||||
$parameters = [$until, $stamp, $name, $stamp];
|
||||
if ($onlyWhenDue) {
|
||||
$sql .= ' AND next_run_at <= ?';
|
||||
$parameters[] = $stamp;
|
||||
}
|
||||
|
||||
try {
|
||||
return 1 === $this->db->executeStatement($sql, $parameters);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*
|
||||
* @return array{name: string, label: string, status: string, message: string, ms: int}
|
||||
*/
|
||||
private function execute(Task $task, array $row, string $trigger, \DateTimeImmutable $now): array
|
||||
{
|
||||
$begin = microtime(true);
|
||||
try {
|
||||
$message = trim($task->run());
|
||||
$status = 'ok';
|
||||
$failures = 0;
|
||||
} catch (\Throwable $error) {
|
||||
// Auch ein Fehler ist ein Ergebnis. Er steht in der Zeile, im Protokoll und auf dem
|
||||
// Bildschirm "Aufgaben" - und er verlaengert den Abstand bis zum naechsten Versuch,
|
||||
// damit eine Aufgabe, die nicht laufen *kann*, nicht bei jedem Aufruf neu anfaengt.
|
||||
$status = 'fehler';
|
||||
$message = sprintf('%s (%s:%d)', $error->getMessage(), basename($error->getFile()), $error->getLine());
|
||||
$failures = (int) $row['failures'] + 1;
|
||||
}
|
||||
$ms = (int) round((microtime(true) - $begin) * 1000);
|
||||
$interval = max(60, (int) $row['interval_seconds']);
|
||||
$delay = 'ok' === $status
|
||||
? $interval
|
||||
: (int) min(self::MAX_BACKOFF, $interval * 2 ** min($failures, 6));
|
||||
$message = '' === $message ? 'Erledigt.' : mb_substr($message, 0, 480);
|
||||
|
||||
try {
|
||||
$this->db->executeStatement(
|
||||
'UPDATE scheduled_task SET next_run_at = ?, last_status = ?, last_message = ?, last_duration_ms = ?, last_trigger = ?, failures = ?, locked_until = NULL WHERE name = ?',
|
||||
[
|
||||
$now->modify('+'.$delay.' seconds')->format('Y-m-d H:i:s'),
|
||||
$status, $message, $ms, $trigger, $failures, $task->name(),
|
||||
],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Die Aufgabe ist gelaufen; nur das Nachtragen ging schief. Die Sperre verfaellt von
|
||||
// selbst, und das Protokoll unten haelt fest, was passiert ist.
|
||||
}
|
||||
$this->append(sprintf(
|
||||
'[%s] %-14s %-7s %6d ms %-11s %s',
|
||||
$now->format('Y-m-d H:i:s'),
|
||||
$task->name(),
|
||||
$status,
|
||||
$ms,
|
||||
$trigger,
|
||||
str_replace(["\r", "\n"], ' ', $message),
|
||||
));
|
||||
|
||||
return ['name' => $task->name(), 'label' => $task->label(), 'status' => $status, 'message' => $message, 'ms' => $ms];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zustand und Verwaltung
|
||||
|
||||
/**
|
||||
* Der schnelle Blick, ohne Datenbank: darf der Besuchs- beziehungsweise Backend-Ausloeser
|
||||
* arbeiten, und wann steht ueberhaupt das naechste Mal etwas an?
|
||||
*
|
||||
* Fehlt die Datei - frische Installation, geleertes `var/`, kein Schreibrecht -, ist die
|
||||
* Antwort "ja, und der Zeitpunkt ist unbekannt". Dann fragt der naechste Aufruf die
|
||||
* Datenbank und schreibt sie neu. Der teure Weg ist der Ausnahmefall, nicht der Normalfall.
|
||||
*
|
||||
* @return array{traffic: bool, admin: bool, next: ?int}
|
||||
*/
|
||||
public function fastState(): array
|
||||
{
|
||||
$raw = @file_get_contents($this->projectDir.'/'.self::STATE_FILE);
|
||||
$data = is_string($raw) ? json_decode($raw, true) : null;
|
||||
if (!is_array($data)) {
|
||||
return ['traffic' => true, 'admin' => true, 'next' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'traffic' => (bool) ($data['traffic'] ?? true),
|
||||
'admin' => (bool) ($data['admin'] ?? true),
|
||||
'next' => isset($data['next']) ? (int) $data['next'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Schluessel fuer den URL-Cronjob, abgeleitet aus dem Anwendungsgeheimnis.
|
||||
*
|
||||
* Nicht in der Datenbank, weil er dann eine Migration, eine Oberflaeche zum Wechseln und
|
||||
* einen Weg braeuchte, ihn nach einem Restore wiederzufinden. So ist er auf jeder
|
||||
* Installation ein anderer, ueberlebt jedes Update und wechselt genau dann, wenn das
|
||||
* Geheimnis wechselt - also dann, wenn man ihn wechseln will.
|
||||
*/
|
||||
public static function cronToken(string $secret): string
|
||||
{
|
||||
return substr(hash_hmac('sha256', 'cms-cron', $secret), 0, 40);
|
||||
}
|
||||
|
||||
/** Schaltet die beiden Ausloeser, die in Anfragen stecken. Konsole und URL-Cron laufen unabhaengig davon weiter. */
|
||||
public function setWorkers(bool $traffic, bool $admin): void
|
||||
{
|
||||
$this->writeState(['traffic' => $traffic, 'admin' => $admin, 'next' => $this->fastState()['next']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt fehlende Zeilen an und entfernt Zeilen ohne Aufgabe im Code.
|
||||
*
|
||||
* Neue Aufgaben starten mit einem Abstand von zwei Minuten und nicht sofort: sonst faellt
|
||||
* direkt nach einem Update alles gleichzeitig an, und zwar im ersten Seitenaufruf danach.
|
||||
*
|
||||
* @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und
|
||||
* zwischen hochgeladenem Code und ausgefuehrter Migration
|
||||
*/
|
||||
public function sync(?\DateTimeImmutable $now = null): bool
|
||||
{
|
||||
$now ??= self::now();
|
||||
$registry = $this->registry();
|
||||
|
||||
try {
|
||||
$existing = $this->rows();
|
||||
foreach ($registry as $name => $task) {
|
||||
if (isset($existing[$name])) {
|
||||
continue;
|
||||
}
|
||||
$this->db->insert('scheduled_task', [
|
||||
'name' => $name,
|
||||
'interval_seconds' => $task->interval(),
|
||||
'enabled' => true,
|
||||
'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'),
|
||||
'last_message' => '',
|
||||
'last_duration_ms' => 0,
|
||||
'last_trigger' => '',
|
||||
'failures' => 0,
|
||||
]);
|
||||
}
|
||||
foreach (array_keys($existing) as $name) {
|
||||
if (!isset($registry[$name])) {
|
||||
$this->db->executeStatement('DELETE FROM scheduled_task WHERE name = ?', [$name]);
|
||||
}
|
||||
}
|
||||
} catch (TableNotFoundException) {
|
||||
// Vor der Installation und zwischen hochgeladenem Code und ausgefuehrter Migration.
|
||||
return false;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
$this->refreshState();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles, was der Bildschirm "Aufgaben" zeigt.
|
||||
*
|
||||
* @return list<array{name: string, label: string, explanation: string, interval: int,
|
||||
* enabled: bool, next: string, last: ?string, status: ?string, message: string,
|
||||
* ms: int, trigger: string, failures: int, running: bool, due: bool}>
|
||||
*/
|
||||
public function overview(?\DateTimeImmutable $now = null): array
|
||||
{
|
||||
$now ??= self::now();
|
||||
$this->sync($now);
|
||||
$stamp = $now->format('Y-m-d H:i:s');
|
||||
$overview = [];
|
||||
foreach ($this->rows() as $name => $row) {
|
||||
$task = $this->registry()[$name] ?? null;
|
||||
if (null === $task) {
|
||||
continue;
|
||||
}
|
||||
$locked = (string) ($row['locked_until'] ?? '');
|
||||
$overview[] = [
|
||||
'name' => $name,
|
||||
'label' => $task->label(),
|
||||
'explanation' => $task->explanation(),
|
||||
'interval' => (int) $row['interval_seconds'],
|
||||
'enabled' => (bool) $row['enabled'],
|
||||
'next' => (string) $row['next_run_at'],
|
||||
'last' => null !== $row['last_run_at'] ? (string) $row['last_run_at'] : null,
|
||||
'status' => null !== $row['last_status'] ? (string) $row['last_status'] : null,
|
||||
'message' => (string) $row['last_message'],
|
||||
'ms' => (int) $row['last_duration_ms'],
|
||||
'trigger' => (string) $row['last_trigger'],
|
||||
'failures' => (int) $row['failures'],
|
||||
'running' => '' !== $locked && $locked > $stamp,
|
||||
'due' => (bool) $row['enabled'] && (string) $row['next_run_at'] <= $stamp,
|
||||
];
|
||||
}
|
||||
|
||||
return $overview;
|
||||
}
|
||||
|
||||
public function enable(string $name, bool $on): void
|
||||
{
|
||||
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [$on, $name]);
|
||||
$this->refreshState();
|
||||
}
|
||||
|
||||
/** Der Abstand, in Sekunden. Unter einer Minute ergibt er keinen Sinn, ueber einem Monat auch nicht. */
|
||||
public function setInterval(string $name, int $seconds): void
|
||||
{
|
||||
$this->db->executeStatement(
|
||||
'UPDATE scheduled_task SET interval_seconds = ? WHERE name = ?',
|
||||
[max(60, min(2592000, $seconds)), $name],
|
||||
);
|
||||
$this->refreshState();
|
||||
}
|
||||
|
||||
/** Gibt eine haengende Sperre frei - fuer den Fall, dass ein Prozess mitten im Lauf abgebrochen wurde. */
|
||||
public function release(string $name): void
|
||||
{
|
||||
$this->db->executeStatement('UPDATE scheduled_task SET locked_until = NULL WHERE name = ?', [$name]);
|
||||
$this->refreshState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Die letzten Zeilen des Protokolls, neueste zuerst.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function protocol(int $lines = 40): array
|
||||
{
|
||||
$raw = @file_get_contents($this->projectDir.'/'.self::LOG_FILE);
|
||||
if (!is_string($raw) || '' === trim($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_slice(array_reverse(array_values(array_filter(explode("\n", trim($raw))))), 0, $lines);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Innenleben
|
||||
|
||||
/** @return array<string, Task> */
|
||||
private function registry(): array
|
||||
{
|
||||
if (null !== $this->registry) {
|
||||
return $this->registry;
|
||||
}
|
||||
$registry = [];
|
||||
foreach ($this->tasks as $task) {
|
||||
$registry[$task->name()] = $task;
|
||||
}
|
||||
ksort($registry);
|
||||
|
||||
return $this->registry = $registry;
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> */
|
||||
private function rows(): array
|
||||
{
|
||||
try {
|
||||
$rows = $this->db->fetchAllAssociative('SELECT * FROM scheduled_task');
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
$indexed = [];
|
||||
foreach ($rows as $row) {
|
||||
$indexed[(string) $row['name']] = $row;
|
||||
}
|
||||
ksort($indexed);
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $rows
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function due(array $rows, \DateTimeImmutable $now): array
|
||||
{
|
||||
$stamp = $now->format('Y-m-d H:i:s');
|
||||
$due = [];
|
||||
foreach ($rows as $row) {
|
||||
$locked = (string) ($row['locked_until'] ?? '');
|
||||
if ((bool) $row['enabled'] && (string) $row['next_run_at'] <= $stamp && ('' === $locked || $locked <= $stamp)) {
|
||||
$due[] = $row;
|
||||
}
|
||||
}
|
||||
// Die am laengsten ueberfaellige zuerst - sonst kommt eine Aufgabe mit kurzem Abstand
|
||||
// immer wieder dran und eine mit langem nie.
|
||||
usort($due, static fn (array $a, array $b): int => strcmp((string) $a['next_run_at'], (string) $b['next_run_at']));
|
||||
|
||||
return $due;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt in die Zustandsdatei, wann das naechste Mal etwas ansteht.
|
||||
*
|
||||
* Eine laufende Aufgabe zaehlt dabei erst ab dem Ende ihrer Sperre. Ohne das waere waehrend
|
||||
* einer langen Sicherung jeder Seitenaufruf wieder bei der Datenbank: faellig ist sie ja,
|
||||
* sie ist nur schon vergeben. Mit dem Ende der Sperre steht in der Datei ein Zeitpunkt in
|
||||
* der Zukunft, und die uebrigen Anfragen sind nach einem Dateizugriff fertig.
|
||||
*/
|
||||
private function refreshState(): void
|
||||
{
|
||||
$next = null;
|
||||
foreach ($this->rows() as $row) {
|
||||
if (!(bool) $row['enabled']) {
|
||||
continue;
|
||||
}
|
||||
$when = max((string) $row['next_run_at'], (string) ($row['locked_until'] ?? ''));
|
||||
$stamp = strtotime($when.' UTC');
|
||||
if (false !== $stamp && (null === $next || $stamp < $next)) {
|
||||
$next = $stamp;
|
||||
}
|
||||
}
|
||||
$state = $this->fastState();
|
||||
$this->writeState(['traffic' => $state['traffic'], 'admin' => $state['admin'], 'next' => $next]);
|
||||
}
|
||||
|
||||
/** @param array{traffic: bool, admin: bool, next: ?int} $state */
|
||||
private function writeState(array $state): void
|
||||
{
|
||||
$file = $this->projectDir.'/'.self::STATE_FILE;
|
||||
if (!is_dir(dirname($file))) {
|
||||
@mkdir(dirname($file), 0o775, true);
|
||||
}
|
||||
// Schlaegt das fehl - schreibgeschuetztes var/ -, fragt jeder Aufruf wieder die
|
||||
// Datenbank. Langsamer, aber richtig; ein Fehler ist es nicht.
|
||||
@file_put_contents($file, json_encode($state, JSON_PRETTY_PRINT), LOCK_EX);
|
||||
}
|
||||
|
||||
private function append(string $line): void
|
||||
{
|
||||
$file = $this->projectDir.'/'.self::LOG_FILE;
|
||||
if (!is_dir(dirname($file))) {
|
||||
@mkdir(dirname($file), 0o775, true);
|
||||
}
|
||||
@file_put_contents($file, $line."\n", FILE_APPEND | LOCK_EX);
|
||||
// Selbst begrenzend, damit das Protokoll auch ohne die Aufraeumaufgabe nie gross wird.
|
||||
if ((int) @filesize($file) > 100000) {
|
||||
$lines = array_values(array_filter(explode("\n", (string) @file_get_contents($file))));
|
||||
@file_put_contents($file, implode("\n", array_slice($lines, -self::LOG_LINES))."\n", LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
||||
private static function now(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Ops\Backup;
|
||||
|
||||
/**
|
||||
* Die tägliche Sicherung von Datenbank und Medien.
|
||||
*
|
||||
* Die schwerste Aufgabe im Plan, und die einzige, bei der das Ausführen in einer Webanfrage
|
||||
* wirklich etwas kostet: sie kopiert die Datenbank und den Medienordner. Der Besucher wartet
|
||||
* trotzdem nicht darauf – sie läuft nach der ausgelieferten Antwort. Was sie belegt, ist ein
|
||||
* PHP-Prozess des Hostings, einmal am Tag, für die Dauer einer Kopie.
|
||||
*
|
||||
* Wer einen echten Cronjob hat, sollte sie dort laufen lassen und hier abschalten. Wer keinen
|
||||
* hat, hat mit dieser Aufgabe eine tägliche Sicherung statt gar keiner – und das ist der Grund,
|
||||
* warum sie trotz ihres Gewichts im Plan steht.
|
||||
*/
|
||||
final class BackupTask implements Task
|
||||
{
|
||||
public function __construct(private Backup $backup)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'backup';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Sicherung anlegen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Ohne sie gibt es keinen Rückweg außer der von Hand angelegten Sicherung. Sie rotiert selbst und enthält Datenbank und Medien, aber weder .env noch vendor/.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 86400;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$result = $this->backup->create('automatisch');
|
||||
$removed = $this->backup->rotate();
|
||||
|
||||
return sprintf(
|
||||
'%s angelegt: %d Seite(n), %d Mediendatei(en).%s',
|
||||
$result['name'],
|
||||
$result['manifest']['inhalt']['seiten'],
|
||||
$result['manifest']['medien']['dateien'],
|
||||
[] === $removed ? '' : sprintf(' %d alte entfernt.', count($removed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Scheduler;
|
||||
use App\Ops\Updater;
|
||||
|
||||
/**
|
||||
* Räumt in `var/` auf, was mit der Zeit liegen bleibt.
|
||||
*
|
||||
* Drei Dinge, und ausdrücklich nur diese drei: abgelaufene Sitzungsdateien, alte Arbeitsordner
|
||||
* eines Updates und zu lang gewordene Protokolle. Nichts davon ist Inhalt, nichts davon lässt
|
||||
* sich vermissen.
|
||||
*
|
||||
* Was diese Aufgabe **nicht** anfasst: Sicherungen (die rotiert `cms:backup` selbst und nach
|
||||
* einer anderen Regel), den Cache (den baut Symfony auf, wenn er fehlt, aber ihn mitten im
|
||||
* Betrieb zu löschen macht die nächste Anfrage langsam ohne Grund) und den Ordner eines Updates,
|
||||
* das noch auf seinen zweiten Schritt wartet.
|
||||
*/
|
||||
final class CleanupTask implements Task
|
||||
{
|
||||
/** Sitzungen und Update-Arbeitsordner, die älter sind, werden entfernt. */
|
||||
private const KEEP_DAYS = 30;
|
||||
|
||||
/** So viele Zeilen bleiben in den Protokollen stehen. */
|
||||
private const KEEP_LINES = 200;
|
||||
|
||||
public function __construct(private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'cleanup';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Aufräumen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return sprintf(
|
||||
'Entfernt Sitzungsdateien und Update-Arbeitsordner, die älter als %d Tage sind, und kürzt die Protokolle. Sicherungen und Inhalte bleiben unberührt.',
|
||||
self::KEEP_DAYS,
|
||||
);
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 86400;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$limit = time() - self::KEEP_DAYS * 86400;
|
||||
|
||||
return sprintf(
|
||||
'%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.',
|
||||
$this->sessions($limit),
|
||||
$this->updateFolders($limit),
|
||||
$this->logs(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sitzungsdateien. Der Garbage Collector von PHP ist auf vielen Hostings abgeschaltet. */
|
||||
private function sessions(int $limit): int
|
||||
{
|
||||
$removed = 0;
|
||||
foreach (glob($this->projectDir.'/var/sessions/*/sess_*') ?: [] as $file) {
|
||||
if (is_file($file) && (int) @filemtime($file) < $limit && @unlink($file)) {
|
||||
++$removed;
|
||||
}
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Arbeitsordner des Archiv-Updates.
|
||||
*
|
||||
* Der jüngste ist der Rückweg aus dem letzten Update, und den lässt diese Aufgabe einen
|
||||
* Monat lang stehen. Wartet ein Update noch auf seinen zweiten Schritt, wird gar nichts
|
||||
* angefasst: dann ist einer dieser Ordner der Stand, auf den zurückgegangen wird.
|
||||
*/
|
||||
private function updateFolders(int $limit): int
|
||||
{
|
||||
if (is_file($this->projectDir.'/'.ArchiveUpdate::MARKE)) {
|
||||
return 0;
|
||||
}
|
||||
$removed = 0;
|
||||
foreach (glob($this->projectDir.'/var/update/*', GLOB_ONLYDIR) ?: [] as $folder) {
|
||||
if ((int) @filemtime($folder) < $limit && $this->removeDirectory($folder)) {
|
||||
++$removed;
|
||||
}
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
private function logs(): int
|
||||
{
|
||||
$shortened = 0;
|
||||
foreach ([Updater::LOG_FILE, Scheduler::LOG_FILE] as $relative) {
|
||||
$file = $this->projectDir.'/'.$relative;
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw)) {
|
||||
continue;
|
||||
}
|
||||
$lines = array_values(array_filter(explode("\n", $raw)));
|
||||
if (count($lines) <= self::KEEP_LINES) {
|
||||
continue;
|
||||
}
|
||||
if (false !== @file_put_contents($file, implode("\n", array_slice($lines, -self::KEEP_LINES))."\n", LOCK_EX)) {
|
||||
++$shortened;
|
||||
}
|
||||
}
|
||||
|
||||
return $shortened;
|
||||
}
|
||||
|
||||
/** Rekursiv löschen, aber nur unterhalb von `var/` – geprüft, nicht angenommen. */
|
||||
private function removeDirectory(string $path): bool
|
||||
{
|
||||
$real = realpath($path);
|
||||
$var = realpath($this->projectDir.'/var');
|
||||
if (false === $real || false === $var || !str_starts_with($real, $var.DIRECTORY_SEPARATOR)) {
|
||||
return false;
|
||||
}
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||
}
|
||||
|
||||
return @rmdir($real);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\Statistics;
|
||||
|
||||
/**
|
||||
* Verdichtet und löscht die Zähler der Statistik.
|
||||
*
|
||||
* Einmal täglich reicht: die Tabelle wächst mit Seiten und Stunden, nicht mit Besuchen, und ein
|
||||
* Tag Verzug ändert an ihrer Größe nichts.
|
||||
*/
|
||||
final class PruneStatisticsTask implements Task
|
||||
{
|
||||
public function __construct(private Statistics $statistics)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'stats-prune';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Statistik verdichten';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return sprintf(
|
||||
'Ohne sie behält die Statistik jede Stunde einzeln, für immer. Verdichtet wird nach %d Tagen, gelöscht nach %d.',
|
||||
Statistics::COMPACT_AFTER_DAYS,
|
||||
Statistics::KEEP_DAYS,
|
||||
);
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 86400;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$result = $this->statistics->prune();
|
||||
|
||||
return sprintf(
|
||||
'%d Tag(e) zusammengefasst, %d Zeile(n) jenseits von %d Tagen entfernt.',
|
||||
$result['compacted'],
|
||||
$result['removed'],
|
||||
Statistics::KEEP_DAYS,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Content\PageRepository;
|
||||
|
||||
/**
|
||||
* Veröffentlicht Seiten, deren geplanter Zeitpunkt vergangen ist.
|
||||
*
|
||||
* Die Aufgabe mit dem kürzesten Abstand, weil sie die einzige ist, die jemand *bemerkt*: wer eine
|
||||
* Seite für Montag 9 Uhr plant, schaut Montag um 9 Uhr nach. Fünf Minuten sind der Kompromiss
|
||||
* zwischen „wirkt sofort" und „fällt bei jedem zweiten Seitenaufruf an".
|
||||
*/
|
||||
final class PublishDueTask implements Task
|
||||
{
|
||||
public function __construct(private PageRepository $pages)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'publish-due';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Geplante Veröffentlichungen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Ohne sie bleibt eine geplante Seite ein Entwurf – die sichere Richtung, aber nicht die gewollte.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 300;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$published = $this->pages->publishDue();
|
||||
|
||||
return [] === $published
|
||||
? 'Nichts fällig.'
|
||||
: sprintf('%d Seite(n) veröffentlicht: /%s', count($published), implode(', /', $published));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
/**
|
||||
* Eine wiederkehrende Aufgabe.
|
||||
*
|
||||
* Der Vertrag ist absichtlich winzig, weil der Ausloeser nicht feststeht: dieselbe Aufgabe laeuft
|
||||
* je nach Hosting im Seitenaufruf eines Besuchers, im geoeffneten Backend, ueber einen
|
||||
* URL-Cronjob oder auf der Konsole. Was sie tut, darf davon nicht abhaengen.
|
||||
*
|
||||
* Drei Regeln, die aus dem Ausfuehrungsort folgen:
|
||||
*
|
||||
* 1. **Kurz sein oder unterbrechbar.** Ein Lauf haengt an einem PHP-Prozess, den das Hosting
|
||||
* nach seiner eigenen Zeit abbricht. Wer viel zu tun hat, arbeitet portionsweise und
|
||||
* berichtet, was offen blieb - der naechste Lauf macht weiter.
|
||||
* 2. **Fehler werfen, nicht schlucken.** Eine geworfene Ausnahme wird zum sichtbaren Fehler mit
|
||||
* wachsendem Abstand bis zum naechsten Versuch. Ein stilles `return` sieht aus wie Erfolg.
|
||||
* 3. **Nichts ausgeben.** Es gibt keine Konsole; die Rueckgabe ist der ganze Bericht.
|
||||
*/
|
||||
interface Task
|
||||
{
|
||||
/** Technischer, gleichbleibender Name, zum Beispiel `publish-due`. Er ist der Schluessel in der Datenbank. */
|
||||
public function name(): string;
|
||||
|
||||
/** Die Bezeichnung in der Verwaltung. */
|
||||
public function label(): string;
|
||||
|
||||
/** Ein Satz darueber, was ausbleibt, wenn diese Aufgabe nicht laeuft. */
|
||||
public function explanation(): string;
|
||||
|
||||
/** Vorgabe fuer den Abstand zwischen zwei Laeufen, in Sekunden. In der Verwaltung aenderbar. */
|
||||
public function interval(): int;
|
||||
|
||||
/**
|
||||
* Fuehrt die Aufgabe aus.
|
||||
*
|
||||
* @return string eine kurze Meldung fuer den Betreiber, die auch dann etwas sagt, wenn nichts
|
||||
* zu tun war ("Nichts faellig." ist eine Antwort, "" ist keine)
|
||||
*
|
||||
* @throws \Throwable wenn die Aufgabe ihr Ziel nicht erreicht hat
|
||||
*/
|
||||
public function run(): string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Ops\Tasks;
|
||||
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Updater;
|
||||
|
||||
/**
|
||||
* Fragt einmal täglich, ob es eine neuere Fassung gibt.
|
||||
*
|
||||
* Sie prüft nur und installiert nichts. Ein Update tauscht Dateien und führt Migrationen aus;
|
||||
* das gehört an einen Knopf, den ein Mensch drückt, und nicht in den Seitenaufruf eines
|
||||
* Besuchers.
|
||||
*
|
||||
* Ist die Fassungsliste nicht erreichbar, ist das ein Fehler und keine Entwarnung: „keine
|
||||
* Antwort" und „kein Update" sehen sonst gleich aus, und das eine ist eine Nachricht, das andere
|
||||
* eine ausgefallene Prüfung.
|
||||
*/
|
||||
final class UpdateCheckTask implements Task
|
||||
{
|
||||
public function __construct(private ArchiveUpdate $archive, private Updater $updater)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'update-check';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Nach Updates sehen';
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Fragt beim Repository nach der neuesten Fassung. Sie installiert nichts – das Ergebnis steht hier und auf dem Bildschirm „System".';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return 86400;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
$latest = $this->archive->latest();
|
||||
if (null === $latest) {
|
||||
throw new \RuntimeException('Die Fassungsliste war nicht erreichbar. Erlaubt dieses Hosting ausgehende Verbindungen?');
|
||||
}
|
||||
$installed = $this->updater->version();
|
||||
|
||||
return version_compare($latest['version'], $installed, '>')
|
||||
? sprintf('Fassung %s ist verfügbar; installiert ist %s.', $latest['version'], $installed)
|
||||
: sprintf('Aktuell: Fassung %s.', $installed);
|
||||
}
|
||||
}
|
||||
+54
-5
@@ -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)),
|
||||
|
||||
@@ -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,13 +18,16 @@
|
||||
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'] %}
|
||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></script>
|
||||
{% block styles %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
{# Eigener Block, nicht Teil von `scripts`: Bildschirme wie die Mediathek ersetzen `scripts`
|
||||
vollstaendig, und der Takt der Verwaltung darf davon nicht abhaengen. #}
|
||||
{% block worker %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<a class="skip-link" href="#admin-main">Zum Inhalt springen</a>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,17 +9,18 @@
|
||||
{% 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>
|
||||
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
|
||||
<div class="section-launch"><button id="add-root-section" class="secondary full-width" disabled>+ Neue Sektion auf Seitenebene</button><p class="field-help">Nach dem aktuellen Abschnitt, außerhalb des gewählten Inhalts.</p></div>
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><p id="insert-hint" class="insert-hint" role="status" hidden>Einfügepunkt gesetzt — das nächste Element landet dort. <button id="cancel-insert" class="text-button" type="button">Abbrechen</button></p><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">Ziehen und in der Vorschau ablegen, oder klicken. Der Werkzeugkasten zeigt an, was an der gewählten Stelle hineinpasst — was dort nicht hingehört, sagt beim Überfahren warum.</p>
|
||||
<label class="search-label" for="library-search">Element zum Einfügen suchen</label>
|
||||
@@ -33,13 +35,15 @@
|
||||
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
|
||||
</section>
|
||||
</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>
|
||||
<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>
|
||||
<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">{{ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
theme: 'M12 3a9 9 0 1 0 0 18Zm0 0a9 9 0 0 1 0 18',
|
||||
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm8-3a8 8 0 0 0-.2-1.7l2-1.5-2-3.5-2.3 1a8 8 0 0 0-3-1.7L14 2h-4l-.5 2.6a8 8 0 0 0-3 1.7l-2.3-1-2 3.5 2 1.5a8 8 0 0 0 0 3.4l-2 1.5 2 3.5 2.3-1a8 8 0 0 0 3 1.7L10 22h4l.5-2.6a8 8 0 0 0 3-1.7l2.3 1 2-3.5-2-1.5c.1-.6.2-1.1.2-1.7Z',
|
||||
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
|
||||
tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2',
|
||||
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
|
||||
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5'
|
||||
} %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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/page-picker.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,36 +25,33 @@
|
||||
<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>
|
||||
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search">
|
||||
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
|
||||
<div><label for="page-status">Status</label><select id="page-status" name="status">{% 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">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
|
||||
<button class="secondary" type="submit">Anwenden</button>
|
||||
{% if query or status != 'all' %}<a class="text-button" href="{{ path(listRoute) }}">Zurücksetzen</a>{% endif %}
|
||||
</form>
|
||||
<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
|
||||
@@ -85,7 +86,8 @@
|
||||
<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="_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>
|
||||
@@ -143,7 +145,8 @@
|
||||
<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="_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>
|
||||
@@ -151,13 +154,7 @@
|
||||
<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>
|
||||
{% 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>
|
||||
@@ -171,7 +168,8 @@
|
||||
<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="_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'} %}
|
||||
@@ -196,12 +194,13 @@
|
||||
<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="_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. Die Veröffentlichung erfolgt, sobald <code>cms:publish-due</code> läuft.</p>
|
||||
<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>
|
||||
@@ -214,7 +213,8 @@
|
||||
<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="_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>
|
||||
@@ -224,7 +224,8 @@
|
||||
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="_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>
|
||||
@@ -239,12 +240,12 @@
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="empty"><h3>Noch keine Seiten vorhanden.</h3><p>Lege oben deine erste Seite an.</p></div>
|
||||
{% 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="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">
|
||||
@@ -256,7 +257,8 @@
|
||||
{% 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') }}">
|
||||
<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>
|
||||
@@ -266,5 +268,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% 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'} %}
|
||||
{% endblock %}
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
click away and the current one is visible. Screens fill in heading, lead, actions and content. #}
|
||||
{% extends 'admin/base.html.twig' %}
|
||||
{% block body_class %}admin-shell{% endblock %}
|
||||
{# Solange jemand hier arbeitet, laeuft der Aufgabenplan - auch auf einer Website ohne Besucher.
|
||||
Das Skript fragt im Minutentakt an; was faellig ist, entscheidet der Server. #}
|
||||
{% block worker %}
|
||||
<meta name="kant-tick" content="{{ path('admin_tasks_tick') }}">
|
||||
<meta name="kant-tick-token" content="{{ csrf_token('tasks-tick') }}">
|
||||
<script src="/assets/task-worker.js?v={{ kant_version() }}" defer></script>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<header class="shell-nav">
|
||||
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>sites</span></a>
|
||||
@@ -13,9 +20,9 @@
|
||||
<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', 'users': 'Benutzer',
|
||||
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer',
|
||||
'account': 'Mein Konto'
|
||||
}[nav|default('')]|default('Verwaltung') }}</span>
|
||||
</summary>
|
||||
@@ -24,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'},
|
||||
@@ -39,15 +47,15 @@
|
||||
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>
|
||||
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
|
||||
{% 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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -186,7 +198,7 @@
|
||||
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. <strong>Nicht enthalten sind <code>.env</code>, <code>vendor/</code> und der Quellcode</strong>: die kommen aus dem Repository, und <code>.env</code> enthält das Geheimnis dieser Installation.</p>
|
||||
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore <name></code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Für den täglichen Cronjob: <code>php bin/console cms:backup</code>.</p>
|
||||
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore <name></code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Die tägliche Sicherung erledigt der <a href="{{ path('admin_tasks') }}">Aufgabenplan</a> – auch ohne Cronjob.</p>
|
||||
<form action="{{ path('admin_system_backup') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="primary" type="submit">Sicherung jetzt anlegen</button>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'tasks' %}
|
||||
{% block title %}Aufgaben{% endblock %}
|
||||
{% block heading %}Wiederkehrende Aufgaben{% endblock %}
|
||||
{% block lead %}Was regelmäßig laufen soll, wer es anstößt und was zuletzt dabei herauskam. Alle Zeiten in UTC.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# Der wichtigste Satz zuerst, und zwar als Einschränkung und nicht als Werbung: ohne Anfrage
|
||||
passiert nichts. Wer das nicht weiß, sucht später den Fehler an der falschen Stelle. #}
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Wer die Arbeit anstößt</h2>
|
||||
<span class="muted">{{ next is null ? 'Zeitpunkt noch unbekannt' : 'Nächster Lauf: ' ~ next|date('Y-m-d H:i:s', 'UTC') ~ ' UTC' }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Ein Webhosting ohne Cronjob hat nur eines: Anfragen. Deshalb erledigt eine Anfrage die Arbeit – nach der ausgelieferten Antwort, sodass niemand darauf wartet. <strong>Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.</strong> Eine Aufgabe für 03:00 Uhr läuft dann beim ersten Aufruf danach, auf einer stillen Website vielleicht um 07:15 Uhr. Wer es genauer braucht, richtet einen der beiden unteren Wege ein.</p>
|
||||
|
||||
<form action="{{ path('admin_tasks_configure') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
|
||||
|
||||
<fieldset class="field-set">
|
||||
<legend>Auslöser in Anfragen</legend>
|
||||
<label class="check-label"><input type="checkbox" name="traffic" value="1"{{ traffic ? ' checked' : '' }}> Besuche der Website</label>
|
||||
<p class="field-help">Jeder Seitenaufruf schaut nach, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei und keine Datenbankabfrage. Ausschalten, wenn ein echter Cronjob die Arbeit übernimmt.</p>
|
||||
<label class="check-label"><input type="checkbox" name="admin" value="1"{{ admin ? ' checked' : '' }}> Geöffnete Verwaltung</label>
|
||||
<p class="field-help">Solange dieser Bildschirm offen und sichtbar ist, fragt er jede Minute nach. So läuft der Plan auch auf einer Website, die gerade niemand besucht.</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="section-head"><h2>Der Plan</h2><span class="muted">Abstand in Minuten</span></div>
|
||||
<table class="table tasks-table">
|
||||
<thead>
|
||||
<tr><th scope="col">Aufgabe</th><th scope="col" class="check-cell">An</th><th scope="col">Abstand</th><th scope="col">Nächster Lauf</th><th scope="col">Zuletzt</th><th scope="col"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ task.label }}</strong>
|
||||
<p class="field-help">{{ task.explanation }}</p>
|
||||
<p class="field-help"><code>{{ task.name }}</code></p>
|
||||
</td>
|
||||
<td class="check-cell">
|
||||
<input type="checkbox" name="enabled[{{ task.name }}]" value="1"{{ task.enabled ? ' checked' : '' }} aria-label="{{ task.label }} ausführen">
|
||||
</td>
|
||||
<td><input type="number" name="interval[{{ task.name }}]" value="{{ (task.interval / 60)|round }}" min="1" max="43200" step="1" aria-label="Abstand für {{ task.label }} in Minuten"></td>
|
||||
<td>
|
||||
{% if not task.enabled %}<span class="pill pill-off">aus</span>
|
||||
{% elseif task.running %}<span class="pill pill-draft">läuft</span>
|
||||
{% elseif task.due %}<span class="pill pill-draft">fällig</span>
|
||||
{% else %}<span class="muted">{{ task.next }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if task.status is null %}
|
||||
<span class="muted">noch nie</span>
|
||||
{% else %}
|
||||
<span class="pill {{ task.status == 'ok' ? 'pill-live' : 'pill-off' }}">{{ task.status }}</span>
|
||||
<p class="field-help">{{ task.last }} · {{ task.ms }} ms{% if task.trigger %} · durch {{ task.trigger }}{% endif %}</p>
|
||||
<p class="field-help{{ task.status == 'ok' ? '' : ' danger-text' }}">{{ task.message }}</p>
|
||||
{% if task.failures > 1 %}<p class="field-help danger-text">{{ task.failures }} Fehlschläge nacheinander – der Abstand bis zum nächsten Versuch wächst.</p>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="secondary" type="submit" form="run-{{ task.name }}">Jetzt ausführen</button>
|
||||
{% if task.running %}<button class="text-button" type="submit" form="release-{{ task.name }}">Sperre aufheben</button>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">Keine Aufgaben. Fehlt die Migration? Der Bildschirm <a href="{{ path('admin_system') }}">System</a> führt sie aus.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="primary" type="submit">Plan speichern</button>
|
||||
</form>
|
||||
|
||||
{# Die Formulare der Knöpfe stehen außerhalb der Tabelle: ein Formular im Formular gibt es
|
||||
in HTML nicht, und `form="..."` verbindet Knopf und Formular über die Tabelle hinweg. #}
|
||||
{% for task in tasks %}
|
||||
<form id="run-{{ task.name }}" action="{{ path('admin_tasks_run') }}" method="post" hidden>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
|
||||
<input type="hidden" name="name" value="{{ task.name }}">
|
||||
</form>
|
||||
{% if task.running %}
|
||||
<form id="release-{{ task.name }}" action="{{ path('admin_tasks_release') }}" method="post" hidden>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
|
||||
<input type="hidden" name="name" value="{{ task.name }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Der zuverlässige Weg</h2><span class="muted">falls dieses Hosting ihn anbietet</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Beide Wege sind unabhängig von den Schaltern oben – sie laufen auch dann, wenn Besuch und Verwaltung ausgeschaltet sind. Wer einen davon einrichtet, sollte den Besuchs-Auslöser abschalten: die Arbeit ist dann schon getan, und die Website antwortet ein wenig schneller.</p>
|
||||
<dl class="facts">
|
||||
<dt>URL-Cronjob</dt>
|
||||
<dd>
|
||||
<code>{{ cronUrl }}</code>
|
||||
<p class="field-help">Diese Adresse beim Hoster als Cronjob eintragen, zum Beispiel alle fünf Minuten. Der Schlüssel ist aus dem Anwendungsgeheimnis abgeleitet, steht in keiner Datenbank und ändert sich, wenn das Geheimnis gewechselt wird. Er gehört nicht in eine E-Mail und nicht in ein Ticket.</p>
|
||||
</dd>
|
||||
<dt>Konsole</dt>
|
||||
<dd>
|
||||
<code>php bin/console cms:tasks:run</code>
|
||||
<p class="field-help">Für einen echten Cronjob, jede Minute. <code>cms:tasks</code> zeigt denselben Plan wie dieser Bildschirm, <code>cms:tasks:run --loop=60</code> läuft dauerhaft.</p>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Protokoll</h2><span class="muted">die letzten Läufe, neueste zuerst</span></div>
|
||||
<div class="card-body">
|
||||
{% if protocol is empty %}
|
||||
<p class="muted">Noch nichts gelaufen. Steht in der Spalte „Nächster Lauf“ ein Zeitpunkt, ist alles in Ordnung – der Plan wartet.</p>
|
||||
{% else %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
{% for line in protocol %}
|
||||
<tr><td><code>{{ line }}</code></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="field-help">Aus <code>var/tasks.log</code>. Die Datei kürzt sich selbst; die Aufgabe „Aufräumen“ tut es ebenfalls.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -13,7 +13,7 @@
|
||||
<ul class="check-list">
|
||||
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Impressum und Datenschutzerklärung ausfüllen.</strong> Beide Seiten sind angelegt und veröffentlicht; die Pflichtangaben stehen als <code>NOCH AUSZUFÜLLEN</code> darin. Solange sie so dastehen, sind sie eine Baustelle mit Ansage – aber keine Erklärung.</span></li>
|
||||
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Die Datei <code>install.php</code> löschen</strong>, falls du über sie installiert hast. Sie löscht sich selbst, aber nachsehen kostet nichts.</span></li>
|
||||
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Einen täglichen Cronjob einrichten:</strong> <code>php bin/console cms:backup</code> für die Sicherung und <code>php bin/console cms:stats:prune</code> für die Statistik. Ohne Sicherung gibt es keinen Rückweg.</span></li>
|
||||
<li class="check-ok"><span class="check-mark" aria-hidden="true">✓</span><span><strong>Sicherung und Aufräumen laufen bereits.</strong> Der Aufgabenplan unter „Aufgaben“ wird von Seitenaufrufen und vom geöffneten Backend angestoßen – ohne Cronjob. Genau, aber nicht minutengenau: ohne Besuch passiert nichts. Wenn dein Hosting einen Cronjob anbietet, trage dort <code>php bin/console cms:tasks:run</code> ein (jede Minute) oder die Adresse des URL-Cronjobs vom Bildschirm „Aufgaben“.</span></li>
|
||||
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>HTTPS prüfen.</strong> Eine Anmeldung über <code>http://</code> überträgt das Passwort im Klartext.</span></li>
|
||||
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Inhalt und Gestaltung:</strong> Startseite bearbeiten, unter „Design“ Farben und Schriften setzen, unter „Einstellungen“ die Angaben zum Unternehmen ergänzen.</span></li>
|
||||
</ul>
|
||||
|
||||
@@ -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
@@ -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>}> */
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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] ?? '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Scheduler;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Der Aufgabenplan - und zwar die Eigenschaften, an denen er auf einem echten Hosting scheitern
|
||||
* würde: läuft eine Aufgabe zweimal, wenn zwei Besucher gleichzeitig kommen? Reißt eine kaputte
|
||||
* Aufgabe die Website mit? Fängt eine, die nicht laufen kann, bei jedem Seitenaufruf neu an?
|
||||
*/
|
||||
final class SchedulerTest extends TestCase
|
||||
{
|
||||
private Connection $db;
|
||||
private string $databaseFile;
|
||||
private string $projectDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->databaseFile = tempnam(sys_get_temp_dir(), 'kant-tasks-');
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->databaseFile));
|
||||
TestSchema::build($this->db);
|
||||
$this->projectDir = sys_get_temp_dir().'/kant-tasks-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->projectDir.'/var', 0o775, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->databaseFile);
|
||||
foreach (glob($this->projectDir.'/var/*') ?: [] as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
@rmdir($this->projectDir.'/var');
|
||||
@rmdir($this->projectDir);
|
||||
}
|
||||
|
||||
/** @param list<SpyTask> $tasks */
|
||||
private function scheduler(array $tasks): Scheduler
|
||||
{
|
||||
return new Scheduler($this->db, $tasks, $this->projectDir);
|
||||
}
|
||||
|
||||
private function makeDue(string $name): void
|
||||
{
|
||||
$this->db->executeStatement('UPDATE scheduled_task SET next_run_at = ? WHERE name = ?', ['2020-01-01 00:00:00', $name]);
|
||||
}
|
||||
|
||||
public function testTheCodeDecidesWhichTasksExist(): void
|
||||
{
|
||||
$this->scheduler([new SpyTask('a'), new SpyTask('b')])->sync();
|
||||
self::assertSame(['a', 'b'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task ORDER BY name'));
|
||||
|
||||
// Nach einem Update kennt der Code eine Aufgabe weniger: ihre Zeile geht mit.
|
||||
$this->scheduler([new SpyTask('a')])->sync();
|
||||
self::assertSame(['a'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task'));
|
||||
}
|
||||
|
||||
public function testANewTaskDoesNotRunInTheVeryNextRequest(): void
|
||||
{
|
||||
// Sonst faellt direkt nach einem Update alles gleichzeitig an - im ersten Aufruf danach.
|
||||
$task = new SpyTask('a');
|
||||
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
|
||||
self::assertSame(0, $task->runs);
|
||||
}
|
||||
|
||||
public function testWhatIsDueRunsAndIsThenRescheduled(): void
|
||||
{
|
||||
$task = new SpyTask('a', 600);
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->makeDue('a');
|
||||
|
||||
$results = $scheduler->runDue('besuch');
|
||||
self::assertCount(1, $results);
|
||||
self::assertSame('ok', $results[0]['status']);
|
||||
self::assertSame('Lauf 1', $results[0]['message']);
|
||||
self::assertSame(1, $task->runs);
|
||||
|
||||
// Danach ist sie nicht mehr faellig, und ein zweiter Aufruf laesst sie in Ruhe.
|
||||
self::assertSame([], $scheduler->runDue('besuch'));
|
||||
self::assertSame(1, $task->runs);
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
|
||||
self::assertSame('besuch', $row['last_trigger']);
|
||||
self::assertGreaterThan(gmdate('Y-m-d H:i:s'), (string) $row['next_run_at']);
|
||||
self::assertNull($row['locked_until']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der eigentliche Zweck der Sperre: zwei Besucher im selben Moment.
|
||||
*
|
||||
* Nachgestellt wird der Zustand, den der erste hinterlaesst, waehrend er noch arbeitet -
|
||||
* faellig, aber gesperrt. Der zweite muss daran vorbeigehen.
|
||||
*/
|
||||
public function testALockedTaskIsLeftAloneByTheNextRequest(): void
|
||||
{
|
||||
$task = new SpyTask('a');
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->db->executeStatement(
|
||||
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
|
||||
['2020-01-01 00:00:00', gmdate('Y-m-d H:i:s', time() + 300), 'a'],
|
||||
);
|
||||
|
||||
self::assertSame([], $scheduler->runDue('besuch'));
|
||||
self::assertSame(0, $task->runs);
|
||||
}
|
||||
|
||||
/** Eine Sperre, deren Prozess das Hosting abgeschossen hat, blockiert nicht fuer immer. */
|
||||
public function testAnExpiredLockIsTakenOver(): void
|
||||
{
|
||||
$task = new SpyTask('a');
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->db->executeStatement(
|
||||
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
|
||||
['2020-01-01 00:00:00', '2020-01-01 00:15:00', 'a'],
|
||||
);
|
||||
|
||||
self::assertCount(1, $scheduler->runDue('besuch'));
|
||||
self::assertSame(1, $task->runs);
|
||||
}
|
||||
|
||||
public function testAFailingTaskIsRecordedAndWaitsLongerEachTime(): void
|
||||
{
|
||||
$task = new SpyTask('a', 300, new \RuntimeException('Kein Netz.'));
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->makeDue('a');
|
||||
|
||||
$results = $scheduler->runDue('besuch');
|
||||
self::assertSame('fehler', $results[0]['status']);
|
||||
self::assertStringContainsString('Kein Netz.', $results[0]['message']);
|
||||
$first = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
|
||||
self::assertSame(1, (int) $first['failures']);
|
||||
// 300 Sekunden Abstand, ein Fehlschlag: der naechste Versuch liegt weiter weg als der
|
||||
// gewoehnliche Abstand, aber die Sperre ist weg - die Aufgabe haengt nicht fest.
|
||||
self::assertNull($first['locked_until']);
|
||||
self::assertGreaterThan(gmdate('Y-m-d H:i:s', time() + 400), (string) $first['next_run_at']);
|
||||
|
||||
$this->makeDue('a');
|
||||
$scheduler->runDue('besuch');
|
||||
$second = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
|
||||
self::assertSame(2, (int) $second['failures']);
|
||||
self::assertGreaterThan((string) $first['next_run_at'], (string) $second['next_run_at']);
|
||||
}
|
||||
|
||||
public function testASwitchedOffTaskNeverRuns(): void
|
||||
{
|
||||
$task = new SpyTask('a');
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$scheduler->enable('a', false);
|
||||
$this->makeDue('a');
|
||||
|
||||
self::assertSame([], $scheduler->runDue('besuch'));
|
||||
self::assertSame(0, $task->runs);
|
||||
|
||||
// Von Hand gestartet laeuft sie trotzdem: wer den Knopf drueckt, meint sie auch.
|
||||
$result = $scheduler->runNow('a');
|
||||
self::assertNotNull($result);
|
||||
self::assertSame(1, $task->runs);
|
||||
}
|
||||
|
||||
public function testOneRequestDoesNotRunEverythingAtOnce(): void
|
||||
{
|
||||
$tasks = [new SpyTask('a'), new SpyTask('b'), new SpyTask('c')];
|
||||
$scheduler = $this->scheduler($tasks);
|
||||
$scheduler->sync();
|
||||
foreach (['a', 'b', 'c'] as $name) {
|
||||
$this->makeDue($name);
|
||||
}
|
||||
|
||||
self::assertCount(2, $scheduler->runDue('besuch', 2));
|
||||
self::assertSame(2, array_sum(array_map(static fn (SpyTask $t): int => $t->runs, $tasks)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Der schnelle Weg: eine Datei sagt, wann sich das Fragen der Datenbank ueberhaupt lohnt.
|
||||
*
|
||||
* Ohne diese Eigenschaft kostet der Aufgabenplan jeden Seitenaufruf eine Abfrage - genau das,
|
||||
* was auf geteiltem Hosting am meisten weh tut.
|
||||
*/
|
||||
public function testTheStateFileHoldsTheNextMomentAnythingIsDue(): void
|
||||
{
|
||||
$scheduler = $this->scheduler([new SpyTask('a', 600)]);
|
||||
$scheduler->sync();
|
||||
|
||||
$state = $scheduler->fastState();
|
||||
self::assertTrue($state['traffic']);
|
||||
self::assertNotNull($state['next']);
|
||||
self::assertGreaterThan(time(), $state['next']);
|
||||
self::assertFileExists($this->projectDir.'/'.Scheduler::STATE_FILE);
|
||||
|
||||
// Die Schalter ueberleben das Nachtragen des naechsten Zeitpunkts.
|
||||
$scheduler->setWorkers(false, true);
|
||||
$this->makeDue('a');
|
||||
$scheduler->runDue('besuch');
|
||||
self::assertFalse($scheduler->fastState()['traffic']);
|
||||
self::assertTrue($scheduler->fastState()['admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waehrend eine Aufgabe laeuft, schickt die Zustandsdatei die uebrigen Anfragen weg.
|
||||
*
|
||||
* Sonst waere jeder Seitenaufruf waehrend einer langen Sicherung wieder bei der Datenbank:
|
||||
* faellig ist sie ja, sie ist nur schon vergeben.
|
||||
*/
|
||||
public function testARunningTaskKeepsTheOtherRequestsOutOfTheDatabase(): void
|
||||
{
|
||||
$scheduler = $this->scheduler([new SpyTask('a')]);
|
||||
$scheduler->sync();
|
||||
$lockEnds = gmdate('Y-m-d H:i:s', time() + 600);
|
||||
$this->db->executeStatement(
|
||||
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
|
||||
['2020-01-01 00:00:00', $lockEnds, 'a'],
|
||||
);
|
||||
|
||||
$scheduler->runDue('besuch');
|
||||
self::assertSame(strtotime($lockEnds.' UTC'), $scheduler->fastState()['next']);
|
||||
}
|
||||
|
||||
/** Zwischen hochgeladenem Code und ausgefuehrter Migration darf nichts explodieren. */
|
||||
public function testAMissingTableIsNotAnError(): void
|
||||
{
|
||||
$this->db->executeStatement('DROP TABLE scheduled_task');
|
||||
$task = new SpyTask('a');
|
||||
|
||||
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
|
||||
self::assertSame([], $this->scheduler([$task])->overview());
|
||||
self::assertSame(0, $task->runs);
|
||||
}
|
||||
|
||||
public function testTheProtocolKeepsWhatHappened(): void
|
||||
{
|
||||
$scheduler = $this->scheduler([new SpyTask('a')]);
|
||||
$scheduler->sync();
|
||||
$scheduler->runNow('a');
|
||||
|
||||
$lines = $scheduler->protocol();
|
||||
self::assertCount(1, $lines);
|
||||
self::assertStringContainsString('a', $lines[0]);
|
||||
self::assertStringContainsString('ok', $lines[0]);
|
||||
self::assertStringContainsString('hand', $lines[0]);
|
||||
}
|
||||
|
||||
public function testTheCronKeyDependsOnTheApplicationSecret(): void
|
||||
{
|
||||
$one = Scheduler::cronToken('geheimnis-eins');
|
||||
$two = Scheduler::cronToken('geheimnis-zwei');
|
||||
self::assertSame(40, strlen($one));
|
||||
self::assertNotSame($one, $two);
|
||||
self::assertSame($one, Scheduler::cronToken('geheimnis-eins'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Ops\Tasks\Task;
|
||||
|
||||
/**
|
||||
* Eine Aufgabe, die nichts tut außer mitzählen - oder auf Wunsch zu scheitern.
|
||||
*
|
||||
* Der Plan lässt sich nur dann ehrlich prüfen, wenn die Aufgabe selbst nichts kann: was hier
|
||||
* gemessen wird, ist die Mechanik ringsum - Fälligkeit, Sperre, Abstand nach einem Fehlschlag.
|
||||
*/
|
||||
final class SpyTask implements Task
|
||||
{
|
||||
public int $runs = 0;
|
||||
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private int $interval = 300,
|
||||
private ?\Throwable $error = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Testaufgabe '.$this->name;
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Nur für Tests.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return $this->interval;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
++$this->runs;
|
||||
if (null !== $this->error) {
|
||||
throw $this->error;
|
||||
}
|
||||
|
||||
return 'Lauf '.$this->runs;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Ops\Scheduler;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* Die drei Auslöser, wie sie über HTTP erreichbar sind.
|
||||
*
|
||||
* Geprüft wird nicht das Aussehen, sondern wer was darf: Der Bildschirm gehört Administratoren,
|
||||
* der Takt der Verwaltung braucht eine Anmeldung und ein gültiges Formular, und der URL-Cronjob
|
||||
* hängt an einem Schlüssel, der sich nicht erraten lässt. Jede dieser Stellen startet Arbeit auf
|
||||
* dem Server – eine offene wäre eine Einladung.
|
||||
*/
|
||||
final class TaskScreenTest extends WebTestCase
|
||||
{
|
||||
private const FIXTURE_PASSWORD = 'Fixture-only-password';
|
||||
private const SECRET = 'isolated-test-secret';
|
||||
|
||||
private Connection $db;
|
||||
private string $path;
|
||||
private array $previous;
|
||||
|
||||
protected static function getKernelClass(): string
|
||||
{
|
||||
return Kernel::class;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-tasks-web-');
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
|
||||
$this->previous = [$_SERVER, $_ENV];
|
||||
foreach (['APP_SECRET' => self::SECRET, 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
|
||||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||
}
|
||||
$this->db = ConnectionFactory::create($url);
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
|
||||
$this->db->insert('cms_user', [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'username' => $username,
|
||||
'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]),
|
||||
'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
'password_changed_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
[$_SERVER, $_ENV] = $this->previous;
|
||||
}
|
||||
|
||||
public function testTheScreenNamesEveryTaskAndTheWayWithoutVisitors(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/aufgaben');
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
|
||||
foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup'] as $task) {
|
||||
self::assertStringContainsString($task, $html);
|
||||
}
|
||||
// Die Einschränkung gehört auf den Bildschirm und nicht in die Dokumentation: wer sie
|
||||
// nicht kennt, sucht den Fehler später an der falschen Stelle.
|
||||
self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html);
|
||||
self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html);
|
||||
// Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr.
|
||||
self::assertSame(5, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
|
||||
}
|
||||
|
||||
public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$anonymous = self::createClient();
|
||||
$anonymous->followRedirects(false);
|
||||
$anonymous->request('POST', '/admin/aufgaben/ausfuehren');
|
||||
self::assertSame('http://localhost/admin/login', $anonymous->getResponse()->headers->get('Location'));
|
||||
|
||||
$editor = $this->login('editor');
|
||||
$editor->request('GET', '/admin/aufgaben');
|
||||
self::assertSame(403, $editor->getResponse()->getStatusCode(), 'Eine Redakteurin verwaltet den Plan nicht.');
|
||||
|
||||
$admin = $this->login();
|
||||
$admin->request('POST', '/admin/aufgaben/ausfuehren', ['_csrf_token' => 'erfunden', 'name' => 'publish-due']);
|
||||
$admin->followRedirect();
|
||||
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
|
||||
self::assertNull($this->db->fetchOne('SELECT last_status FROM scheduled_task WHERE name = ?', ['publish-due']));
|
||||
}
|
||||
|
||||
public function testAnAdministratorCanRunATaskFromTheScreen(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$crawler = $client->request('GET', '/admin/aufgaben');
|
||||
$client->submit($crawler->filter('#run-publish-due')->form());
|
||||
$client->followRedirect();
|
||||
|
||||
self::assertStringContainsString('Geplante Veröffentlichungen', (string) $client->getResponse()->getContent());
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['publish-due']);
|
||||
self::assertSame('ok', $row['last_status']);
|
||||
self::assertSame('hand', $row['last_trigger']);
|
||||
self::assertNull($row['locked_until'], 'Nach dem Lauf ist die Sperre weg - sonst blockiert ein Klick die Aufgabe für eine Viertelstunde.');
|
||||
}
|
||||
|
||||
/** Der zweite Auslöser: das geöffnete Backend. Er gehört jedem angemeldeten Konto, nicht nur der Verwaltung. */
|
||||
public function testTheOpenBackendDrivesThePlan(): void
|
||||
{
|
||||
$editor = $this->login('editor');
|
||||
$editor->request('GET', '/admin');
|
||||
$token = $this->tokenFrom((string) $editor->getResponse()->getContent());
|
||||
self::assertNotSame('', $token, 'Jeder Bildschirm der Verwaltung bringt den Takt mit.');
|
||||
|
||||
$editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => $token]);
|
||||
self::assertSame(200, $editor->getResponse()->getStatusCode());
|
||||
$answer = json_decode((string) $editor->getResponse()->getContent(), true);
|
||||
self::assertIsArray($answer);
|
||||
self::assertArrayHasKey('ran', $answer);
|
||||
|
||||
// Ohne gültiges Formular passiert nichts, auch für ein angemeldetes Konto nicht.
|
||||
$editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => 'erfunden']);
|
||||
self::assertSame(403, $editor->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/** Der dritte Auslöser: ein Aufruf von außen, der nur mit dem richtigen Schlüssel etwas tut. */
|
||||
public function testTheUrlCronAnswersOnlyToItsKey(): void
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$client = self::createClient();
|
||||
|
||||
$client->request('GET', '/cms-cron/'.str_repeat('a', 40));
|
||||
self::assertSame(404, $client->getResponse()->getStatusCode(), 'Ein falscher Schlüssel erfährt nicht einmal, dass es die Stelle gibt.');
|
||||
|
||||
$client->request('GET', '/cms-cron/'.Scheduler::cronToken(self::SECRET));
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
self::assertStringContainsString('text/plain', (string) $client->getResponse()->headers->get('Content-Type'));
|
||||
self::assertStringContainsString('noindex', (string) $client->getResponse()->headers->get('X-Robots-Tag'));
|
||||
self::assertStringContainsString('Aufgabe(n)', (string) $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
private function tokenFrom(string $html): string
|
||||
{
|
||||
return 1 === preg_match('#<meta name="kant-tick-token" content="([^"]+)"#', $html, $match) ? $match[1] : '';
|
||||
}
|
||||
|
||||
private function login(string $username = 'admin'): KernelBrowser
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$client = self::createClient();
|
||||
$client->followRedirects(false);
|
||||
$crawler = $client->request('GET', '/admin/login');
|
||||
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD]));
|
||||
$client->followRedirect();
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use DoctrineMigrations\Version20260912001100;
|
||||
use DoctrineMigrations\Version20260912001200;
|
||||
use DoctrineMigrations\Version20260912001300;
|
||||
use DoctrineMigrations\Version20260913000100;
|
||||
use DoctrineMigrations\Version20260914000100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,7 @@ final class TestSchema
|
||||
Version20260912001200::class,
|
||||
Version20260912001300::class,
|
||||
Version20260913000100::class,
|
||||
Version20260914000100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
+32
-2
@@ -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);
|
||||
|
||||
@@ -4,6 +4,30 @@ import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
import { relevantProperty } from '../public/assets/editor-model.js';
|
||||
|
||||
test('sections start after the containing root, explicit placement can nest, undo restores the tree', () => {
|
||||
const original = fixture();
|
||||
const history = new EditorHistory(original);
|
||||
const id = history.change(doc => insertBlock(doc, 'section', 't1'));
|
||||
assert.equal(history.document.blocks[1].id, id);
|
||||
assert.equal(locate(history.document.blocks, id).parent, null);
|
||||
history.undo(); assert.deepEqual(history.document, original);
|
||||
history.redo(); assert.equal(history.document.blocks[1].id, id);
|
||||
const nested = history.change(doc => insertBlockAt(doc, 'section', 's1', 0));
|
||||
assert.equal(locate(history.document.blocks, nested).parent.id, 's1');
|
||||
});
|
||||
|
||||
test('design fields follow block type without hiding existing stored values', () => {
|
||||
const text = createBlock('text'), image = createBlock('image');
|
||||
assert.equal(relevantProperty(text, 'objectFit', { group: 'image' }), false);
|
||||
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), false);
|
||||
assert.equal(relevantProperty(image, 'objectFit', { group: 'image' }), true);
|
||||
assert.equal(relevantProperty(text, 'fontSize', { group: 'text' }), true);
|
||||
assert.equal(relevantProperty(text, 'gridColumns', { group: 'layout' }), false);
|
||||
image.style = { mobile: { fontSize: '14px' } };
|
||||
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), true);
|
||||
});
|
||||
|
||||
function fixture() { return { schemaVersion: 1, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }, { id: 't1', type: 'text', props: { text: 'Body' } }] }, { id: 's2', type: 'section', children: [] }] }; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user