Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f475d1374 | ||
|
|
ab86312e87 | ||
|
|
16da844934 | ||
|
|
a3c89bb69d | ||
|
|
25fd388f80 | ||
|
|
e18c881db8 | ||
|
|
6d21c452bd | ||
|
|
b8991ddd5c | ||
|
|
0920e3cb83 | ||
|
|
d78a58f714 | ||
|
|
41d87f62a4 | ||
|
|
11b60fc5d8 | ||
|
|
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,12 +18,20 @@ 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.
|
||||
|
||||
## Eigene 404-Seite (ab 1.8.0)
|
||||
|
||||
Unter **Einstellungen → 404-Seite** eine vorhandene Seite per Livesuche auswählen und **404-Zuordnung speichern**. Oder **404-Vorlage anlegen** anklicken, den angelegten Entwurf im Editor gestalten und veröffentlichen. Die Ausgabe übernimmt den Standard-Header, Standard-Footer, Schriften und Farben der Website. **404-Vorschau** öffnet die tatsächlich sichtbare Fehlerseite.
|
||||
|
||||
Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website-Design. Entwürfe bleiben privat; Umbenennen erhält die Zuordnung, nach dem Löschen greift wieder der Standard. Das Entfernen der Auswahl setzt die Zuordnung zurück und erhält den Seiteninhalt. Die Zuordnung benötigt Veröffentlichungsrechte. Unbekannte URLs bleiben unverändert und antworten mit HTTP 404; die Fehlerseite wird nicht in Sitemap/llms.txt aufgenommen. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:404`, Berichte und Screenshots unter `reports/not-found/`. Datenvertrag und Rückweg: ADR 082.
|
||||
|
||||
## Anmelden und bearbeiten
|
||||
|
||||
**Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position.
|
||||
|
||||
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
|
||||
|
||||
```powershell
|
||||
@@ -34,6 +42,10 @@ Das Passwort wird verdeckt zweimal abgefragt (14–72 Bytes). Bestehende Benutze
|
||||
|
||||
Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzufügen/auswählen, in der Mitte die Vorschau, rechts Eigenschaften. Die Auswahl ist zwischen Strukturbaum und Vorschau synchron. Sektion, Zeile, Spalte, Überschrift, Text, Bild, Button und Galerie sind vorhanden. Bilder können aus der Mediathek oder über vorhandene lokale/HTTPS-Adressen eingebunden werden.
|
||||
|
||||
**Bild prozentual verkleinern (ab 1.7.2):** Bild auswählen → Inhalt → **Bildbreite (%)**. Regler oder Zahlenfeld setzen 1–100 % der verfügbaren Breite in Sektion/Spalte. Desktop, Tablet und Mobil können eigene Werte erhalten; kleinere Geräte übernehmen zunächst den Wert vom größeren Gerät. „Breite zurücksetzen“ entfernt den eigenen Wert und stellt die Vererbung beziehungsweise automatische Breite wieder her. Originalbreite/-höhe beschreiben weiterhin die Datei. Der Regler bearbeitet denselben Wert wie Design → Größe → Breite; eine dort gesetzte maximale Breite bleibt wirksam. Prüfung mit kopierten Kant-IT-Daten: `npm run test:image-size`, Bericht und Screenshots unter `reports/image-size/`.
|
||||
|
||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
|
||||
@@ -42,7 +54,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 +83,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 +112,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 +126,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 +155,28 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||
- Apache mit Rewrite-Unterstützung für `public/.htaccess` oder entsprechende FastCGI/Nginx-Konfiguration.
|
||||
- Schreibzugriff für PHP auf `var/`, HTTPS und eine Möglichkeit, Migrationen/Cache-Kommandos kontrolliert auszuführen.
|
||||
|
||||
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
|
||||
Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsvariablen dürfen sie gezielt überschreiben: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
|
||||
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `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.
|
||||
|
||||
Ab **1.7.1** aktualisieren sich Seiten, Beiträge und Mediathek beim Tippen per AJAX, ohne Enter oder Neuladen. Status, Anzahl und Blättern reagieren ebenfalls direkt. Der Cursor bleibt im Suchfeld; Zurück/Vor im Browser stellt die Filter wieder her. Die Mediathek sucht in Titel und Alternativtext über alle Ergebnisseiten. Menüs, Seitenauswahl und Editor-Suchfelder reagieren ebenfalls beim Tippen. Bei Ladefehlern bleiben die bisherigen Treffer sichtbar und „Erneut suchen“ wiederholt die Anfrage. Ohne JavaScript bleibt das Suchformular mit „Anwenden“ benutzbar.
|
||||
|
||||
Unter **Menüs** zuerst den Menüplatz wählen, links Inhalte suchen und zum Menü hinzufügen. Rechts lassen sich Beschriftung, Ziel, Reihenfolge und Unterpunkte bearbeiten. Pfeilbuttons sind per Tastatur erreichbar; Rückgängig stellt entfernte oder umgeordnete Einträge wieder her. Erst **Menü speichern** ändert die Website. Validierungs-, Verbindungs- und Versionsfehler erhalten die lokalen Eingaben. Ein paralleler Speicherstand muss bewusst neu geladen werden.
|
||||
|
||||
**Bereiche** verwenden denselben Editor; Speichern wirkt sofort auf allen verknüpften Seiten. Unter **Formulare** ist die Meldung nach dem Absenden ein mehrzeiliges Feld über die verfügbare Formularbreite.
|
||||
|
||||
### Konfigurationswechsel von älteren Fassungen
|
||||
|
||||
Beim ersten Start von 1.7.0 wird eine vorhandene `.env.local` einmalig mit `.env` zusammengeführt. Die bisher wirksamen Werte aus `.env.local` bleiben dabei erhalten, insbesondere Datenbank, Medienordner und Secret. Beide Originale liegen anschließend unter `var/backups/environment-<Zeitpunkt>-<Kennung>/`; erst nach erfolgreichem Schreiben wird `.env.local` entfernt. Danach gelten Änderungen direkt in `.env`. System zeigt die tatsächlich genutzte Datenbank und die Quelle `.env` oder Server-Umgebung an.
|
||||
|
||||
Weitere Symfony-Overlays (`.env.prod`, `.env.prod.local`, `.env.local.php`) werden nicht mehr automatisch geladen. Falls ein Hosting sie bewusst verwendet hat, deren gewünschte Werte vor dem Wechsel in `.env` oder echte Servervariablen übernehmen. Der Installer, Setup und der Admin-Pfad-Befehl erzeugen keine `.env.local` mehr. Rückweg: alten Code und die beiden gesicherten Konfigurationsdateien gemeinsam wiederherstellen.
|
||||
|
||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||
|
||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||
+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/}'
|
||||
@@ -34,6 +40,9 @@ services:
|
||||
App\EventSubscriber\AdminHeadersSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\EventSubscriber\PublicNotFoundSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\Statistics:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
@@ -57,6 +66,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 +98,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,62 @@
|
||||
# Arbeitsplan: Installation, Verwaltung und Bewegung
|
||||
|
||||
Auftrag vom 14.09.2026. Ausgangspunkt: Release 1.6.2 plus bereits vorhandene lokale Änderungen an der Systemdiagnose. Diese bleiben erhalten und werden an den neuen Konfigurationsvertrag angepasst. Vorhandener Patch liegt lokal unter `var/work-before-usability/`.
|
||||
|
||||
## 1. Fehler reproduzieren und Daten schützen
|
||||
|
||||
- [x] Bereichsfehler auf den Seitenzugriff in der gemeinsamen Vorlage zurückführen und Öffnen/Vorschau/Speichern im Browser prüfen.
|
||||
- [x] Kant-IT-Datenbank für die Prüfung isoliert kopieren; echte Inhalte und Zugangsdaten nicht verändern.
|
||||
- [x] Slider und Einblendungen auf der Kant-IT-Startseite mit Positionen und Zeitverlauf prüfen.
|
||||
|
||||
## 2. Eine eindeutige Installationskonfiguration
|
||||
|
||||
- [x] `.env` ist die Konfigurationsdatei der Installation. Installer, Setup und Admin-Pfad-Befehl schreiben dort; keine neue `.env.local`.
|
||||
- [x] Bestehende wirksame Einstellungen aus älteren Installationen einmalig und mit Sicherung in `.env` zusammenführen. Datenbankverbindung und Secret erhalten; danach keine versteckte Überschreibung.
|
||||
- [x] Updates erhalten `.env`; echte Server-Umgebungsvariablen bleiben für Hosting/Testbetrieb möglich. Anzeige im Systembereich muss die tatsächliche Quelle nennen, ohne Passwörter auszugeben.
|
||||
- [x] Neuinstallation, bestehende Installation, wiederholtes Booten, Updateerhalt und Fehler beim Schreiben prüfen.
|
||||
|
||||
## 3. Bereiche zuverlässig bearbeiten
|
||||
|
||||
- [x] Gemeinsamen Editor ohne Zugriff auf nicht vorhandene Seitenvariablen rendern; benannte Routen auch bei geändertem Admin-Pfad verwenden.
|
||||
- [x] Kopf, Fuß und verknüpfte Sektion öffnen, Inhalt ändern, Vorschau prüfen, speichern und neu laden; Versionskonflikte erhalten.
|
||||
- [x] Sofort wirksames Speichern bei Bereichen in der Oberfläche korrekt benennen.
|
||||
|
||||
## 4. Seiten und Beiträge übersichtlich verwalten
|
||||
|
||||
- [x] Eigene Navigation und Ansichten für Seiten und Beiträge; passende Anlageformulare.
|
||||
- [x] Datenbankseitige Pagination, Suche, Statusfilter und Ergebniszahl; keine vollständigen Dokumentlisten für jede Bildschirmseite laden.
|
||||
- [x] Filter und Seitenzahl beim Blättern erhalten; leere Suche und ungültige Seitenzahlen behandeln. Papierkorb ebenfalls begrenzen.
|
||||
- [x] Übergeordnete Seiten bleiben unabhängig von der aktuellen Ergebnisseite wählbar.
|
||||
|
||||
## 5. Menüeditor mit verständlichem Aufbau
|
||||
|
||||
- [x] Seiten/Beiträge nach Titel oder Adresse suchen und mit eindeutiger Vorschau auswählen.
|
||||
- [x] Inhalt auswählen → zum Menü hinzufügen → Reihenfolge/Unterpunkte ändern → speichern. Eigene Links getrennt anbieten.
|
||||
- [x] Bestehende Ziele und Hierarchien erhalten, Umordnen auch mit Tastatur/Buttons; Speicherfehler und ungespeicherte Änderungen sichtbar behandeln.
|
||||
|
||||
## 6. Formulare, Animationen und Slider
|
||||
|
||||
- [x] Meldung nach dem Absenden als ausreichend breites mehrzeiliges Feld anbieten.
|
||||
- [x] Einblendungen sichtbar beim Eintreten in den Bildschirm starten; Effektauswahl tatsächlich unterscheiden. Inhalte ohne JavaScript lesbar lassen und Bewegungsreduktion respektieren.
|
||||
- [x] Slider mit sichtbarer Bedienung, funktionierendem Wechsel und nachvollziehbarer Pause/Automatik. Auf der realen Kant-IT-Seite über mehrere Wechsel prüfen.
|
||||
- [x] Bewegung in der Editorvorschau gezielt prüfbar machen, ohne die Sandbox für Inhaltsskripte zu öffnen.
|
||||
|
||||
## 7. Abnahme und Übergabe
|
||||
|
||||
- [x] Relevante PHP-/JavaScript-Tests und vollständige Browserabläufe, einschließlich Tastatur, Speicherung, Fehlerzuständen und unterschiedlichen Bildschirmbreiten.
|
||||
- [x] Änderungen der öffentlichen Ausgabe auf Kant-IT-Inhalten visuell und mit den passenden Qualitätsprüfungen kontrollieren; keine ungeprüften Scores behaupten.
|
||||
- [x] Tatsächliche Ergebnisse, Grenzen und Migration dokumentieren; Versionsnummer vor Veröffentlichung mit dem Repository abgleichen.
|
||||
|
||||
Jeder Abschnitt wird erst als erledigt markiert, wenn seine Prüfung bestanden ist. Der Plan ersetzt keine Umsetzung; Befunde und Änderungen werden darin nachgeführt.
|
||||
|
||||
## Ergebnis der Abnahme
|
||||
|
||||
Umgesetzt als 1.7.0, ohne neues Datenbank- oder Dokumentformat. Begründungen und Konfigurationsmigration: ADR 076–078. Die ursprünglichen lokalen Änderungen an Systemdiagnose/STATUS wurden integriert, die veraltete lokale Versionsnummer 1.4.7 mit der bestehenden Releasefolge abgeglichen.
|
||||
|
||||
Geprüft: PHP-Vollsuite 461/461, JavaScript 30/30, alle 43 Twig-Dateien und Produktionscontainer. Komplette Editorprüfung einschließlich unterschiedlicher Einblendungen und Bewegungsreduktion. Kant-IT-Browserabläufe mit 4 Seiten/29 Beiträgen, Menüfehlern und Versionskonflikt, mehrzeiliger Formularmeldung, Kopf/Fuß und neu angelegter Sektion samt echtem Textinhalt. Desktop, 1024 und 390 px; axe ohne Verstöße. Öffentliche Inhalte und Slider bleiben ohne JavaScript erreichbar.
|
||||
|
||||
Kant-IT-Startseite, Produktionsmodus, Lighthouse 13.4.1: jeweils drei Läufe mobil/desktop, Leistungsmedian jeweils 99; Barrierefreiheit, Best Practices, SEO und Agentic Browsing jeweils 100. Lokale Labormessung, keine Felddaten/CWV-Abnahme für Kundenseiten. Messungen: `reports/admin-quality/lighthouse/`; Browserbericht und Bilder im übergeordneten Ordner. Der zusätzliche Updater-Test verändert die Datenbank vor einem Migrationsfehler, prüft echte Wiederherstellung und einen erhaltenen Wartungszustand, falls die Sicherung fehlt.
|
||||
|
||||
Veröffentlichungsnachweis und bekannte Grenzen stehen im aktuellen Abschnitt von STATUS.md.
|
||||
|
||||
**Veröffentlicht:** `495b268`, Tag `v1.7.0`, Release mit ZIP/Prüfsumme/Installer. Quellabgleich und anonyme Downloads geprüft; frischer Release-Installer antwortet mit HTTP 200 und erzeugt ausschließlich `.env`.
|
||||
+128
-3
@@ -1,6 +1,100 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 083 – Footer-Links werden redaktionell platziert (14.09.2026)
|
||||
|
||||
Auf ausdrücklichen Wunsch von Umut entfällt die automatisch angehängte Rechtszeile vollständig. Seiten, Beiträge, Archive, Fehlerseiten und Editorvorschauen geben ausschließlich die selbst platzierten Footer-/Inhaltslinks aus. Die frühere Erkennung des speziellen `legal`-Blocks konnte normale Text- oder Menülinks nicht berücksichtigen; dadurch erschien unter einem vollständig gestalteten Footer ein zusätzlicher heller Streifen mit doppelten Links. Die Template-Ausgabe, ihre Erkennung im Controller und ungenutztes CSS sind entfernt.
|
||||
|
||||
Normale Links, Menüs und bewusst eingefügte „Rechtliches“-Blöcke bleiben erhalten. Der optionale Block nutzt weiterhin die konfigurierten Seitenadressen und den Link zur Einwilligung. Einwilligungsdialog, Einstellungen und der Endpunkt `/einwilligung` behalten ihre Funktion. Keine Inhalte werden umgeschrieben, keine neue Schema- oder Datenbankmigration. Bei einem Rückweg auf ältere Versionen erscheint deren automatische Leiste wieder.
|
||||
|
||||
Diese Entscheidung ersetzt die automatische Verlinkung aus ADR 033 und die bedingte Zusatzleiste aus ADR 062; Position und Gestaltung der Links liegen beim Autor.
|
||||
|
||||
## ADR 082 – Zuweisbare 404-Seite im gemeinsamen Seitenrenderer (14.09.2026)
|
||||
|
||||
`website.settings.notFoundPageId` verweist auf eine normale Seite über deren stabile ID. Eine eigene Einstellung mit Livesuche weist sie zu oder entfernt die Zuordnung; sie benötigt Veröffentlichungsrechte und CSRF-Schutz. Der erwartete bisherige Wert schützt gegen ein veraltetes Formular. Die Änderung liest die aktuellen Einstellungen innerhalb einer Transaktion, unter MySQL/MariaDB mit Zeilensperre, und schreibt nur die geänderte Zuordnung in den erhaltenen JSON-Inhalt. Das normale Unternehmensformular erhält diesen Wert unabhängig von seinen eigenen Feldern. Startseite und 404-Seite dürfen nicht dieselbe Seite sein.
|
||||
|
||||
Ohne verwendbare veröffentlichte Zuordnung wird ein v10-Blockdokument im Speicher erzeugt. Öffentliche GETs legen keine Seiten an. „404-Vorlage anlegen“ erstellt eine bearbeitbare Kopie als Entwurf und weist sie zu; bis zur Veröffentlichung bleibt die Standardausgabe sichtbar. Vorhandene Adressen einschließlich Papierkorb werden erhalten; bei belegtem `/404` wird `/404-2` usw. gewählt. Wiederholtes Anlegen überschreibt keine Seite. Die erzeugte Vorlage erhält `noindex`. Erstellen und Zuweisen laufen in einer gemeinsamen Datenbanktransaktion.
|
||||
|
||||
Normale Seiten, zugewiesene Fehlerseiten und der Standard verwenden denselben Renderer mit Theme-CSS, Schriften, Standard-Header/-Footer, Menüs, verknüpften Sektionen und Einwilligungsanzeige. Auf der Fehlerausgabe haben die Standardbereiche Vorrang vor eventuellen Bereichsabweichungen der ausgewählten Seite. Nur veröffentlichte Revisionen sind öffentlich sichtbar; Umbenennen erhält die Zuordnung, Löschen/nicht veröffentlichte Seiten führen auf den Standard zurück. Die Originaladresse bleibt stehen, die Antwort trägt HTTP 404 und `noindex, follow` in HTML und Header, ohne Canonical oder strukturierte Daten einer normalen Seite. Auch die eigene Adresse der zugewiesenen Seite antwortet mit 404. Sitemap und llms.txt schließen die zugewiesene ID zusätzlich aus, unabhängig von deren normalen Metadaten.
|
||||
|
||||
Ein schmaler Exception-Subscriber übernimmt öffentliche HTML-GET/HEAD-Routingfehler außerhalb der Slug-Grammatik, etwa alte `.html`-Adressen oder tiefere Pfade. Fehler bestehender Controller, Verwaltung, Medien/Assets, Formulare, Installation, AJAX sowie maschinenlesbare Ressourcen bleiben ihren bisherigen Endpunkten überlassen. Normale Seiten- und Archivfehler rufen den Fehlerrenderer direkt auf. 301/410 werden vorher behandelt; 410 nutzt einen eigenen Standardtext und behält seinen Status.
|
||||
|
||||
**Migration und Rückweg:** Keine Datenbankmigration und kein neues Dokumentschema. Fehlender/null Settings-Inhalt erhält beim Lesen den leeren Standardwert. Die Einstellung wird erst beim Zuweisen gespeichert. Die Vorlage nutzt bestehende v10-Blöcke. Älterer Code ignoriert die neue Zuordnung und verwendet wieder seine bisherige 404-Ausgabe; die angelegte Seite und ihre Revisionen bleiben normaler Inhalt. Vor einem Rückweg die Website-Einstellungen sichern, da ältere Einstellungsformulare unbekannte JSON-Schlüssel entfernen können. Kein automatisches Veröffentlichen oder Überschreiben vorhandener Seiten.
|
||||
|
||||
## ADR 081 – Linkziel und sichtbare Medienauswahl (14.09.2026)
|
||||
|
||||
Schema v20 ergänzt optionales `props.newTab: boolean` an Button, Bild, Symbolkarte, Aufruf-Band und Symbol. Fehlend/false öffnet wie bisher im aktuellen Tab; true erzeugt im gemeinsamen Renderer `target="_blank" rel="noopener"`. Derselbe Wert ist als optionales Feld in Menüeinträgen verfügbar; die bestehende Menüversion schützt weiterhin vor veralteten Schreibzugriffen. Der Rich-Text-Filter erlaubt ausschließlich `_blank` als Ziel und setzt `noopener` selbst. Andere Targets und fremde Attribute werden entfernt. Textlinks bleiben Teil des bisherigen HTML-Vertrags. Bei Bildern entscheidet die gewählte Klickaktion; eine hinterlegte Adresse bleibt bei „Nichts tun“ gespeichert, erzeugt aber keinen Link mehr.
|
||||
|
||||
Bilder, Galerie-/Logobilder und Hintergründe zeigen kleine Vorschaubilder mit Lade-/Fehlerzustand. Für Hintergrund-IDs liefert eine authentifizierte einzelne Medienabfrage die tatsächlich vorhandene Bildvariante; auch kleine Originale und archivierte, weiterhin verwendete Dateien funktionieren. Die Bildauswahl verwendet denselben Vorschaucache. Späte Antworten ersetzen keine neuere Auswahl. Entfernen eines Inhaltsbildes löscht dessen Block aus dem Entwurf, ein Galerieeintrag bleibt eine einzelne Änderung; beide sind rückgängig zu machen. Die Mediendatei bleibt erhalten. Bildaustausch ersetzt Quelle und Bildmetadaten, erhält Link, Bildunterschrift, Block-ID und Gestaltung.
|
||||
|
||||
Ein Hintergrund wird mit `style.<device>.backgroundImage: "none"` ausdrücklich ausgeblendet. Das ist von Zurücksetzen zu unterscheiden: Zurücksetzen entfernt den eigenen Wert und stellt Vererbung her. Der Compiler übernimmt Bild, Verlauf und Überlagerung schrittweise Desktop→Tablet→Mobil; dadurch erscheint ein auf Tablet entferntes Bild nicht auf Mobil wieder. Geerbte Verläufe bleiben beim Entfernen des Bildes erhalten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Erzeugung. Die neuen Bedienelemente gehören nur zum Editor; kein zusätzliches öffentliches JavaScript.
|
||||
|
||||
**Migration:** Dokumente v1–19 bleiben lesbar und werden nicht beim Laden umgeschrieben. Das Editor-Modell hebt die Version erst beim Setzen eines neuen Block-Linkwerts oder eines ausdrücklichen Hintergrund-„none“ auf v20 an, einschließlich der bisherigen Schema-Upgrades. Die Validierung verlangt für diese Werte v20. Keine Datenbankmigration. Rückweg auf ältere Software: vor Bearbeitung mit v20 angelegte Revision/Export oder die Sicherung vor dem Update wiederherstellen; ältere Validatoren können v20 nicht speichern. Menüs benötigen ebenfalls die Sicherung, falls neue Tab-Einstellungen auf älterem Code erhalten bleiben sollen. Kein stilles Herabsetzen der Dokumentversion.
|
||||
|
||||
**Korrektur 1.7.4:** Native Inhalts-Auswahlfelder verarbeiten `input` und `change`, identische aufeinanderfolgende Werte werden nur einmal übernommen. Jede Auswahl ist ein eigener Undo-Schritt. Die Bild-Klickaktion ersetzt beim Wechsel nicht mehr den ganzen Feinschliff: Linkziel und Tab-Häkchen werden in einer bestehenden Gruppe ein-/ausgeblendet. Das erhält Dropdown, Fokus und eingegebene Adresse. Ein Test mit ausschließlich `change` konnte das fehlende URL-Feld vor der Korrektur reproduzieren; native Auswahl und Speicherung werden zusätzlich geprüft. Keine Änderung des Datenvertrags.
|
||||
|
||||
## ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026)
|
||||
|
||||
Der Inhaltsbereich eines Bildes erhält oben einen Prozentregler und ein Zahlenfeld für 1–100 % der verfügbaren Containerbreite. Beide schreiben ausschließlich `style.<device>.width` über die bestehende `setStyle`-Transaktion. Die generische Breite unter Design und die direkte Bildsteuerung sind dadurch immer derselbe Wert. Originalmaße, Quelle, Varianten, Alternativtext und Bildunterschrift bleiben unverändert. Vorhandene maximale Breiten werden erhalten und im Hinweis genannt; Werte in anderen Einheiten bleiben sichtbar, bis bewusst eine Prozentbreite gesetzt wird. Keine neue Style-Eigenschaft, keine neue Schema- oder Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen JavaScript.
|
||||
|
||||
Die Steuerung verwendet das aktive Vorschaugerät und zeigt die Herkunft des Werts. Zurücksetzen entfernt nur die Breite dieses Geräts und stellt die bestehende Desktop→Tablet→Mobil-Vererbung wieder her. Eingaben aktualisieren die Vorschau, ohne das Feld zu ersetzen. Eine Ziehbewegung ist ein Undo-Schritt; Zahlenwerte außerhalb des Bereichs werden mit verständlicher Meldung abgelehnt und nicht gespeichert. Tastatur, Undo/Redo, Speichern/Laden und prozentuale Ausgabe ohne JavaScript werden am realen TeamViewer-Bild aus einer isolierten Kopie der Pilotdaten geprüft.
|
||||
|
||||
## ADR 079 – Livesuche mit gemeinsamen HTML-Ergebnissen (14.09.2026)
|
||||
|
||||
Seiten, Beiträge und Mediathek verwenden dieselben authentifizierten GET-Routen für vollständige Ansichten und AJAX. Der Header `X-Requested-With: XMLHttpRequest` fordert JSON mit einem serverseitig gerenderten Ergebnisfragment, Gesamtzahl und normalisierten Filtern an. Twig bleibt die einzige Darstellung für Listen, Berechtigungen, CSRF-Felder, leere Ergebnisse und Pagination. Suchformular sowie Anlegen/Hochladen bleiben außerhalb des austauschbaren Bereichs; vorhandene Formulare funktionieren weiter ohne JavaScript. Keine Schema- oder Inhaltsmigration.
|
||||
|
||||
Das Verwaltungsmodul wartet beim Tippen 220 ms und ersetzt nur die Ergebnisliste. Eine neue Eingabe bricht die alte Anfrage sofort ab; eine zusätzliche Sequenzprüfung verwirft verspätete Antworten. IME-Eingaben werden erst nach der Komposition gesucht. Beginnt während einer ausstehenden Suche eine Bearbeitung in der Liste, wird die Suche angehalten, damit die Antwort keine Eingabe entfernt. Nach dem Austausch werden Seitenauswahlen initialisiert und die Rückkehrfilter aller Formulare aktualisiert. Fehler erhalten die bisherigen Ergebnisse und bieten Wiederholen an. Ergebniszahl und Ladezustand sind für Screenreader zugänglich; Tastaturfokus bleibt beim Tippen erhalten.
|
||||
|
||||
URLs enthalten den Suchzustand. Tippen ersetzt den aktuellen Historieneintrag, Filterwahl und Blättern legen einen neuen an; `popstate` lädt passende Ergebnisse und stellt die Felder wieder her. Ohne JavaScript arbeitet das GET-Formular mit Submit und regulären Links. Das Modul gehört ausschließlich zum Admin-Paket.
|
||||
|
||||
Die Mediathek filtert Titel und Alternativtext vor COUNT und LIMIT in der Datenbank, inklusive Archivfilter und wörtlicher Behandlung von `%`, `_` und `!`. SQLite erhält eine Unicode-fähige LOWER-Funktion über PDO/mb_strtolower, weil die eingebaute Funktion nur ASCII verarbeitet und etwa „über“ nicht zu „Über“ passte. MySQL verwendet weiterhin seine native Funktion. Die Korrektur gilt auch für Inhalts- und Menüsuche; bestehende Daten und Indizes werden nicht verändert.
|
||||
|
||||
## ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026)
|
||||
|
||||
Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. `motion.js` beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert.
|
||||
|
||||
Slider behalten Scroll-Snap als Grundlage. Ein kleines Modul ergänzt sichtbare Pfeile, Zähler, optionale Punkte sowie Pause/Wiedergabe bei Autoplay. Manuelle Bedienung stoppt auch einen bereits geplanten Timer. Automatik wartet bei Hover, außerhalb des Bildschirms oder in unsichtbaren Tabs; Bewegungsreduktion deaktiviert sie. Ohne Skript bleibt der Inhalt scrollbar. Im Editor startet der Benutzer diese Module ausdrücklich aus dem Elternfenster; die iframe-Sandbox bleibt `allow-same-origin` ohne `allow-scripts`. Neue Vorschauen räumen Observer, Timer und Ereignisse der alten Instanz auf.
|
||||
|
||||
## ADR 077 – Begrenzte Inhaltslisten und Menüaufbau mit Suche (14.09.2026)
|
||||
|
||||
Seiten und Beiträge erhalten getrennte Routen und Navigation. Die Datenbank führt Typ, Suche, Status, COUNT und LIMIT/OFFSET aus; nur 20 oder 50 Dokumente werden pro Ansicht dekodiert. Stabile Sortierung verhindert Überschneidungen bei identischen Zeitstempeln. Papierkorb und leere Suchergebnisse folgen demselben Vertrag. Keine Schemaänderung. Unterlagen mit Titeln und Pfaden werden höchstens in Gruppen von 20 als authentifizierte Suchvorschläge geliefert. Eine separate ID-Abfrage hält bereits ausgewählte Eltern unabhängig von der Ergebnisseite sichtbar.
|
||||
|
||||
Der Menüeditor bearbeitet einen Menüplatz je Ansicht. Suche und eigene Links stehen links, Reihenfolge und Felder rechts. Hinzufügen, Haupt-/Unterpunkte, Umordnen und Entfernen sind per Buttons/Tastatur bedienbar und rückgängig zu machen. Haupteinträge ziehen ihre Kinder mit; beim Entfernen bleiben deren Links als Hauptpunkte erhalten. Speichern verwendet weiterhin die validierte Menüstruktur und erwartete Version, liefert bei JavaScript JSON und behält bei 422/409 oder Verbindungsfehlern die Eingaben. Keine neuen Datenformate.
|
||||
|
||||
Der Fehler 500 bei Bereichen entstand durch einen unbedingten Zugriff auf `page.slug` in der gemeinsamen Editorvorlage. Bereiche haben keine Seite. Routen und öffentliche Links richten sich jetzt nach dem bearbeiteten Objekt; Speichern eines Bereichs ist sichtbar als sofort wirksame Änderung bezeichnet. Der API-Pfad wird aus benannten Routen abgeleitet, auch nach Verlegung der Verwaltung.
|
||||
|
||||
## ADR 076 – Eine Installationsdatei und sichtbare Datenbankquelle (14.09.2026)
|
||||
|
||||
`.env` ist die einzige automatisch geladene Konfigurationsdatei. Sie gehört zur Installation und wird nicht mehr von Git versioniert. Installer, Setup und Admin-Pfad-Befehl schreiben atomar und unter einer Dateisperre in diese Datei, ohne bestehende fremde Schlüssel zu verwerfen. Ein Marker unterscheidet eine vom Installer bestätigte Datenbank von der bloßen Vorlage. Echte Prozess-/Servervariablen bleiben bewusst höher priorisiert.
|
||||
|
||||
Beim ersten Boot mit einer alten `.env.local` bleiben deren bisher wirksame Werte erhalten: beide Originale sichern, zusammengeführte `.env` schreiben, erst danach `.env.local` entfernen. Sicherungen liegen außerhalb des Webroots unter `var/backups/environment-*`. Secret und Datenbank werden nicht neu erzeugt oder gewechselt. Ein Schreibfehler bricht die Migration ab, statt still auf Standarddaten zu wechseln. Andere Symfony-Overlays werden nicht mehr implizit geladen; eine manuelle Nutzung benötigt vor dem Wechsel die Übernahme in `.env`/Servervariablen. Rückweg: Code und beide Konfigurationssicherungen gemeinsam wiederherstellen.
|
||||
|
||||
Der Git-Updater sichert Konfigurationsinhalte vor dem Checkout und stellt sie vor jedem neuen PHP-Prozess wieder her, auch beim Rollback. Das behandelt ausdrücklich ältere Repositorys mit getrackter `.env`. Der Archiv-Updater nimmt diese Dateien weiterhin vom Dateiaustausch aus. Zusätzlich wurde ein vorhandener Rückwegfehler behoben: Der Git-Updater speicherte nur einen Beschreibungstext und leitete daraus einen ungültigen Datenbank-Sicherungspfad ab. Der tatsächliche Sicherungsordner wird jetzt getrennt geführt. Ein fehlgeschlagenes Zurückschreiben lässt den Wartungszustand aktiv; der Regressionstest verändert die Datenbank vor dem simulierten Migrationsfehler und prüft ihre Wiederherstellung. Bereits installierter alter Git-Updater-Code kann diesen neuen Schutz beim erstmaligen Wechsel noch nicht ausführen; daher diesen Übergang per Archiv-Update durchführen oder die Konfiguration vor dem manuellen Checkout sichern und anschließend wiederherstellen (UPDATE.md).
|
||||
|
||||
Die bereits begonnene Systemdiagnose bleibt erhalten: tatsächliche Datenbankart, SQLite-Pfad/Größe/Schreibrecht beziehungsweise maskierte Serveradresse sowie die tatsächliche Quelle. Die Quelle stammt aus den vom Dotenv-Lader gesetzten Variablen; sie wird nicht aus der bloßen Existenz einer Datei geraten. Passwort und Secret werden nicht angezeigt.
|
||||
|
||||
## ADR 075 – Leere Container sind Arbeitsflächen (14.09.2026)
|
||||
|
||||
Leere Spalten hatten keine Höhe; leere Sektionen waren zwar durch Abstände vorhanden, aber weder als Container erkennbar noch mit einem direkten Einfügeweg versehen. Der lokale Flatsome-3.20.9-Quelltext verwendet für solche Zustände eine eigene sichtbare Inhaltsfläche mit Mindesthöhe und mittigem Einfügewerkzeug (konkrete Fundstellen in `docs/FLATSOME-ABGLEICH.md`).
|
||||
|
||||
`preview-containers.js` ergänzt deshalb erst im vollständig geparsten Vorschau-DOM sichtbare Hilfsflächen für leere Sektionen, Zeilen und Spalten. Eine leere Sektion bietet außerdem Zeilenlayouts mit 1–4 Spalten. Die Hilfen sind echte Buttons mit Tastaturbedienung und eindeutigen Namen, etwa „Spalte 2 von 2: Inhalt hinzufügen“. Sie setzen einen genauen Einfügepunkt oder führen dieselbe Modelltransaktion wie der Werkzeugkasten aus. Abbrechen oder eine andere Auswahl hebt den Einfügepunkt auf.
|
||||
|
||||
Die Editor-CSS gibt den Flächen mindestens 130 Layoutpixel Höhe. Der öffentliche Renderer und seine CSS bleiben unverändert; die Mindesthöhe ist eine Bearbeitungshilfe und wird nicht als Designwert gespeichert. Nach dem ersten Inhalt verschwindet die Hilfe automatisch, Undo stellt sie wieder her. Kein Schemawechsel und keine Datenmigration; bestehende leere Container erhalten die Hilfe allein beim Öffnen. `preview.css` und das neue Modul erhalten die Versionskennung 1.6.2, damit ein bestehender Browsercache die Funktion nicht verdeckt.
|
||||
|
||||
Neu eingefügte Elemente werden nach dem Rendern sichtbar gescrollt. Die Vorschau gilt erst mit geparstem DOM und geladenen Hilfsstilen als bereit. Die Drop-Geometrie wird beim Scrollen und beim Loslassen neu ermittelt, Zielcontainer werden benannt, und nebeneinanderstehende Spalten werden horizontal eingeordnet; mobil gestapelte Spalten vertikal. Ein Ziehen nahe dem Vorschaurand scrollt den Frame, bis der Zeiger den Rand verlässt oder das Ziehen endet. Inhalt und Undo bleiben von bloßem Scrollen unberührt.
|
||||
|
||||
Der Offen-/Geschlossen-Zustand von Feinschliff-Gruppen wird direkt aus ihren bisherigen DOM-Knoten übernommen. Ein verzögertes `toggle`-Ereignis allein reicht nicht: Ein schneller Neuaufbau nach einer Wertänderung kann den Knoten entfernen, bevor das Ereignis ankommt, und würde die gerade geöffnete Gruppe wieder schließen.
|
||||
|
||||
## ADR 074 – Editorfläche, Seitenebene und kontextbezogene Gestaltung (14.09.2026)
|
||||
|
||||
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 +375,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 +532,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.
|
||||
|
||||
+172
-1
@@ -1,6 +1,118 @@
|
||||
# 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.8.1 – Keine automatische Rechtszeile unter dem Footer (14.09.2026)
|
||||
|
||||
**Behoben:** Das CMS hängt Datenschutz, Impressum und Einwilligungslink nicht mehr automatisch an den Footer an. Entfernt aus Seiten-/Archiv-/Fehlervorlagen und damit auch aus Seiten- und Bereichsvorschauen. Normale Textlinks, Menüs und ausdrücklich eingefügte „Rechtliches“-Bausteine bleiben erhalten; der Editorhinweis beschreibt die freie Platzierung. Keine Inhaltsänderung oder Migration (ADR 083).
|
||||
|
||||
**Geprüft:** PHP-Vollsuite 477 Tests / 3815 Assertions und 32 JavaScript-Tests erfolgreich. Vorhandene Tests an die redaktionelle Platzierung angepasst; freiwilliger Rechtsblock und Einwilligungsablauf weiterhin geprüft. 47 Twig-Dateien und Produktionscontainer gültig, PHP-Stilprüfung der drei betroffenen PHP-Dateien ohne Änderungen, Git-Diff ohne Whitespacefehler.
|
||||
|
||||
**Browser:** Isolierte Kant-IT-Kopie mit bestehenden Rechtsblöcken sowie einer Variante mit normalen Footer-Textlinks wie im Screenshot. Acht Prüfgruppen erfolgreich: Startseite, Beitragsarchiv und 404 auf Desktop 1600/Mobil 390 sowie Seiten- und Footer-Editor. Eigene Links erhalten, keine Zusatzleiste, kein horizontaler Überlauf. Screenshots unter `reports/footer-links/` visuell geprüft. Gegenprobe mit dem unveränderten Release 1.8.0 reproduziert die doppelte Leiste auf allen sechs öffentlichen Ansichten (`reports/footer-links-before/`). Originaldaten bleiben unverändert.
|
||||
|
||||
**Bekannte Prüfbefunde:** Die Variante mit gewöhnlichen, unformatierten Textlinks meldet Kontrastbefunde an diesen Links; bei sichtbarem Nach-oben-Knopf zusätzlich einen Landmark-Befund. Das Archiv meldet außerdem Überschriftenreihenfolge und doppelte Landmark-Benennung. Alle Befunde betreffen dieselben Elemente wie in der Gegenprobe mit 1.8.0. Keine fehlerfreie Gesamt-Barrierefreiheit oder neuen Lighthouse-Werte behauptet. Keine Wiederholung der gesamten Editor-Bedienprüfung für diese Template-Entfernung.
|
||||
|
||||
**Veröffentlichung:** Release 1.8.1 wird aus dem geprüften Commit gebaut und auf dem vorhandenen Git bereitgestellt. Danach über die Systemverwaltung aktualisieren; Kundenhostings werden hier nicht verändert.
|
||||
|
||||
## 1.8.0 – Eigene 404-Seite mit Website-Design (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Einstellungen → 404-Seite mit Livesuche, bestehende Seite zuweisen, Auswahl zurücksetzen, direktem Bearbeiten und öffentlicher 404-Vorschau. „404-Vorlage anlegen“ erstellt und verknüpft einen bearbeitbaren Entwurf, ohne vorhandene Seiten zu überschreiben. Bis zur Veröffentlichung erscheint eine automatische Standard-404. Normaler Seitenrenderer übernimmt Standard-Header/-Footer, Theme, Schriften, Menüs und verknüpfte Sektionen. Die ID-Verknüpfung übersteht Umbenennen; Löschen führt auf den Standard zurück. Nur veröffentlichte Revisionen werden öffentlich ausgeliefert.
|
||||
|
||||
**Antwortverhalten:** Echte 404-Antwort an der ursprünglichen URL, auch bei ungewöhnlichen/tiefen HTML-Adressen und beim direkten Aufruf der zugewiesenen Seite. Kein Canonical und keine strukturierten Daten einer normalen Seite; `noindex, follow` in HTML und Header. Ausschluss der zugewiesenen ID aus Sitemap und llms.txt. 301/410, Verwaltungs-/Medien-/Formularendpunkte und maschinenlesbare Ressourcen behalten ihre eigene Behandlung. Zuweisung mit Veröffentlichungsrechten, CSRF und Schutz gegen veraltete Formulare. Keine Datenbankmigration; optionaler Settings-Schlüssel und vorhandenes Dokumentschema v10 (ADR 082).
|
||||
|
||||
**Geprüft:** PHP-Vollsuite 477 Tests / 3817 Assertions erfolgreich, einschließlich neuer 404-Fälle zu Entwurf/Veröffentlichung, Umbenennen, Löschen/Fallback, bestehenden `/404`-Seiten, Schreibkonflikten, Berechtigungen, CSRF, HEAD, Bereichen, 301/410, Sitemap und llms.txt. 32 JavaScript-Tests erfolgreich. `npm run test:404` mit isolierter Kant-IT-Kopie: Vorlage anlegen, im Editor bearbeiten, veröffentlichen, tatsächliche Ausgabe, Livesuche/Tastaturwahl, Zuordnung nach Neuladen, Rücksetzen und veraltetes zweites Formular. Desktop 1600 und Mobil 390 ohne Überlauf/axe-Verstöße; öffentliche Startseite weiterhin 200 mit Canonical und ohne axe-Befund. Funktioniert auch ohne JavaScript; Header/Footer/Schrift mit dem Originalpiloten abgeglichen, Screenshots visuell geprüft (`reports/not-found/`). Originaldaten bleiben unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Laden, Veröffentlichung, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit. Alle 47 Twig-Dateien und der Produktionscontainer gültig; PHP-Stilprüfung über 133 Dateien ohne Befund.
|
||||
|
||||
**Veröffentlicht:** Commit `16da844` und Tag `v1.8.0` auf `origin`; [Release 1.8.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.0) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `4c90a0256ad827c40ae09db85fbe72a9315f41b181c26f9983d710b646bc64b4`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Keine neuen Lighthouse-Scores für eine absichtlich nicht indexierbare 404-Seite. Keine neuen Firefox-/Safari-/Screenreader- oder MySQL-Läufe; lokale Datenbankprüfungen verwenden SQLite. Nach Veröffentlichung über die Systemverwaltung aktualisieren; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
|
||||
|
||||
## 1.7.4 – URL-Feld bei Bild-Klickaktion zuverlässig einblenden (14.09.2026)
|
||||
|
||||
**Behoben:** „Zu einer Adresse führen“ blendet Linkziel und Tab-Häkchen unmittelbar unter dem Dropdown ein, auch bei Bildern ohne frühere Verlinkung. Inhalts-Auswahlfelder reagieren auf `input` und `change`, ohne doppelte Änderungen. Das Dropdown bleibt beim Umschalten im DOM; Linkfelder werden nur ein-/ausgeblendet, eingegebene Adressen bleiben erhalten. Jede Auswahl bleibt einzeln rückgängig zu machen. Keine neue Schema-/Datenbankmigration, kein geänderter öffentlicher Renderer oder öffentliches Paket. Cachekennung 1.7.4.
|
||||
|
||||
**Geprüft:** Der ergänzte Browserfall mit ausschließlich `change` scheiterte vor dem Fix am fehlenden URL-Feld (`var/image-link-before.log`). Danach `npm run test:link-media` mit sechs Prüfgruppen bestanden: erstmals verlinktes Bild, natives Dropdown mit Mausöffnung/Tastaturwahl, Tastaturwechsel, stabiler DOM-Knoten/Fokus, Nur-change-Ereignis, URL-Erhalt, Vergrößern/Nichts tun, Undo/Redo, Speichern/Laden und veröffentlichte Zieladresse. Bestehende Bild-, Hintergrund-, Textlink- und Menüprüfungen ebenfalls erfolgreich. Desktop-/Mobil-Editor und öffentliche Seite ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). 32 JavaScript-Tests bestanden. Schreibtests ausschließlich in isolierten Kant-IT-Kopien.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Inhalts-/Design-Auswahl, Undo, Speichern, Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`).
|
||||
|
||||
**Veröffentlicht:** Commit `25fd388` und Tag `v1.7.4` auf `origin`; [Release 1.7.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.4) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3532 Archivdateien, 209 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `f41ff10ad403a5dccd48e8d2805079dc3ebdfa2966fd548c4c1b0abe6d26a617`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Kein erneuter PHP- oder Lighthouse-Lauf für diese reine Editor-Korrektur. Kundenhostings werden nicht verändert; nach Einspielen des Updates den Editor neu öffnen, damit die versionierten Skripte geladen werden.
|
||||
|
||||
## 1.7.3 – Links in neuen Tabs und Bildvorschauen (14.09.2026)
|
||||
|
||||
**Umgesetzt:** „In neuem Tab öffnen“ bei Buttons, verlinkten Bildern, Symbolen, Symbolkarten, Aufruf-Bändern, Textlinks und Menüeinträgen. Serverseitig validierte Ausgabe mit `target="_blank" rel="noopener"`; normale Links bleiben im selben Tab. Textlinks lassen sich mit vorhandenem Ziel/Häkchen wieder bearbeiten. „Nichts tun“ am Bild deaktiviert auch eine früher hinterlegte Verlinkung. Inhalts-, Galerie-/Logo- und Hintergrundbilder zeigen Vorschauen mit Lade-/Fehlerzustand und Entfernen-Funktion. Entfernen ist rückgängig zu machen und erhält die Datei in der Mediathek. Bildaustausch erhält Link, Bildunterschrift und Gestaltung. Geerbte Hintergrundbilder sind sichtbar und je Gerät entfernbar; kleinere Geräte übernehmen die Entfernung, sofern dort kein eigener Wert besteht. Verläufe bleiben erhalten. Tastaturfokus bleibt beim Umschalten der Klickaktion und nach dem Entfernen eines Hintergrunds bedienbar.
|
||||
|
||||
**Datenvertrag:** Dokument-Schema v20 nur beim Verwenden neuer Block-Linkwerte oder ausdrücklicher Hintergrundentfernung. Vorhandene Dokumente bleiben lesbar, keine Datenbankmigration. Migration und Rückweg: ADR 081. Medienvorschauen verwenden eine authentifizierte Einzelabfrage und tatsächlich vorhandene kleine Varianten, auch bei archivierten verwendeten Bildern. Kein zusätzliches öffentliches JavaScript.
|
||||
|
||||
**Geprüft:** 338 PHP-Tests (`CmsTest|EditorSecurityTest`) mit 2873 Assertions; 32 JavaScript-Tests; 47 Twig-Vorlagen gültig; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:link-media` auf einer isolierten Kant-IT-Kopie: Vorschauen, fehlendes Bild, Entfernen/Undo bei Bild/Galerie/Hintergrund, Vererbung/Reset, Links erstellen/ändern/deaktivieren, Menü-Undo, Speichern/Laden und Veröffentlichung. Neuer Tab tatsächlich geöffnet, Ursprungstab erhalten und `window.opener` null. Veröffentlichter Hintergrund ohne JavaScript bei 1600/900/390 px geprüft. Öffentliche Seite sowie Desktop-/Mobil-Editor ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). Vollständiges `npm run test:editor` ebenfalls erfolgreich, einschließlich Mediathek, Entwurf/Veröffentlichung/Konflikten und Desktop-/Mobil-Barrierefreiheit. Originaldaten bleiben unverändert; Schreibtests ausschließlich in temporären Kopien.
|
||||
|
||||
**Veröffentlicht:** Commit `6d21c45` und Tag `v1.7.3` auf `origin`; [Release 1.7.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.3) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen. Aus dem exakten Commit gebaut: 3532 Archivdateien, 209 versionierte Quelldateien bytegenau verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `acf6a4ebea555fe7613935372e1a7922aca4e2c72646175a93d349359787e315`.
|
||||
|
||||
**Grenzen und nächster Schritt:** Keine erneute Lighthouse-Messung für diese Link-/Mediensteuerung; öffentliche Ausgabe und Barrierefreiheit direkt geprüft. Keine neuen Firefox-/Safari-/Screenreader-Prüfungen. Nach Veröffentlichung kann das Update über die Systemverwaltung eingespielt werden; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
|
||||
|
||||
## 1.7.2 – Bildbreite direkt in Prozent einstellen (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Beim ausgewählten Bild steht im Reiter Inhalt oben „Bildbreite (%)“ mit Regler, Zahlenfeld und Zurücksetzen. 1–100 % beziehen sich auf die verfügbare Breite der Sektion/Spalte. Desktop, Tablet und Mobil verwenden die vorhandene Vererbung; Herkunft und bestehende maximale Breiten sind sichtbar. Beide Bildsteuerungen bearbeiten denselben gespeicherten Breitenwert wie Design → Größe. Originalmaße, Medienvarianten, Alt-Text und Bildunterschrift bleiben erhalten. Ungültige Zahlen verändern den gespeicherten Wert nicht, eine Ziehbewegung bleibt ein Undo-Schritt. Der veraltete Hinweis auf eine erst künftig verfügbare Mediathek wurde berichtigt. Keine neue Schema-/Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen Paket.
|
||||
|
||||
**Geprüft:** `npm run test:image-size` auf einer isolierten Kant-IT-Kopie mit dem TeamViewer-Logo: Regler mit echter Mausbewegung und Tastatur, Zahlenfeld, Fokus, ungültiger Wert, Undo/Redo, responsive Vererbung/Reset, Speichern/Laden, unveränderte Bilddaten und Abgleich mit Design → Größe. Veröffentlichte Ausgabe ohne JavaScript bei 1600/900/390 px: 60/75/30 % und unverzerrtes Seitenverhältnis gemessen. Neue Steuerung auf Desktop/Mobil ohne Überlauf und axe-Verstöße, Screenshots visuell geprüft (`reports/image-size/`). Vollständige Editor-Browserprüfung erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). 30 JavaScript-Tests und 35 PHP-Tests zu Styles/Bildern mit 67 Assertions erfolgreich. Alle Schreibtests in eigenen Datenbankkopien.
|
||||
|
||||
**Veröffentlicht:** Commit `0920e3c` und Tag `v1.7.2` auf `origin`; [Release 1.7.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.2) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen. Aus dem exakten Commit gebaut: 3531 Archivdateien, 208 versionierte Quelldateien bytegenau verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `e9983669871815e46c8a675bf59eb3632f37f015c0026ccb072a93119def5ac8`.
|
||||
|
||||
**Grenzen und nächster Schritt:** Das Update kann über die Systemverwaltung eingespielt werden; Kundenhostings wurden nicht verändert. Da nur die Editorsteuerung vorhandener Style-Werte geändert wurde, keine erneuten öffentlichen Lighthouse-Messungen; die Prozentdarstellung wurde im gemeinsamen Renderer direkt überprüft.
|
||||
|
||||
## 1.7.1 – Livesuche in der Verwaltung (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Seiten und Beiträge suchen beim Tippen per AJAX; Status, 20/50 Einträge und Pagination aktualisieren nur die Ergebnisliste. URL, Browser-Zurück/Vor, Eingabefokus und Rückkehrfilter beim Speichern bleiben zusammenhängend. Die Mediathek durchsucht Titel und Alternativtext über alle Seiten, kombiniert mit Archivfilter. Bestehende Suchen in Menüs, übergeordneten Seiten, Editor-Werkzeugkasten/Struktur/Eigenschaften und Bildauswahl reagieren bereits beim Tippen. Kein Enter nötig; ohne JavaScript bleibt „Anwenden“ erhalten. Alte Antworten werden verworfen, Fehler erhalten Treffer und Eingaben, Wiederholen ist direkt möglich. Eine während der Suche begonnene Bearbeitung hält den Austausch an. SQLite-Suche erkennt Groß-/Kleinschreibung auch bei Umlauten. Keine Datenmigration und kein zusätzliches öffentliches JavaScript.
|
||||
|
||||
**Geprüft:** PHP 8.4.22: `EditorSecurityTest|CmsTest` 332 Tests / 2832 Assertions; nach der abschließenden Korrektur zur Suchanfrage „0“ die fünf Livesuche-/Medienfälle erneut mit 111 Assertions. 30 JavaScript-Tests; 24 Admin-Twig-Vorlagen; Produktionscontainer; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:search`: sechs Browser-Prüfgruppen mit kopierten Kant-IT-Daten, darunter schnelle/überholte Anfragen, Fehler/Wiederholen, abgelaufene Sitzung, IME, Pagination/Historie, Seitenauswahl nach AJAX, Bearbeitung während laufender Suche, Medien und Betrieb ohne JavaScript. Desktop 1440 und Mobil 390: kein horizontaler Überlauf, axe ohne Verstöße; Screenshots visuell geprüft. `test:admin -- --admin-only` bestätigt zusätzlich Menüs, Formulare, Bereiche, Speichern/Laden sowie die Vorschau mit aktivierter Bewegung; Tablet/Mobil ohne axe-Befunde. Berichte: `reports/live-search/`, `reports/admin-quality/`; Testkonten und Änderungen nur in isolierten Datenbankkopien.
|
||||
|
||||
**Veröffentlicht:** Commit `41d87f6` und Tag `v1.7.1` auf `origin`; [Release 1.7.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.1) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` hochgeladen und anschließend ohne Anmeldung heruntergeladen/bytegenau geprüft. Paket aus dem exakten Commit: 3531 Dateien, davon 208 versionierte Quelldateien verglichen; Produktionsabhängigkeiten ohne Entwicklungspakete, `.env` aus Vorlage, keine `.env.local`. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite mit HTTP 200 geprüft. ZIP-SHA-256: `08846e3b8b700e66075160956a9e1963dc5d4f11a350dae175a0ff9511dd2ab1`.
|
||||
|
||||
**Grenzen und nächster Schritt:** Das Update ist veröffentlicht und kann über die Systemverwaltung eingespielt werden; Kundenhostings wurden nicht verändert. MySQL wurde in diesem Schritt nicht ausgeführt; die lokale Prüfung verwendet SQLite. Öffentliche Lighthouse-Messungen wurden für diese Verwaltungsänderung nicht wiederholt; Nachweise aus 1.7.0 stehen unten.
|
||||
|
||||
## 1.7.0 – Installation, Verwaltungsabläufe und Bewegung (14.09.2026)
|
||||
|
||||
**Veröffentlicht:** Commit `495b268` und annotierter Tag `v1.7.0` sind auf `origin` hochgeladen. [Release 1.7.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.0) enthält ZIP, SHA-256-Datei und `install.php`. Gebaut aus dem exakten Commit: 3527 Archivdateien, 204 versionierte Quelldateien bytegenau verglichen, Produktionsabhängigkeiten ohne Entwicklungspakete. Archivintegrität, Prüfsumme im Installer, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env` vorhanden, keine `.env.local`) geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; API meldet 1.7.0 als neuestes stabiles Release. ZIP-SHA-256: `b708f7dffad74e1def31325533b646557963b0f3e702c46bd85ee109fc22a192`. Das Update ist bereitgestellt, nicht auf Kundenhostings eingespielt.
|
||||
|
||||
**Umgesetzt:** `.env` ist die einzige automatisch geladene Installationsdatei und aus Git entfernt. Setup, Webinstaller und Admin-Pfad-Befehl schreiben dort. Alte `.env.local` wird einmalig mit Sicherung beider Originale zusammengeführt; wirksame Datenbank und Secret bleiben erhalten. System zeigt die tatsächliche Datenbank samt Quelle. Der Git-Updater erhält Konfiguration vor dem neuen PHP-Prozess und beim Rückweg; ein entdeckter falscher Datenbank-Sicherungspfad wurde ebenfalls korrigiert. Ein fehlgeschlagenes Zurückschreiben lässt die Website im Wartungszustand. Vorgehen beim erstmaligen Wechsel von altem Git-Updater-Code und bei FTP: UPDATE.md. Keine Schema-/Datenbankmigration.
|
||||
|
||||
**Verwaltung:** Fehler 500 beim Öffnen von Bereichen behoben. Seiten/Beiträge mit eigener Navigation, datenbankseitiger Pagination (20/50), Suche, Status, begrenztem Papierkorb und Filtererhalt. Elternauswahl unabhängig von der Ergebnisseite. Menüeditor mit Suchauswahl per Tastatur, eigenen Links, klarer Reihenfolge, Haupt-/Unterpunkten und Rückgängig; Validierungs-/Verbindungs-/Versionsfehler erhalten Eingaben. Formularmeldung mehrzeilig über die verfügbare Breite; Felddefinitionen auf schmalen Bildschirmen als beschriftete Gruppen. Mobil zugänglicher Kontolink.
|
||||
|
||||
**Bewegung:** Richtungen und Effekte werden beim Erscheinen tatsächlich unterschieden. Slider haben Pfeile, Zähler, optionale Punkte und Pause/Wiedergabe. Ein bereits geplanter Wechsel wird bei manueller Bedienung abgebrochen. Automatik wartet bei Hover, außerhalb des Bildschirms und in unsichtbaren Tabs; reduzierte Bewegung stoppt sie. Bewegung im Editor ausdrücklich testbar, iframe-Sandbox weiterhin ohne Skriptfreigabe. Kein JavaScript für die Lesbarkeit der Inhalte erforderlich.
|
||||
|
||||
**Geprüft:** PHP 8.4.22 Vollsuite 461 Tests/3577 Assertions bestanden; anschließend 10/10 Tests des finalen Updaters mit 206 Assertions, einschließlich echter Datenbankänderung vor dem Fehler und ausgefallenem Restore. `npm.cmd test` 30/30. Alle 43 Twig-Dateien und Produktionscontainer gültig. Vollständiger `npm.cmd run test:editor` bestanden, einschließlich neuer Links-/Rechtseinblendung, reduzierter Bewegung, Drag-and-drop, Undo/Redo, Speicher-/Veröffentlichungs-/Konfliktabläufen und sämtlichen axe-Prüfungen. `node scripts/admin-quality-check.mjs --audit` bestanden: isolierte Kant-IT-Kopie mit 4 Seiten/29 Beiträgen; Pagination ohne Überschneidungen, leere Suche, Seitengrößen und Grenzwerte; Menüsuche per Tastatur, Hierarchie/Undo/Speicherung sowie 422/409 ohne Eingabeverlust; breite Formularmeldung nach Neuladen; echte gemeinsame Sektion mit Text; Kopf/Fuß ändern und neu laden; mehrfaches Slider-Autoplay, manuelle Pause, Fortsetzen, reduzierte Bewegung und bedienbare Sandbox-Vorschau. Desktop/1024/390 px ohne Seitenüberlauf oder axe-Verstoß. Zusätzliche Prüfung ohne JavaScript und visuelle Screenshots bestanden. Kunden-/Pilotdatenbank bleibt eine nur gelesene Quelle; Testkonten und Änderungen liegen ausschließlich in temporären Kopien, deren Hintergrundaufgaben abgeschaltet sind.
|
||||
|
||||
**Öffentliche Messung:** Kant-IT-Startseite mit Originalmedien, Produktionsmodus, Lighthouse **13.4.1**, je drei Läufe: Leistung mobil **99/99/99**, Desktop **99/99/97**, Median jeweils **99**. Barrierefreiheit, Best Practices, SEO und Agentic Browsing in allen sechs Läufen **100**. Lokaler PHP-Server mit Kompression/Cachekopfzeilen über `bin/dev-router.php`; keine Garantie für Hosting oder beliebige Kundeninhalte. Berichte: `reports/admin-quality/lighthouse/summary.json`, einzelne Läufe daneben; Editor: `reports/editor/result.json`; Verwaltungsabläufe: `reports/admin-quality/result.json`. Screenshots der Verwaltung, der mobilen Formulardefinition und des Piloten visuell geprüft.
|
||||
|
||||
**Grenzen / nächster Schritt:** Kein neuer Firefox-/Safari-/Screenreader- oder echter Touchgerätetest, keine CWV-Felddaten. Der Pilot enthält vereinzelt bereits gespeicherte HTML-Entities als Klartext. Die übrigen offenen Entwicklungsplan-Pakete bleiben bestehen. Installations- und Bedienkorrekturen nach Veröffentlichung über den vorhandenen Archiv-Updater auf der gewünschten Installation übernehmen. Arbeitsplan: ARBEITSPLAN-ADMIN-QUALITAET.md; Entscheidungen: ADR 076–078.
|
||||
|
||||
## 1.6.2 – Sichtbare leere Sektionen und Spalten (14.09.2026)
|
||||
|
||||
**Veröffentlicht:** Commit `6a044e5` und annotierter Tag `v1.6.2` sind auf `origin` hochgeladen. [Release 1.6.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.2) enthält ZIP, SHA-256-Datei und `install.php`. Aus dem exakten Commit mit Produktionsabhängigkeiten gebaut: 3521 Archivdateien, 199 Quelldateien gegen den Commit verglichen, Archivintegrität und Produktionscontainer geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und gegen die lokalen Dateien geprüft; 1.6.2 ist laut API das neueste stabile Release. ZIP-SHA-256: `6af093d752fafa76c720c203e1e3ced03e37ace3481a405b7d6eed378255e449`. Das Update ist bereitgestellt, nicht auf Kundenwebsites installiert.
|
||||
|
||||
**Erledigt:** Leere Sektionen, Zeilen und Spalten besitzen in der Vorschau sichtbare, beschriftete Arbeitsflächen. „Inhalt hinzufügen“ setzt einen genauen Einfügepunkt, öffnet den Werkzeugkasten und fokussiert die Suche. Leere Sektionen bieten unmittelbar 1–4 Spalten; neue Elemente werden sichtbar gescrollt. Beim Ziehen sind Container und Ablageziel erkennbar, die Vorschau scrollt am Rand mit. Spalten werden auf Desktop horizontal, bei mobiler Stapelung vertikal eingeordnet. Geöffnete Feinschliff-Gruppen bleiben auch bei schnellen Änderungen erhalten. Cachekennung 1.6.2; keine Schema- oder Datenbankmigration. Die Hilfen werden ausschließlich im Editor-DOM erzeugt und erscheinen weder in gespeicherten Dokumenten noch auf öffentlichen Seiten. Entscheidungen: ADR 075.
|
||||
|
||||
**Referenz:** Die tatsächlichen Builder-Dateien des bereitgestellten Flatsome 3.20.9 wurden erneut gelesen. Übernommen sind sichtbare Leerflächen, direkte Plus-Aktionen und schnelle Spaltenlayouts. Konkrete Fundstellen und Umfang stehen in `docs/FLATSOME-ABGLEICH.md`; keine laufende WordPress-Referenzinstallation geprüft.
|
||||
|
||||
**Geprüft:** `npm.cmd test` 30/30; PHP 8.4.22 `vendor/bin/phpunit --filter EditorSecurityTest` 27/27 mit 312 Assertions; JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig. Der vollständige isolierte `npm.cmd run test:editor` besteht einschließlich axe-Prüfungen. Neue Abdeckung: sichtbare leere Sektion, Tastaturwahl des Zweispaltenlayouts, Einfügen in genau die zweite Spalte, Ziehen in die erste Spalte mit Zielmarkierung und automatischem Scrollen, Undo/Redo, leere Layouts nach Speichern/Neuladen, Spaltenumsortierung Desktop/Mobil und Veröffentlichung ohne Editorhilfen. Desktop-, Mobil- und Drag-Ansichten anhand der Screenshots visuell geprüft. Bericht: `reports/editor/result.json`; Bilder: `empty-section.png`, `empty-layout-desktop.png`, `empty-layout-mobile.png`, `empty-layout-drag.png` im selben Ordner. Keine Änderungen an Kundendaten durch die isolierten Tests.
|
||||
|
||||
**Bekannte Grenzen:** Der öffentliche Renderer ist unverändert; Lighthouse wurde für diese Fassung nicht erneut gemessen. Die Messung zu 1.6.1 bleibt unten dokumentiert. Kein neuer Screenreader-, Firefox-, Safari- oder echter Touchgerätetest. **Nächster Schritt:** Die neuen Aufbauabläufe mit Umuts tatsächlichen Seiten nutzen und anschließend die restlichen Arbeitspakete des Entwicklungsplans fortsetzen.
|
||||
|
||||
## 1.6.1 – Editorbreite, neue Seitenabschnitte und passende Eigenschaften (14.09.2026)
|
||||
|
||||
**Veröffentlicht am 14.09.2026:** Commit `b783d9d` und annotierter Tag `v1.6.1` sind auf `origin` (`https://git.kant-it.de/kantarci/KantSites.git`) hochgeladen. Das stabile [Release 1.6.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.1) enthält ZIP, SHA-256-Datei und `install.php`. Das Archiv wurde aus dem exakten Commit mit Produktionsabhängigkeiten gebaut (3520 Dateien); Archivintegrität, Übereinstimmung mit dem Commit und Produktionscontainer wurden geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; die API liefert 1.6.1 als neuestes stabiles Release. Archiv-SHA-256: `a6e7da2bb14ede43dd2d94960b3b4d67cc285d9c717b560f796a91cbcde4dd26`. Dieser Schritt veröffentlicht das Update, installiert es aber nicht auf Kundenwebsites.
|
||||
|
||||
**Erledigt:** Die festen 690/900/760-px-Grenzen für Text/H1/H2 sind entfernt. Elemente nutzen ihren Container, eigene Maximalbreiten bleiben möglich. Dauerhaft sichtbare Aktion **Neue Sektion auf Seitenebene**; auch der normale Sektionsklick erzeugt ohne expliziten Einfügepunkt einen übergeordneten Seitenabschnitt. Linke und rechte Sidebar sind per Ziehen und Tastatur verstellbar und merken ihre Breite im Browser. Die Vorschau nutzt ihre Fläche ohne Außenränder; die Elementbibliothek läuft nicht mehr horizontal über. Auf kleinen Fenstern passen sich die Panels an, auf dem Telefon stehen sie untereinander.
|
||||
|
||||
**Feinschliff:** Bild-, Text- und Containeroptionen werden passend gefiltert und sortiert. Gespeicherte Werte bleiben sichtbar; die gesamte Eigenschaftenauswahl ist über eine Schaltfläche erreichbar. Die Auswahl eines anderen Blocks setzt die Panelrolleiste nach oben. Geöffnete Eigenschaftengruppen bleiben beim Ändern eines Werts geöffnet. Automatische Größen und „Ohne Begrenzung“ sind explizit wählbar. Cachekennung auf 1.6.1 angehoben; keine Datenbankmigration, keine Kundeninhalte durch Tests geändert. Begründung und Rückweg: ADR 074.
|
||||
|
||||
**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 +126,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 +150,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 +257,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 +316,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 +562,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');
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -8,7 +8,12 @@
|
||||
"test:editor": "node scripts/editor-check.mjs",
|
||||
"test:browser": "node scripts/browser-check.mjs",
|
||||
"test:links": "node scripts/link-check.mjs",
|
||||
"audit": "node scripts/audit.mjs"
|
||||
"audit": "node scripts/audit.mjs",
|
||||
"test:admin": "node scripts/admin-quality-check.mjs",
|
||||
"test:search": "node scripts/live-search-check.mjs",
|
||||
"test:image-size": "node scripts/image-size-check.mjs",
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
+55
-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,31 @@ 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}
|
||||
.selected-media-preview{margin:12px 0;padding:10px;border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.selected-media-preview img{display:block;width:100%;height:140px;object-fit:contain;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px}.selected-media-preview figcaption{font-size:12px;line-height:1.5;overflow-wrap:anywhere;margin-top:8px;color:var(--muted)}
|
||||
.live-search-ready .search-submit{display:none}.search-status{min-height:20px;margin:-12px 0 0}#list-results{scroll-margin-top:16px}#list-results>.section-head{margin-top:0}#list-results:focus-visible{outline:2px solid var(--accent);outline-offset:5px}#search-error p{margin-top:0}
|
||||
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
|
||||
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
||||
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
|
||||
.picker-results{position:absolute;z-index:30;top:100%;left:0;right:0;min-width:220px;max-height:300px;overflow-y:auto;background:white;border:1px solid #a7b9af;border-radius:8px;box-shadow:0 10px 30px #18382b24;padding:5px}
|
||||
.picker-results[hidden]{display:none}.picker-results button{display:flex;flex-direction:column;align-items:start;width:100%;padding:10px;text-align:left;color:#163d30;background:white;border:0;border-radius:5px}
|
||||
.picker-results button:hover,.picker-results button[aria-selected=true]{background:#e8f2e9}.picker-results span{font-size:12px;color:#496458;overflow-wrap:anywhere}.picker-status:empty{display:none}
|
||||
@media(max-width:650px){.list-toolbar>div,.list-toolbar .list-search{min-width:0;flex-basis:100%}.picker-results{min-width:0}.picker-input button{min-width:44px;min-height:44px}}
|
||||
|
||||
/* One menu at a time: content picker on the left, ordered structure on the right. */
|
||||
.menu-slots{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 24px}.menu-slots a{padding:10px 16px;border:1px solid var(--line);border-radius:8px;text-decoration:none;background:white}.menu-slots a[aria-current=page]{background:var(--shell);color:white;border-color:var(--shell)}
|
||||
.menu-workspace{display:grid;grid-template-columns:minmax(260px,320px) minmax(0,1fr);gap:24px;align-items:start}.menu-add{display:grid;gap:16px;position:sticky;top:20px}.menu-add .card{margin:0}.menu-add .content-picker{margin-bottom:16px}.menu-structure{min-width:0}.menu-items{list-style:none;padding:0;margin:24px 0;display:grid;gap:12px}.menu-entry{border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.menu-entry.menu-child{margin-left:32px;border-left:3px solid #8eaa99}.menu-entry summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px;cursor:pointer}.menu-entry summary::after{content:'⌄';font-size:18px}.menu-entry-title{overflow-wrap:anywhere;flex:1}.menu-entry-fields{padding:0 16px 16px}.menu-entry-fields .content-picker{margin-top:14px}.menu-entry-actions{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;border-top:1px solid var(--line);background:#f7f9f6;border-radius:0 0 9px 9px}.menu-entry-actions button{min-height:36px}.menu-entry-actions .danger-text{margin-left:auto}.menu-save{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding-top:16px;border-top:1px solid var(--line)}.menu-save [role=status]{font-size:13px;color:var(--muted)}
|
||||
@media(max-width:1100px){.menu-workspace{grid-template-columns:minmax(220px,280px) minmax(0,1fr)}}
|
||||
@media(max-width:850px){.menu-workspace{grid-template-columns:1fr}.menu-add{position:static;grid-template-columns:1fr 1fr}.menu-add>p{grid-column:1/-1}}
|
||||
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
|
||||
|
||||
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
|
||||
.form-def{min-width:0}.field-table{table-layout:fixed;width:100%}.field-table input:not([type=checkbox]),.field-table select{width:100%;min-width:0;max-width:100%}
|
||||
@media(max-width:1100px){
|
||||
.field-table,.field-table tbody{display:block}.field-table thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)}
|
||||
.field-table tbody{display:grid;gap:16px}.field-table tr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px}
|
||||
.field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left}
|
||||
}
|
||||
@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}}
|
||||
@@ -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,8 +5,8 @@
|
||||
* 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 { openMedia } from './media.js';
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
|
||||
export const GROUPS = {
|
||||
text: 'Typografie',
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -430,15 +444,18 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
const mediaControl = (key, value, apply) => {
|
||||
const wrap = element('div', 'media-control');
|
||||
const status = element('p', 'media-chosen', value ? `Bild gewählt (${String(value).slice(0, 8)}…)` : 'Kein Bild gewählt');
|
||||
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||
const hasImage = Boolean(value && value !== 'none');
|
||||
const preview = createMediaPreview({ mediaId: hasImage ? value : '' });
|
||||
const choose = element('button', 'secondary', hasImage ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||
choose.type = 'button';
|
||||
choose.id = `style-${key}`; choose.setAttribute('aria-label', choose.textContent);
|
||||
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
|
||||
wrap.append(status, choose);
|
||||
if (value) {
|
||||
wrap.append(preview.element, choose);
|
||||
if (hasImage) {
|
||||
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
||||
remove.type = 'button';
|
||||
remove.addEventListener('click', () => apply(''));
|
||||
remove.title = 'Hintergrundbild für dieses Gerät entfernen. Die Datei bleibt in der Mediathek.';
|
||||
remove.addEventListener('click', () => { apply('none'); document.getElementById(choose.id)?.focus(); });
|
||||
wrap.append(remove);
|
||||
}
|
||||
return wrap;
|
||||
@@ -446,14 +463,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 +509,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,11 +540,11 @@ 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);
|
||||
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
|
||||
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
@@ -591,6 +621,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
};
|
||||
|
||||
function describe(property, value) {
|
||||
if (property.type === 'media') return value === 'none' ? 'Kein Bild' : 'Bild aus der Mediathek';
|
||||
if (property.type === 'choice') return property.values[value] ?? value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -247,7 +252,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 19;
|
||||
export const SCHEMA_VERSION = 20;
|
||||
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
|
||||
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
@@ -305,7 +310,7 @@ export function mediaProps(media) {
|
||||
export function insertMedia(document, selected, items) {
|
||||
const target = locate(document.blocks, selected)?.block;
|
||||
upgradeDocument(document, 3);
|
||||
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
|
||||
if (target?.type === 'image') { target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' }; return target.id; }
|
||||
let parent = target;
|
||||
if (target?.type !== 'gallery') {
|
||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||
@@ -491,6 +496,7 @@ export function setStyle(document, id, device, key, value, { side = null, hover
|
||||
if (!block) return;
|
||||
const empty = value === '' || value === null || value === undefined;
|
||||
if (!empty) upgradeDocument(document, 10);
|
||||
if (key === 'backgroundImage' && value === 'none') upgradeDocument(document, 20);
|
||||
|
||||
block.style ??= {};
|
||||
block.style[device] ??= {};
|
||||
@@ -533,3 +539,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;
|
||||
}
|
||||
@@ -193,6 +193,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
|
||||
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
|
||||
.rt-link input{font-size:11px;min-height:30px}
|
||||
.rt-link{flex-wrap:wrap;align-items:center}.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
|
||||
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
|
||||
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
|
||||
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
||||
@@ -246,6 +247,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 +283,35 @@ 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}
|
||||
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
|
||||
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
|
||||
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
|
||||
+241
-27
@@ -1,9 +1,12 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
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)));
|
||||
@@ -150,7 +177,9 @@ function renderProperties() {
|
||||
}
|
||||
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
|
||||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||||
let imagePreview;
|
||||
const field = (label, key, value, options = {}) => {
|
||||
const container = options.container || panel;
|
||||
const id = `property-${key}`;
|
||||
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
|
||||
let input;
|
||||
@@ -162,8 +191,13 @@ function renderProperties() {
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
|
||||
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||||
if (options.maxLength) input.maxLength = options.maxLength;
|
||||
input.addEventListener('input', () => {
|
||||
let previousChoice = input.value;
|
||||
const applyValue = () => {
|
||||
const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value;
|
||||
// Native selects may emit input, change, or both. Apply each selection once and keep
|
||||
// the control in place while its popup or keyboard navigation is active.
|
||||
if (options.choices && input.value === previousChoice) return;
|
||||
previousChoice = input.value;
|
||||
history.change(doc => {
|
||||
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||||
if (!current) return;
|
||||
@@ -176,14 +210,20 @@ function renderProperties() {
|
||||
else current[key] = next;
|
||||
if (key === 'caption') upgradeDocument(doc, 3);
|
||||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, `${selected || 'page'}:${key}`);
|
||||
if (key === 'decorative') renderProperties();
|
||||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||
options.onInput?.(next);
|
||||
if (key === 'decorative') { renderProperties(); document.getElementById(id)?.focus({ preventScroll: true }); }
|
||||
renderTree(); controls(); schedulePreview();
|
||||
});
|
||||
};
|
||||
input.addEventListener('input', applyValue);
|
||||
if (options.choices) input.addEventListener('change', applyValue);
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) panel.append(labelEl); else panel.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); panel.append(help); }
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
@@ -288,7 +328,7 @@ function renderProperties() {
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
||||
}
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
@@ -306,15 +346,25 @@ function renderProperties() {
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'image') {
|
||||
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
|
||||
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
renderImageWidth(block, panel);
|
||||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||
panel.append(imagePreview.element);
|
||||
const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options';
|
||||
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
|
||||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
showLinkOptions(clickAction); panel.append(linkOptions);
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen';
|
||||
remove.title = 'Bild aus diesem Inhalt entfernen. Die Datei bleibt in der Mediathek.';
|
||||
remove.addEventListener('click', () => { actions.delete(block.id); $('#undo').focus(); }); panel.append(remove);
|
||||
field('Bildadresse', 'src', block.props.src, { help: 'Lokale Datei oder HTTPS-Adresse. Über die Mediathek wird die passende Bildvariante automatisch gewählt.' });
|
||||
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
|
||||
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
|
||||
field('Originalbreite (px)', 'width', block.props.width, { number: true });
|
||||
field('Originalhöhe (px)', 'height', block.props.height, { number: true });
|
||||
field('Originalhöhe (px)', 'height', block.props.height, { number: true, help: 'Abmessungen der Bilddatei. Die Anzeigegröße stellst du oben unter Bildbreite ein.' });
|
||||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||
}
|
||||
if (block.type === 'gallery') {
|
||||
@@ -323,9 +373,25 @@ function renderProperties() {
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
}
|
||||
if (['gallery', 'logos'].includes(block.type)) {
|
||||
const images = document.createElement('div'); images.className = 'selected-media-list';
|
||||
for (const child of block.children || []) {
|
||||
const item = document.createElement('div');
|
||||
const title = child.props.alt || child.props.caption || 'Bild';
|
||||
item.append(createMediaPreview({ src: child.props.sources?.[0]?.src || child.props.src, label: title }).element);
|
||||
const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'text-button'; edit.textContent = 'Bearbeiten'; edit.setAttribute('aria-label', `${title} bearbeiten`);
|
||||
edit.addEventListener('click', () => choose(child.id));
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Entfernen'; remove.setAttribute('aria-label', `${title} entfernen`);
|
||||
remove.addEventListener('click', () => {
|
||||
history.change(doc => { const found = locate(doc.blocks, child.id); if (found) found.siblings.splice(found.index, 1); }); changed(); $('#undo').focus();
|
||||
});
|
||||
item.append(edit, remove); images.append(item);
|
||||
}
|
||||
panel.append(images);
|
||||
}
|
||||
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.' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +436,48 @@ function renderAdvanced(block, panel) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderImageWidth(block, panel) {
|
||||
const group = document.createElement('div'); group.className = 'image-width-control';
|
||||
const label = document.createElement('label'); label.htmlFor = 'image-width-percent'; label.textContent = 'Bildbreite (%)';
|
||||
const row = document.createElement('div'); row.className = 'image-width-inputs';
|
||||
const slider = document.createElement('input'); slider.type = 'range'; slider.setAttribute('aria-label', 'Bildbreite Schieberegler');
|
||||
const exact = document.createElement('input'); exact.type = 'number'; exact.id = label.htmlFor;
|
||||
for (const input of [slider, exact]) { input.min = '1'; input.max = '100'; input.step = '0.1'; input.setAttribute('aria-describedby', 'image-width-help image-width-state'); }
|
||||
const state = document.createElement('p'); state.id = 'image-width-state'; state.className = 'field-help';
|
||||
const help = document.createElement('p'); help.id = 'image-width-help'; help.className = 'field-help';
|
||||
help.textContent = 'Anteil an der verfügbaren Breite der Sektion oder Spalte. Die Originalmaße der Datei bleiben erhalten.';
|
||||
const error = document.createElement('p'); error.className = 'field-help image-width-error'; error.setAttribute('role', 'alert'); error.hidden = true;
|
||||
error.textContent = 'Bitte 1 bis 100 eingeben. Die bisherige Breite bleibt bis dahin erhalten.';
|
||||
const reset = document.createElement('button'); reset.type = 'button'; reset.id = 'image-width-reset'; reset.className = 'text-button'; reset.textContent = 'Breite zurücksetzen';
|
||||
const sync = (keepInput = false) => {
|
||||
const current = locate(history.document.blocks, block.id)?.block;
|
||||
const { value, source } = effectiveStyle(current, device, 'width');
|
||||
const percent = typeof value === 'string' && /^\d+(?:\.\d+)?%$/.test(value) ? parseFloat(value) : null;
|
||||
if (!keepInput) exact.value = percent ?? '';
|
||||
exact.placeholder = value && percent === null ? value : '100';
|
||||
slider.value = percent ?? 100;
|
||||
slider.setAttribute('aria-valuetext', percent !== null ? `${percent} Prozent` : `Aktuell ${value || 'automatisch'}. Prozentwert wählen.`);
|
||||
state.textContent = `${devices[device]} · ${source === device ? 'Eigener Wert' : source ? `Von ${devices[source]} übernommen` : 'Automatische Breite'}${value ? ` (${value})` : ''}`;
|
||||
const maximum = effectiveStyle(current, device, 'maxWidth').value;
|
||||
if (maximum && maximum !== 'none') state.textContent += `. Maximale Breite: ${maximum} (Design → Größe).`;
|
||||
};
|
||||
const apply = (value, keepInput = false) => {
|
||||
error.hidden = true; exact.removeAttribute('aria-invalid');
|
||||
history.change(doc => setStyle(doc, block.id, device, 'width', value), `image-width:${block.id}:${device}`);
|
||||
controls(); schedulePreview(); sync(keepInput);
|
||||
};
|
||||
slider.addEventListener('input', () => apply(`${slider.value}%`));
|
||||
exact.addEventListener('input', () => {
|
||||
if (exact.validity.badInput || (exact.value !== '' && !exact.checkValidity())) { error.hidden = false; exact.setAttribute('aria-invalid', 'true'); return; }
|
||||
apply(exact.value === '' ? '' : `${Number(exact.value)}%`, true);
|
||||
});
|
||||
// A whole drag or keyboard edit is one undo step, independent of preview requests.
|
||||
slider.addEventListener('change', () => { history.mergeKey = null; });
|
||||
exact.addEventListener('blur', () => { history.mergeKey = null; if (error.hidden) sync(); });
|
||||
reset.addEventListener('click', () => { history.mergeKey = null; apply(''); history.mergeKey = null; });
|
||||
row.append(slider, exact); group.append(label, row, state, help, error, reset); panel.append(group); sync();
|
||||
}
|
||||
|
||||
function renderDesign(block, panel) {
|
||||
if (designSchema && designPanel) {
|
||||
designPanel(panel);
|
||||
@@ -432,7 +540,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 +553,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 +653,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 +698,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 +716,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 +743,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 +792,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 +866,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 +985,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 +1126,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 +1165,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% }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { setupPicker } from './page-picker.js';
|
||||
|
||||
const form = document.querySelector('[data-live-search]');
|
||||
if (form) {
|
||||
const results = document.querySelector('#list-results');
|
||||
const status = document.querySelector('#search-status');
|
||||
const error = document.querySelector('#search-error');
|
||||
const search = form.querySelector('input[type="search"]');
|
||||
const fields = [...form.querySelectorAll('input[name],select[name]')];
|
||||
let timer, controller, sequence = 0, composing = false;
|
||||
|
||||
const cancel = () => { clearTimeout(timer); controller?.abort(); sequence++; results.removeAttribute('aria-busy'); };
|
||||
const updateReset = () => {
|
||||
form.querySelector('[data-search-reset]').hidden = !fields.some(field => field.value !== (field.dataset.searchDefault || ''));
|
||||
};
|
||||
const urlFor = parameters => {
|
||||
const url = new URL(form.action);
|
||||
for (const [name, value] of Object.entries(parameters)) {
|
||||
const fallback = fields.find(field => field.name === name)?.dataset.searchDefault || (name === 'seite' ? '1' : '');
|
||||
if (String(value) !== fallback) url.searchParams.set(name, String(value));
|
||||
}
|
||||
return url;
|
||||
};
|
||||
const currentUrl = () => urlFor({ ...Object.fromEntries(new FormData(form)), q: search.value.trim(), seite: 1 });
|
||||
const restoreFilters = url => {
|
||||
for (const field of fields) field.value = url.searchParams.get(field.name) ?? field.dataset.searchDefault ?? '';
|
||||
updateReset();
|
||||
};
|
||||
|
||||
async function load(url, navigation = 'replace', focusResults = false) {
|
||||
cancel(); const request = sequence;
|
||||
controller = new AbortController(); const signal = controller.signal;
|
||||
results.setAttribute('aria-busy', 'true'); error.hidden = true;
|
||||
status.textContent = 'Ergebnisse werden gesucht …';
|
||||
updateReset();
|
||||
try {
|
||||
const response = await fetch(url, { signal, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' } });
|
||||
if (response.redirected || response.status === 401 || response.status === 403) throw new Error('Deine Sitzung ist abgelaufen. Bitte erneut anmelden und die Suche wiederholen.');
|
||||
if (!response.ok) throw new Error('Die Suche konnte nicht geladen werden. Die bisherigen Ergebnisse bleiben sichtbar.');
|
||||
const data = await response.json();
|
||||
if (signal.aborted || request !== sequence) return;
|
||||
if (typeof data.html !== 'string' || !data.parameters || !Number.isInteger(data.total)) throw new Error('Die Suchantwort konnte nicht verarbeitet werden.');
|
||||
const parsed = new DOMParser().parseFromString(data.html, 'text/html');
|
||||
results.replaceChildren(...parsed.body.childNodes);
|
||||
results.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
// Keep create/upload forms outside the replaced list on the same filtered screen.
|
||||
const context = { _list_query: data.parameters.q, _list_status: data.parameters.status, _list_limit: data.parameters.limit, _list_page: data.parameters.seite, q: data.parameters.q, archiv: data.parameters.archiv, seite: data.parameters.seite };
|
||||
for (const [name, value] of Object.entries(context)) if (value !== undefined) {
|
||||
document.querySelectorAll(`form:not([data-live-search]) input[type="hidden"][name="${name}"]`).forEach(input => { input.value = value; });
|
||||
}
|
||||
const canonical = urlFor(data.parameters);
|
||||
if (navigation !== 'none' && canonical.href !== location.href) history[navigation === 'push' ? 'pushState' : 'replaceState'](null, '', canonical);
|
||||
status.textContent = `${data.total} Treffer${data.parameters.q ? ` für „${data.parameters.q}“` : ''}.`;
|
||||
if (focusResults) { results.focus({ preventScroll: true }); results.scrollIntoView({ block: 'start' }); }
|
||||
} catch (failure) {
|
||||
if (signal.aborted || request !== sequence) return;
|
||||
error.querySelector('[role="alert"]').textContent = failure instanceof TypeError ? 'Verbindung unterbrochen. Die bisherigen Ergebnisse bleiben sichtbar.' : failure.message;
|
||||
error.hidden = false; status.textContent = 'Suche fehlgeschlagen.';
|
||||
} finally {
|
||||
if (request === sequence) results.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
cancel(); updateReset();
|
||||
if (composing) return;
|
||||
error.hidden = true; status.textContent = 'Ergebnisse werden gesucht …'; results.setAttribute('aria-busy', 'true');
|
||||
timer = setTimeout(() => load(currentUrl()), search.value ? 220 : 0);
|
||||
};
|
||||
search.addEventListener('compositionstart', () => { composing = true; cancel(); });
|
||||
search.addEventListener('compositionend', () => { composing = false; queue(); });
|
||||
search.addEventListener('input', queue);
|
||||
form.addEventListener('change', event => { if (event.target.matches('select')) load(currentUrl(), 'push'); });
|
||||
form.addEventListener('submit', event => { event.preventDefault(); if (!composing) load(currentUrl()); });
|
||||
form.querySelector('[data-search-reset]').addEventListener('click', event => {
|
||||
event.preventDefault(); const url = new URL(form.action); restoreFilters(url); load(url, 'push'); search.focus();
|
||||
});
|
||||
results.addEventListener('click', event => {
|
||||
const link = event.target.closest('.pager a');
|
||||
if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault(); load(new URL(link.href), 'push', true);
|
||||
});
|
||||
// A delayed response must not overwrite a form the user has just started editing.
|
||||
results.addEventListener('input', () => {
|
||||
if (results.getAttribute('aria-busy') === 'true') { cancel(); status.textContent = 'Suche angehalten, während du einen Eintrag bearbeitest.'; }
|
||||
});
|
||||
window.addEventListener('popstate', () => { const url = new URL(location.href); restoreFilters(url); load(url, 'none'); });
|
||||
window.addEventListener('pagehide', cancel);
|
||||
document.querySelector('#search-retry').addEventListener('click', () => load(currentUrl()));
|
||||
form.classList.add('live-search-ready');
|
||||
status.textContent = 'Die Suche aktualisiert sich beim Tippen.';
|
||||
search.setAttribute('aria-controls', results.id);
|
||||
updateReset();
|
||||
}
|
||||
+42
-1
@@ -1,5 +1,45 @@
|
||||
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
|
||||
|
||||
const previews = new Map();
|
||||
async function previewItem(id) {
|
||||
if (!previews.has(id)) previews.set(id, fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media/' + id, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('Bild nicht erreichbar');
|
||||
return (await response.json()).item;
|
||||
}).catch(error => { previews.delete(id); throw error; }));
|
||||
return previews.get(id);
|
||||
}
|
||||
|
||||
/** A preview of the current selection, including small originals without a 480 px variant. */
|
||||
export function createMediaPreview(initial = {}) {
|
||||
const wrap = element('figure', '', 'selected-media-preview');
|
||||
const image = element('img'); image.alt = ''; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; image.hidden = true;
|
||||
const caption = element('figcaption'); caption.setAttribute('aria-live', 'polite');
|
||||
wrap.append(image, caption); let sequence = 0;
|
||||
const update = async ({ src = '', mediaId = '', label = '' } = {}) => {
|
||||
const request = ++sequence; image.hidden = true; image.removeAttribute('src');
|
||||
caption.textContent = src || mediaId ? 'Bildvorschau wird geladen …' : 'Kein Bild ausgewählt';
|
||||
if (!src && !mediaId) return;
|
||||
try {
|
||||
if (!src && /^[a-f0-9]{32}$/.test(mediaId)) {
|
||||
const item = await previewItem(mediaId);
|
||||
if (request !== sequence) return;
|
||||
src = item.variants[0]?.src || ''; label ||= item.title;
|
||||
}
|
||||
const url = new URL(src, location.origin);
|
||||
if (!(src.startsWith('/') && !src.startsWith('//') && url.origin === location.origin) && url.protocol !== 'https:') throw new Error();
|
||||
if (!src) throw new Error();
|
||||
const description = label || decodeURIComponent(url.pathname.split('/').pop()) || 'Ausgewähltes Bild';
|
||||
image.onload = () => { if (request === sequence) { image.hidden = false; caption.textContent = description; } };
|
||||
image.onerror = () => { if (request === sequence) { image.hidden = true; caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.'; } };
|
||||
image.src = url.href;
|
||||
} catch {
|
||||
if (request === sequence) caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.';
|
||||
}
|
||||
};
|
||||
update(initial);
|
||||
return { element: wrap, update };
|
||||
}
|
||||
|
||||
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
||||
@@ -20,10 +60,11 @@ 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}).`);
|
||||
for (const item of data.items || (data.item ? [data.item] : [])) previews.set(item.id, Promise.resolve(item));
|
||||
return data;
|
||||
}
|
||||
function render() {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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.type === 'checkbox' ? (input.checked ? '1' : '0') : 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', type === 'checkbox' ? 'check-label' : '', label); caption.htmlFor = input.id;
|
||||
if (type === 'checkbox') { caption.prepend(input); body.append(caption); } else 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 newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
|
||||
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; }
|
||||
const newTab = form.querySelector('#link-new-tab');
|
||||
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), newTab: newTab.checked, level: 1 }); name.value = url.value = ''; newTab.checked = false;
|
||||
});
|
||||
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}}
|
||||
@@ -83,7 +83,10 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
linkInput.type = 'text';
|
||||
linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…';
|
||||
linkInput.setAttribute('aria-label', 'Linkziel');
|
||||
let savedRange = null;
|
||||
const newTabLabel = document.createElement('label'); newTabLabel.className = 'check-label';
|
||||
const newTab = document.createElement('input'); newTab.type = 'checkbox';
|
||||
newTabLabel.append(newTab, document.createTextNode(' In neuem Tab öffnen'));
|
||||
let savedRange = null, currentLink = null;
|
||||
const confirmLink = () => {
|
||||
const target = linkInput.value.trim();
|
||||
linkRow.hidden = true;
|
||||
@@ -93,8 +96,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(savedRange);
|
||||
}
|
||||
if (target) document.execCommand('createLink', false, target);
|
||||
else document.execCommand('unlink');
|
||||
if (target) {
|
||||
if (currentLink && surface.contains(currentLink)) currentLink.setAttribute('href', target);
|
||||
else document.execCommand('createLink', false, target);
|
||||
const range = window.getSelection().rangeCount ? window.getSelection().getRangeAt(0) : null;
|
||||
for (const anchor of surface.querySelectorAll('a')) {
|
||||
if (anchor !== currentLink && !(anchor.getAttribute('href') === target && range?.intersectsNode(anchor))) continue;
|
||||
if (newTab.checked) { anchor.setAttribute('target', '_blank'); anchor.setAttribute('rel', 'noopener'); }
|
||||
else { anchor.removeAttribute('target'); anchor.removeAttribute('rel'); }
|
||||
}
|
||||
} else document.execCommand('unlink');
|
||||
push(surface.innerHTML);
|
||||
linkInput.value = '';
|
||||
};
|
||||
@@ -102,14 +113,20 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
||||
});
|
||||
linkRow.append(linkInput, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
||||
savedRange = selection.rangeCount > 0 && surface.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0).cloneRange() : null;
|
||||
const node = savedRange?.startContainer;
|
||||
currentLink = (node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement)?.closest('a');
|
||||
if (currentLink && (!surface.contains(currentLink) || !currentLink.contains(savedRange.endContainer))) currentLink = null;
|
||||
linkInput.value = currentLink?.getAttribute('href') || '';
|
||||
newTab.checked = currentLink?.getAttribute('target') === '_blank';
|
||||
linkRow.hidden = false;
|
||||
linkInput.focus();
|
||||
}));
|
||||
linkRow.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); linkRow.hidden = true; surface.focus(); } });
|
||||
|
||||
const toggle = button('HTML', 'Zwischen Formatierung und HTML wechseln', () => {
|
||||
const toSource = source.hidden;
|
||||
|
||||
@@ -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. */
|
||||
@@ -162,12 +162,6 @@ body.consent-open{padding-bottom:260px}
|
||||
.site-footer .block-column{border:0;border-radius:0;padding:0}
|
||||
.site-header .block-text,
|
||||
.site-footer .block-text{margin:0}
|
||||
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
|
||||
.legal-bar a{color:var(--accent);text-decoration:none}
|
||||
.legal-bar a:hover{text-decoration:underline}
|
||||
|
||||
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
|
||||
figure and left the picture square - which looked like the setting did nothing. */
|
||||
.block-image img, .block-gallery img, .block-logos img { border-radius: inherit }
|
||||
@@ -197,7 +191,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,234 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8' }));
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory } });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], {
|
||||
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
|
||||
});
|
||||
await mkdir('reports/admin-quality', { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [];
|
||||
try {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (i === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'no-preference' }); page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
const errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
if (process.argv.includes('--visual-only')) {
|
||||
await page.goto(origin);
|
||||
await page.evaluate(async () => {
|
||||
const images = [...document.images]; images.forEach(image => { image.loading = 'eager'; });
|
||||
await Promise.all(images.map(image => image.decode().catch(() => {}))); await document.fonts.ready;
|
||||
});
|
||||
await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true, animations: 'disabled' });
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1440, height: 1000 } });
|
||||
const noScript = await plain.newPage(); await noScript.goto(origin);
|
||||
assert.equal(await noScript.locator('h1').isVisible(), true);
|
||||
assert.equal(await noScript.locator('#hero-slider .slider-slide').count(), 4);
|
||||
assert.ok(await noScript.locator('main').innerText().then(text => text.includes('Dienstleistungen') || text.includes('Full-Service')));
|
||||
await noScript.screenshot({ path: 'reports/admin-quality/pilot-without-javascript.png', fullPage: true });
|
||||
await plain.close();
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('.field-table tbody tr').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
console.log('Public no-JavaScript baseline and visual captures passed.');
|
||||
} else {
|
||||
if (!process.argv.includes('--admin-only')) {
|
||||
await page.goto(origin); await page.mouse.move(1, 1);
|
||||
const slider = page.locator('#hero-slider');
|
||||
const geometry = () => slider.evaluate(el => ({ left: el.scrollLeft, width: el.clientWidth, total: el.scrollWidth, slides: el.children.length, columns: getComputedStyle(el).gridAutoColumns }));
|
||||
const before = await geometry(); await page.waitForTimeout(8000); const after = await geometry();
|
||||
console.log('Pilot slider:', JSON.stringify({ before, after }));
|
||||
if (process.argv.includes('--probe')) {
|
||||
console.log('Motion:', await page.locator('[class*="fx-entrance-"]').evaluateAll(nodes => nodes.map(el => ({ id: el.id, name: getComputedStyle(el).animationName, duration: getComputedStyle(el).animationDuration, timeline: getComputedStyle(el).animationTimeline }))));
|
||||
} else {
|
||||
assert.ok(after.left > before.left + 20, 'the real pilot slider advances');
|
||||
}
|
||||
await page.waitForTimeout(7100); const second = await geometry();
|
||||
console.log('Second slider:', JSON.stringify(second), await slider.locator('xpath=following-sibling::*[1]').innerText());
|
||||
assert.ok(second.left > after.left + 20, 'autoplay advances more than once');
|
||||
const controls = slider.locator('xpath=following-sibling::*[1]');
|
||||
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
|
||||
await page.waitForTimeout(700); const manual = await geometry();
|
||||
console.log('Manual slider:', JSON.stringify(manual), await controls.innerText());
|
||||
assert.ok(manual.left < second.left - 20);
|
||||
await page.mouse.move(1, 1); await page.waitForTimeout(7200);
|
||||
console.log('Paused slider:', JSON.stringify(await geometry()), await controls.innerText());
|
||||
assert.ok(Math.abs((await geometry()).left - manual.left) < 2, 'manual navigation pauses autoplay');
|
||||
await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).click();
|
||||
await page.mouse.move(1, 1); await page.waitForTimeout(7700);
|
||||
assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
assert.equal(await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).isDisabled(), true);
|
||||
const reduced = await geometry(); await page.waitForTimeout(7100);
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
|
||||
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'manual navigation works with reduced motion');
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
checks.push({ name: 'pilot slider: repeated autoplay, manual pause, resume, reduced motion', passed: true });
|
||||
await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true });
|
||||
}
|
||||
if (process.argv.includes('--audit')) {
|
||||
execFileSync(process.execPath, ['scripts/audit.mjs'], { stdio: 'inherit', windowsHide: true, env: { ...process.env, CMS_URL: origin, CMS_AUDIT_DIR: 'reports/admin-quality/lighthouse' } });
|
||||
}
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
for (const [name, path] of [['pages', '/admin'], ['posts', '/admin/posts'], ['menus', '/admin/menus'], ['forms', '/admin/forms'], ['areas', '/admin/partials']]) {
|
||||
const response = await page.goto(origin + path); assert.equal(response.status(), 200, path);
|
||||
await page.screenshot({ path: `reports/admin-quality/${name}.png`, fullPage: true });
|
||||
const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
|
||||
checks.push({ name, passed: true });
|
||||
}
|
||||
await page.goto(origin + '/admin/posts');
|
||||
const rowLinks = () => page.locator('.rows article h3 a');
|
||||
assert.equal(await rowLinks().count(), 20);
|
||||
const firstIds = await rowLinks().evaluateAll(links => links.map(link => link.href));
|
||||
await page.getByRole('navigation', { name: 'Ergebnisseiten oben', exact: true }).getByRole('link', { name: 'Weiter' }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 9);
|
||||
assert.equal(await rowLinks().count(), 9);
|
||||
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
|
||||
assert.equal((await rowLinks().evaluateAll(links => links.map(link => link.href))).some(id => firstIds.includes(id)), false);
|
||||
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 29);
|
||||
assert.equal(await rowLinks().count(), 29);
|
||||
await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 0);
|
||||
assert.equal(await rowLinks().count(), 0);
|
||||
await page.goto(origin + '/admin/posts?seite=999'); assert.equal(await rowLinks().count(), 9);
|
||||
await page.goto(origin + '/admin'); assert.equal(await rowLinks().count(), 4);
|
||||
checks.push({ name: 'separate pages/posts, bounded pages, no overlap, size/search/clamping', passed: true });
|
||||
|
||||
await page.goto(origin + '/admin/menus');
|
||||
const entries = page.locator('#menu-items > li'); const initialCount = await entries.count();
|
||||
const search = page.getByRole('combobox', { name: 'Inhalte suchen' });
|
||||
await search.fill('Impressum');
|
||||
await page.getByRole('option').filter({ hasText: 'Impressum' }).first().waitFor();
|
||||
await search.press('ArrowDown'); await search.press('Enter');
|
||||
await page.getByRole('button', { name: 'Zum Menü hinzufügen', exact: true }).click();
|
||||
assert.equal(await entries.count(), initialCount + 1);
|
||||
await entries.last().getByRole('button', { name: 'Als Unterpunkt', exact: true }).click();
|
||||
assert.equal(await entries.last().getAttribute('data-level'), '2');
|
||||
await entries.last().getByRole('button', { name: 'Entfernen', exact: true }).click();
|
||||
assert.equal(await entries.count(), initialCount);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
assert.equal(await entries.count(), initialCount + 1);
|
||||
assert.equal(await entries.last().getAttribute('data-level'), '2');
|
||||
const menuSave = () => Promise.all([
|
||||
page.waitForResponse(response => response.url().includes('/menus/header') && response.request().method() === 'POST'),
|
||||
page.getByRole('button', { name: 'Menü speichern', exact: true }).click(),
|
||||
]).then(([response]) => response);
|
||||
assert.equal((await menuSave()).status(), 200);
|
||||
await page.reload(); assert.equal(await entries.count(), initialCount + 1);
|
||||
assert.equal(await entries.last().getAttribute('data-level'), '2');
|
||||
await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click();
|
||||
await page.locator('#link-label').fill('QA Kontakt'); await page.locator('#link-url').fill('javascript:alert(1)');
|
||||
await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click();
|
||||
assert.equal((await menuSave()).status(), 422);
|
||||
assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)');
|
||||
assert.equal(await page.locator('#menu-error').isVisible(), true);
|
||||
await entries.last().locator('summary').click();
|
||||
await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt');
|
||||
assert.equal((await menuSave()).status(), 200);
|
||||
const externalSave = await page.locator('#menu-editor').evaluate(async form => {
|
||||
const response = await fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } });
|
||||
return response.status;
|
||||
});
|
||||
assert.equal(externalSave, 200);
|
||||
await page.getByLabel('Name des Menüs').fill('QA lokaler Stand');
|
||||
assert.equal((await menuSave()).status(), 409);
|
||||
assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand');
|
||||
assert.equal(await page.locator('#menu-error').isVisible(), true);
|
||||
checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true });
|
||||
|
||||
await page.goto(origin + '/admin/forms');
|
||||
const form = page.locator('.form-def').first(), message = form.locator('[name="success"]');
|
||||
const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth }));
|
||||
assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width');
|
||||
await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
|
||||
await form.getByRole('button', { name: 'Formular speichern', exact: true }).click();
|
||||
assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
|
||||
checks.push({ name: 'wide multiline form response persists', passed: true });
|
||||
|
||||
await page.goto(origin + '/admin/partials');
|
||||
await page.getByText('Neuen Bereich anlegen', { exact: true }).click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('QA Gemeinsame Sektion');
|
||||
await page.getByRole('button', { name: 'Anlegen und bearbeiten', exact: true }).click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.locator('#structure .tree-item').first().click();
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await page.getByLabel('Text', { exact: true }).fill('Dieser Inhalt wird als gemeinsame Sektion gespeichert.');
|
||||
await page.getByLabel('Text', { exact: true }).press('Tab');
|
||||
await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor();
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor();
|
||||
await page.reload();
|
||||
await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor();
|
||||
await page.goto(origin + '/admin/partials');
|
||||
const areaLinks = await rowLinks().evaluateAll(links => links.map(link => link.href));
|
||||
for (const url of areaLinks) {
|
||||
assert.equal((await page.goto(url)).status(), 200);
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.locator('#select-page').click();
|
||||
const title = page.getByLabel('Seitentitel', { exact: true });
|
||||
await title.fill((await title.inputValue()) + ' QA'); await title.press('Tab');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor();
|
||||
await page.reload(); await page.locator('#select-page').click();
|
||||
await page.waitForFunction(() => document.querySelector('#save-status')?.textContent !== 'Wird geladen …');
|
||||
assert.match(await title.inputValue(), / QA$/);
|
||||
}
|
||||
checks.push({ name: 'real pilot areas open, edit, preview, save and reload', passed: true });
|
||||
|
||||
await page.goto(origin + '/admin/pages/' + fixture.homeId);
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
|
||||
const preview = page.frameLocator('#preview'), previewSlider = preview.locator('#hero-slider');
|
||||
// Measure in the outer page: Playwright's in-frame animation stability check can stall in
|
||||
// a no-script sandbox while a Web Animation runs. Use a real pointer on verified geometry.
|
||||
const nextPreview = preview.getByRole('button', { name: 'Nächste Folie', exact: true });
|
||||
await nextPreview.waitFor(); await page.waitForTimeout(1500);
|
||||
const previewBox = await nextPreview.boundingBox(); await page.waitForTimeout(150);
|
||||
const settledBox = await nextPreview.boundingBox();
|
||||
assert.ok(Math.abs(settledBox.y - previewBox.y) < 1 && settledBox.width > 0, 'preview control has stable visible geometry');
|
||||
await page.mouse.click(settledBox.x + settledBox.width / 2, settledBox.y + settledBox.height / 2);
|
||||
await page.waitForTimeout(800);
|
||||
assert.ok(await previewSlider.evaluate(slider => slider.scrollLeft > 20));
|
||||
assert.equal(await page.locator('#preview').getAttribute('sandbox'), 'allow-same-origin');
|
||||
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
|
||||
assert.equal(await preview.locator('.slider-controls').count(), 0);
|
||||
checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true });
|
||||
|
||||
for (const width of [1024, 390]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
for (const route of ['/admin/posts', '/admin/menus', '/admin/forms']) {
|
||||
await page.goto(origin + route);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`);
|
||||
const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], `${route} ${width}`);
|
||||
await page.screenshot({ path: `reports/admin-quality/${route.split('/').pop()}-${width}.png` });
|
||||
}
|
||||
}
|
||||
checks.push({ name: 'tablet/mobile layouts fit and pass axe', passed: true });
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile('reports/admin-quality/result.json', JSON.stringify({ checks }, null, 2));
|
||||
console.log('Pilot and administration checks passed.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: 'reports/admin-quality/failure.png', fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/image-size'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||
const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800);
|
||||
assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title;
|
||||
data.document.blocks.unshift({ id: 'qa-image-size-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px' } }, children: [{ id: 'qa-image-size', type: 'image', props: original }] });
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const select = async () => { await page.locator('#structure [data-block-id="qa-image-size"]').click(); await page.locator('[data-panel="content"]').click(); };
|
||||
await select();
|
||||
const exact = () => page.getByLabel('Bildbreite (%)', { exact: true });
|
||||
const slider = () => page.getByRole('slider', { name: 'Bildbreite Schieberegler' });
|
||||
const width = async percent => page.waitForFunction(percent => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument;
|
||||
const figure = doc?.getElementById('qa-image-size'); if (!figure) return false;
|
||||
const parent = figure.parentElement; const style = doc.defaultView.getComputedStyle(parent);
|
||||
const available = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
return Math.abs(figure.getBoundingClientRect().width / available * 100 - percent) < .1;
|
||||
}, percent);
|
||||
const saved = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); };
|
||||
const changeDevice = async device => page.locator(`[data-viewport="${device}"]`).click();
|
||||
assert.equal(await exact().inputValue(), '');
|
||||
await exact().fill('50'); await width(50);
|
||||
assert.equal(await exact().evaluate(input => input === document.activeElement), true);
|
||||
await exact().blur();
|
||||
await slider().focus(); await slider().press('ArrowRight'); await width(50.1);
|
||||
await page.locator('#undo').click(); await width(50); await page.locator('#redo').click(); await width(50.1);
|
||||
const box = await slider().boundingBox();
|
||||
await page.mouse.move(box.x + box.width * .2, box.y + box.height / 2); await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * .6, box.y + box.height / 2, { steps: 8 }); await page.mouse.up();
|
||||
const dragged = Number(await exact().inputValue()); assert.ok(dragged > 50.1); await width(dragged);
|
||||
await page.locator('#undo').click(); await width(50.1);
|
||||
await page.locator('#redo').click(); await width(dragged);
|
||||
await exact().fill('0'); assert.equal(await exact().getAttribute('aria-invalid'), 'true'); await width(dragged);
|
||||
await exact().fill('50'); await width(50); await exact().blur();
|
||||
checks.push('number, keyboard and drag update preview; one undo per gesture; invalid values do not change the document');
|
||||
|
||||
await changeDevice('tablet'); assert.equal(await exact().inputValue(), '50');
|
||||
assert.match(await page.locator('#image-width-state').innerText(), /Von Desktop übernommen/);
|
||||
await exact().fill('75'); await width(75); await exact().blur();
|
||||
await changeDevice('mobile'); assert.equal(await exact().inputValue(), '75');
|
||||
await exact().fill('30'); await width(30); await exact().blur();
|
||||
await page.locator('#image-width-reset').click(); await width(75);
|
||||
assert.match(await page.locator('#image-width-state').innerText(), /Von Tablet übernommen/);
|
||||
await page.locator('#undo').click(); await width(30);
|
||||
await changeDevice('desktop'); assert.equal(await exact().inputValue(), '50'); await width(50);
|
||||
await saved(); await page.reload(); await select(); assert.equal(await exact().inputValue(), '50'); await width(50);
|
||||
const stored = await (await context.request.get(api)).json();
|
||||
const image = stored.document.blocks[0].children[0];
|
||||
assert.deepEqual(image.style, { desktop: { width: '50%' }, tablet: { width: '75%' }, mobile: { width: '30%' } });
|
||||
assert.deepEqual(image.props, original, 'original dimensions, media variants, caption and alt text are unchanged');
|
||||
checks.push('device inheritance, reset, undo, save/reload and unchanged image metadata');
|
||||
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
const size = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Größe$/ }) });
|
||||
if (!(await size.getAttribute('open'))) await size.locator('summary').click();
|
||||
assert.equal(await page.locator('#style-width').inputValue(), '50');
|
||||
await page.locator('#style-width').fill('60'); await page.locator('#style-width').blur();
|
||||
await page.locator('[data-panel="content"]').click(); assert.equal(await exact().inputValue(), '60'); await width(60);
|
||||
await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true });
|
||||
for (const viewport of [1600, 390]) {
|
||||
await page.setViewportSize({ width: viewport, height: 1050 });
|
||||
await exact().scrollIntoViewIfNeeded();
|
||||
const results = await new AxeBuilder({ page }).include('.image-width-control').analyze();
|
||||
assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.locator('.image-width-control').evaluate(el => el.scrollWidth <= el.clientWidth), true);
|
||||
await page.locator('.image-width-control').screenshot({ path: `${report}/control-${viewport}.png` });
|
||||
}
|
||||
await page.setViewportSize({ width: 1600, height: 1050 }); await saved();
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
for (const [viewport, percent] of [[1600, 60], [900, 75], [390, 30]]) {
|
||||
await publicPage.setViewportSize({ width: viewport, height: 900 }); await publicPage.goto(origin);
|
||||
const geometry = await publicPage.locator('#qa-image-size').evaluate(async figure => {
|
||||
const img = figure.querySelector('img'); await img.decode();
|
||||
const parent = figure.parentElement, style = getComputedStyle(parent);
|
||||
return { figure: figure.getBoundingClientRect().width, available: parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), width: img.getBoundingClientRect().width, height: img.getBoundingClientRect().height };
|
||||
});
|
||||
assert.ok(Math.abs(geometry.figure / geometry.available * 100 - percent) < .1);
|
||||
assert.ok(Math.abs(geometry.width / geometry.height - original.width / original.height) < .02, 'image aspect ratio is preserved');
|
||||
await publicPage.locator('#qa-image-size-section').screenshot({ path: `${report}/published-${viewport}.png` });
|
||||
}
|
||||
await plain.close(); checks.push('shared Design width; desktop/mobile accessible controls; published responsive percentages and aspect ratio without JavaScript');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Image size checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,212 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/link-media'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||
const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800);
|
||||
assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title;
|
||||
data.document.blocks.unshift({ id: 'qa-link-section', type: 'section', children: [
|
||||
{ id: 'qa-link-image', type: 'image', props: { ...original, href: '/impressum', click: 'link' } },
|
||||
{ id: 'qa-empty-image', type: 'image', props: { ...original, click: 'keine' } },
|
||||
{ id: 'qa-link-button', type: 'button', props: { text: 'QA Neuer Tab', href: '/impressum' } },
|
||||
{ id: 'qa-link-text', type: 'text', props: { text: '<p>Textlink prüfen</p>' } },
|
||||
{ id: 'qa-link-gallery', type: 'gallery', children: [{ id: 'qa-gallery-image', type: 'image', props: original }] },
|
||||
] });
|
||||
data.document.blocks.unshift({ id: 'qa-background', type: 'section', style: { desktop: { backgroundImage: chosen.id, minHeight: '200px' } }, children: [] });
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const frame = page.frameLocator('#preview');
|
||||
const select = async (id, panel = 'content') => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`[data-panel="${panel}"]`).click(); };
|
||||
const thumbnail = () => page.locator('#properties > .selected-media-preview img');
|
||||
const background = async hasImage => page.waitForFunction(hasImage => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, block = doc?.getElementById('qa-background');
|
||||
return block && doc.defaultView.getComputedStyle(block).backgroundImage.includes('url(') === hasImage;
|
||||
}, hasImage);
|
||||
const save = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); };
|
||||
await select('qa-empty-image');
|
||||
const clickAction = () => page.getByLabel('Beim Klick', { exact: true });
|
||||
await page.locator('#property-href').waitFor({ state: 'hidden' });
|
||||
const nativeSelect = await clickAction().elementHandle();
|
||||
await clickAction().click(); await page.keyboard.press('End'); await page.keyboard.press('Enter');
|
||||
await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
assert.equal(await nativeSelect.evaluate(el => el.isConnected), true, 'Keep the native dropdown mounted while changing the action');
|
||||
await nativeSelect.dispose();
|
||||
await page.getByLabel('Linkziel', { exact: true }).fill('/kontakt');
|
||||
await clickAction().focus(); await page.keyboard.press('Home');
|
||||
await page.locator('#property-href').waitFor({ state: 'hidden' });
|
||||
await page.keyboard.press('End'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
|
||||
await clickAction().selectOption('keine');
|
||||
await clickAction().evaluate(el => { el.value = 'link'; el.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
|
||||
await page.locator('#undo').click();
|
||||
assert.equal(await clickAction().inputValue(), 'keine'); await page.locator('#property-href').waitFor({ state: 'hidden' });
|
||||
await page.locator('#redo').click();
|
||||
assert.equal(await clickAction().inputValue(), 'link'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
await clickAction().selectOption('vergroessern');
|
||||
await page.locator('#property-href').waitFor({ state: 'hidden' }); await page.locator('#property-newTab').waitFor({ state: 'hidden' });
|
||||
await frame.locator('#qa-empty-image a.image-zoom').waitFor();
|
||||
await clickAction().selectOption('link'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
await page.locator('.image-link-options').screenshot({ path: `${report}/image-link-fields.png` });
|
||||
checks.push('fresh image URL appears after native dropdown and keyboard selection; URL retained; change-only event supported');
|
||||
await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' });
|
||||
assert.ok(await thumbnail().evaluate(img => img.naturalWidth > 0));
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await frame.locator('#qa-link-image a[target="_blank"][rel="noopener"]').waitFor();
|
||||
await page.getByLabel('Beim Klick', { exact: true }).selectOption('keine');
|
||||
await frame.locator('#qa-link-image a').waitFor({ state: 'detached' });
|
||||
await page.getByLabel('Beim Klick', { exact: true }).focus();
|
||||
await page.getByLabel('Beim Klick', { exact: true }).selectOption('link');
|
||||
assert.equal(await page.getByLabel('Beim Klick', { exact: true }).evaluate(el => el === document.activeElement), true);
|
||||
await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
await page.getByLabel('Bildadresse', { exact: true }).fill('/nicht-vorhanden.png');
|
||||
await page.getByLabel('Bildadresse', { exact: true }).blur();
|
||||
await page.locator('#properties > .selected-media-preview').getByText(/nicht verfügbar/).waitFor();
|
||||
await page.locator('#undo').click(); await thumbnail().waitFor({ state: 'visible' });
|
||||
await page.getByRole('button', { name: 'Bild entfernen', exact: true }).click();
|
||||
await frame.locator('#qa-link-image').waitFor({ state: 'detached' });
|
||||
assert.equal((await context.request.get(origin + '/admin/api/media/' + chosen.id)).status(), 200);
|
||||
await page.locator('#undo').click(); await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' });
|
||||
await page.locator('.right-panel').screenshot({ path: `${report}/image-inspector.png` });
|
||||
await select('qa-link-gallery'); await page.locator('.selected-media-list img').waitFor({ state: 'visible' });
|
||||
await page.locator('.selected-media-list').getByRole('button', { name: /entfernen$/ }).click();
|
||||
await frame.locator('#qa-gallery-image').waitFor({ state: 'detached' });
|
||||
await page.locator('#undo').click(); await frame.locator('#qa-gallery-image').waitFor();
|
||||
checks.push('image and gallery thumbnails, missing-image feedback, remove/undo without deleting media; image link/no-action toggle');
|
||||
|
||||
await select('qa-background', 'design');
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Hintergrund$/ }) });
|
||||
if (!(await group.getAttribute('open'))) await group.locator('summary').click();
|
||||
await group.locator('.selected-media-preview img').waitFor({ state: 'visible' });
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await group.locator('.selected-media-preview img').waitFor({ state: 'visible' }); await background(true);
|
||||
await group.getByRole('button', { name: 'Bild entfernen', exact: true }).click(); await background(false);
|
||||
assert.equal(await page.locator('#style-backgroundImage').evaluate(el => el === document.activeElement), true);
|
||||
await page.locator('[data-viewport="mobile"]').click(); await background(false);
|
||||
await page.locator('[data-viewport="desktop"]').click(); await background(true);
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await page.getByRole('button', { name: 'Hintergrundbild zurücksetzen', exact: true }).click(); await background(true);
|
||||
await page.locator('#undo').click(); await background(false);
|
||||
await page.locator('[data-viewport="desktop"]').click();
|
||||
checks.push('inherited background thumbnail, remove on tablet/mobile, preserve desktop, reset and undo');
|
||||
|
||||
await select('qa-link-button'); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await frame.locator('#qa-link-button[target="_blank"]').waitFor();
|
||||
await select('qa-link-text');
|
||||
const surface = page.locator('.rt-surface');
|
||||
await surface.evaluate(el => { const range = document.createRange(); range.selectNodeContents(el.querySelector('p')); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.focus(); });
|
||||
await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click();
|
||||
await page.getByLabel('Linkziel', { exact: true }).fill('/impressum');
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await frame.locator('#qa-link-text a[target="_blank"][rel="noopener"]').waitFor();
|
||||
const editExisting = async () => {
|
||||
await surface.locator('a').evaluate(el => { const range = document.createRange(); range.selectNodeContents(el); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.closest('[contenteditable]').focus(); });
|
||||
await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click();
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/impressum');
|
||||
};
|
||||
await editExisting(); assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).uncheck(); await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await frame.locator('#qa-link-text a:not([target])').waitFor();
|
||||
await editExisting(); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await page.locator('.rt-link').screenshot({ path: `${report}/text-link.png` });
|
||||
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze(); assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await save(); await page.reload(); await select('qa-link-image');
|
||||
assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await thumbnail().waitFor({ state: 'visible' });
|
||||
await select('qa-empty-image');
|
||||
assert.equal(await clickAction().inputValue(), 'link');
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
checks.push('button and rich-text target creation/edit/uncheck, safe attributes, save/reload and publication');
|
||||
|
||||
await page.goto(origin + '/admin/menus');
|
||||
await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click();
|
||||
await page.locator('#link-label').fill('QA Menülink'); await page.locator('#link-url').fill('/impressum');
|
||||
await page.locator('#link-new-tab').check(); await page.locator('#menu-add-url').click();
|
||||
const entry = () => page.locator('#menu-items > li').last();
|
||||
await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await entry().getByLabel('In neuem Tab öffnen', { exact: true }).uncheck();
|
||||
await page.locator('#menu-undo').click();
|
||||
await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await page.getByRole('button', { name: 'Menü speichern', exact: true }).click();
|
||||
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
|
||||
await page.reload(); await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await entry().screenshot({ path: `${report}/menu-link.png` });
|
||||
checks.push('menu creation, checkbox undo, save and reload');
|
||||
|
||||
const publicPage = await context.newPage(); await publicPage.goto(origin);
|
||||
for (const selector of ['#qa-link-image a', '#qa-link-button', '#qa-link-text a']) {
|
||||
assert.equal(await publicPage.locator(selector).getAttribute('target'), '_blank');
|
||||
assert.equal(await publicPage.locator(selector).getAttribute('rel'), 'noopener');
|
||||
}
|
||||
assert.equal(await publicPage.getByRole('link', { name: 'QA Menülink', exact: true }).getAttribute('target'), '_blank');
|
||||
assert.equal(await publicPage.locator('#qa-empty-image a').getAttribute('href'), '/kontakt');
|
||||
const popupPromise = publicPage.waitForEvent('popup'); await publicPage.locator('#qa-link-button').click();
|
||||
const popup = await popupPromise; await popup.waitForLoadState();
|
||||
assert.equal(new URL(popup.url()).pathname, '/impressum'); assert.equal(await popup.evaluate(() => window.opener), null); await popup.close();
|
||||
assert.equal(new URL(publicPage.url()).pathname, '/');
|
||||
const publicAudit = await new AxeBuilder({ page: publicPage }).analyze();
|
||||
assert.deepEqual(publicAudit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
await publicPage.close();
|
||||
const noScriptContext = await browser.newContext({ javaScriptEnabled: false });
|
||||
const noScript = await noScriptContext.newPage();
|
||||
for (const [width, hasImage] of [[1600, true], [900, false], [390, false]]) {
|
||||
await noScript.setViewportSize({ width, height: 1000 }); await noScript.goto(origin);
|
||||
assert.equal(await noScript.locator('#qa-background').evaluate(el => getComputedStyle(el).backgroundImage.includes('url(')), hasImage);
|
||||
assert.equal(await noScript.locator('#qa-link-image a').getAttribute('target'), '_blank');
|
||||
}
|
||||
await noScriptContext.close();
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`); await select('qa-link-image');
|
||||
for (const viewport of [1600, 390]) {
|
||||
await page.setViewportSize({ width: viewport, height: 1050 }); await thumbnail().scrollIntoViewIfNeeded();
|
||||
const result = await new AxeBuilder({ page }).exclude('#preview').analyze();
|
||||
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.locator('.selected-media-preview').evaluate(el => el.scrollWidth <= el.clientWidth), true);
|
||||
await page.locator('#properties').screenshot({ path: `${report}/image-properties-${viewport}.png` });
|
||||
}
|
||||
checks.push('public links really open a separate tab without opener; public page and desktop/mobile image controls pass axe; background removal persists without JavaScript at 1600/900/390 px');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Link and media checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,162 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: environment });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...environment, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/live-search'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const search = () => page.locator('[data-live-search] input[type="search"]');
|
||||
const rows = () => page.locator('.rows article h3 a');
|
||||
const settled = () => page.waitForFunction(() => /^\d+ Treffer/.test(document.querySelector('#search-status')?.textContent) && !document.querySelector('#list-results').hasAttribute('aria-busy'));
|
||||
const type = async value => { await search().fill(value); await settled(); };
|
||||
const count = async expected => { await settled(); assert.equal(await rows().count(), expected); };
|
||||
const mark = async () => page.evaluate(() => { window.searchDocumentMarker = 'same-document'; });
|
||||
const sameDocument = async () => assert.equal(await page.evaluate(() => window.searchDocumentMarker), 'same-document');
|
||||
const next = () => page.locator('.pager').first().getByRole('link', { name: 'Weiter' });
|
||||
|
||||
await page.goto(origin + '/admin/posts'); await mark();
|
||||
assert.equal(await rows().count(), 20);
|
||||
await next().click(); await count(9); await sameDocument();
|
||||
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
|
||||
await page.goBack(); await count(20);
|
||||
await page.goForward(); await count(9);
|
||||
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50'); await count(29);
|
||||
assert.equal(new URL(page.url()).searchParams.has('seite'), false);
|
||||
await type('nichtvorhandener-qa-begriff'); await count(0); await sameDocument();
|
||||
assert.equal(await search().evaluate(input => input === document.activeElement), true);
|
||||
assert.equal(await page.locator('.create-form input[name="_list_query"]').inputValue(), 'nichtvorhandener-qa-begriff');
|
||||
await type(''); await count(29);
|
||||
await page.locator('[data-search-reset]').click(); await count(20);
|
||||
assert.equal(await page.getByLabel('Pro Seite', { exact: true }).inputValue(), '20');
|
||||
await page.getByLabel('Status', { exact: true }).selectOption('trash'); await count(0);
|
||||
await page.goBack(); await count(20);
|
||||
assert.equal(await page.getByLabel('Status', { exact: true }).inputValue(), 'all');
|
||||
checks.push('posts: typing, focus, filter, page size, pagination, reset, back/forward and form context without navigation');
|
||||
|
||||
// Let an old server response arrive after the latest query has already completed.
|
||||
let delayed;
|
||||
const oldRequest = new Promise(resolve => { delayed = resolve; });
|
||||
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'old-delayed-search', async route => {
|
||||
const response = await route.fetch(); delayed();
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
});
|
||||
await search().fill('old-delayed-search'); await oldRequest;
|
||||
await type(''); await count(20); await page.waitForTimeout(1000); await count(20);
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
let requests = 0;
|
||||
const countRequests = request => { if (request.headers()['x-requested-with'] === 'XMLHttpRequest') requests++; };
|
||||
page.on('request', countRequests);
|
||||
await search().pressSequentially('fast-query', { delay: 25 }); await count(0);
|
||||
assert.equal(requests, 1, 'rapid typing is debounced');
|
||||
page.off('request', countRequests);
|
||||
await type(''); await count(20);
|
||||
await search().dispatchEvent('compositionstart'); await search().fill('ime-query');
|
||||
await page.waitForTimeout(400); assert.equal(await rows().count(), 20);
|
||||
await search().dispatchEvent('compositionend'); await count(0);
|
||||
await type(''); await count(20);
|
||||
checks.push('stale responses ignored; rapid input debounced; IME composition waits for completed text');
|
||||
|
||||
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'error-query', route => route.fulfill({ status: 500, body: 'Unavailable' }));
|
||||
await search().fill('error-query'); await page.locator('#search-error').waitFor({ state: 'visible' });
|
||||
assert.equal(await rows().count(), 20); assert.equal(await search().inputValue(), 'error-query');
|
||||
await page.unrouteAll({ behavior: 'wait' }); await page.getByRole('button', { name: 'Erneut suchen' }).click(); await count(0);
|
||||
assert.equal(await page.locator('#search-error').isVisible(), false);
|
||||
await type(''); await count(20);
|
||||
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'expired-session', route => route.fulfill({ status: 401, body: '' }));
|
||||
await search().fill('expired-session'); await page.getByRole('alert').filter({ hasText: 'erneut anmelden' }).waitFor();
|
||||
assert.equal(await rows().count(), 20); await page.unrouteAll({ behavior: 'wait' });
|
||||
await type(''); await count(20);
|
||||
checks.push('server error and expired session preserve results and input; retry succeeds');
|
||||
|
||||
await page.goto(origin + '/admin'); await mark();
|
||||
await type('Impressum'); await count(1); await sameDocument();
|
||||
assert.match(await rows().first().innerText(), /Impressum/);
|
||||
await page.screenshot({ path: `${report}/pages-search.png`, fullPage: true });
|
||||
await page.reload(); assert.equal(await search().inputValue(), 'Impressum'); assert.equal(await rows().count(), 1);
|
||||
await type(''); await count(4);
|
||||
const entry = page.locator('.rows article').filter({ hasText: 'Impressum' }).first();
|
||||
await entry.locator('summary').first().click();
|
||||
await entry.getByRole('combobox', { name: 'Übergeordnete Seite (optional)' }).fill('Datenschutz');
|
||||
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().waitFor();
|
||||
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().click();
|
||||
assert.ok(await entry.locator('.picker-value').inputValue());
|
||||
let pending;
|
||||
const pendingRequest = new Promise(resolve => { pending = resolve; });
|
||||
await page.route(url => url.pathname === '/admin' && url.searchParams.get('q') === 'late-edit-search', async route => {
|
||||
const response = await route.fetch(); pending();
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
});
|
||||
await search().fill('late-edit-search'); await pendingRequest;
|
||||
await entry.getByLabel('Titel für Suchergebnisse').fill('Ungespeicherte Bearbeitung');
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(await entry.getByLabel('Titel für Suchergebnisse').inputValue(), 'Ungespeicherte Bearbeitung');
|
||||
assert.match(await page.locator('#search-status').innerText(), /angehalten/);
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
checks.push('pages: live title search, reloadable URLs, reinitialized parent picker after AJAX');
|
||||
|
||||
await page.goto(origin + '/admin/media'); await mark();
|
||||
const library = await (await context.request.get(origin + '/admin/api/media')).json();
|
||||
const target = library.items.filter(item => !item.archived).at(-1); assert.ok(target, 'pilot has media');
|
||||
await type(target.title);
|
||||
assert.ok((await page.locator('.library-body strong').allTextContents()).includes(target.title));
|
||||
assert.equal(await page.locator('form[action$="/media/upload"] input[name="q"]').inputValue(), target.title);
|
||||
await sameDocument();
|
||||
await page.screenshot({ path: `${report}/media-search.png`, fullPage: true });
|
||||
await page.getByLabel('Status', { exact: true }).selectOption('1'); await settled();
|
||||
assert.equal(new URL(page.url()).searchParams.get('archiv'), '1');
|
||||
await type('nichtvorhandenes-bild'); assert.equal(await page.locator('.library-tile').count(), 0);
|
||||
await type(''); assert.ok(await page.locator('.library-tile').count());
|
||||
const older = page.locator('.pager a[rel="next"]');
|
||||
if (await older.count()) { await older.click(); await settled(); assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); await sameDocument(); }
|
||||
checks.push('media: global title search, archive filter, empty/reset states, pagination, upload context');
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const route of ['/admin', '/admin/posts', '/admin/media']) {
|
||||
await page.goto(origin + route); await type('nichtvorhandener-begriff');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`);
|
||||
const audit = await new AxeBuilder({ page }).analyze();
|
||||
assert.deepEqual(audit.violations.map(v => ({ id: v.id, targets: v.nodes.map(n => n.target) })), [], `${route} ${width}`);
|
||||
if (width === 390) await page.screenshot({ path: `${report}/${route.split('/').pop()}-mobile.png`, fullPage: true });
|
||||
}
|
||||
}
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false, storageState: await context.storageState() });
|
||||
const fallback = await plain.newPage(); await fallback.goto(origin + '/admin');
|
||||
await fallback.getByLabel('Seiten durchsuchen').fill('Impressum');
|
||||
await fallback.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
||||
assert.equal(await fallback.locator('.rows article h3 a').count(), 1);
|
||||
await plain.close(); checks.push('desktop/mobile axe and overflow checks; HTML fallback without JavaScript');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Live search browser checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,137 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/not-found'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const visitor = await context.newPage();
|
||||
visitor.on('pageerror', error => errors.push(error.message));
|
||||
await visitor.goto(origin);
|
||||
const shell = await visitor.evaluate(() => ({
|
||||
header: document.querySelector('.site-header').textContent.replace(/\s+/g, ' ').trim(),
|
||||
footer: document.querySelector('.site-footer').textContent.replace(/\s+/g, ' ').trim(),
|
||||
font: getComputedStyle(document.body).fontFamily,
|
||||
}));
|
||||
const checkError = async (target, heading = 'Seite nicht gefunden', targetPage = visitor) => {
|
||||
const response = await targetPage.goto(origin + target);
|
||||
assert.equal(response.status(), 404);
|
||||
assert.ok(response.headers()['x-robots-tag'].includes('noindex'));
|
||||
assert.ok(response.headers()['content-security-policy'].includes("'sha256-"));
|
||||
assert.equal(await targetPage.locator('h1').innerText(), heading);
|
||||
assert.equal((await targetPage.locator('.site-header').textContent()).replace(/\s+/g, ' ').trim(), shell.header);
|
||||
assert.equal((await targetPage.locator('.site-footer').textContent()).replace(/\s+/g, ' ').trim(), shell.footer);
|
||||
assert.equal(await targetPage.evaluate(() => getComputedStyle(document.body).fontFamily), shell.font);
|
||||
assert.equal(await targetPage.locator('link[rel="canonical"]').count(), 0);
|
||||
assert.equal(await targetPage.locator('meta[name="robots"]').getAttribute('content'), 'noindex, follow');
|
||||
};
|
||||
const audit = async targetPage => {
|
||||
const result = await new AxeBuilder({ page: targetPage }).analyze();
|
||||
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await targetPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
};
|
||||
await checkError('/eine-unbekannte-seite');
|
||||
await audit(visitor); await visitor.screenshot({ path: `${report}/default-desktop.png`, fullPage: true });
|
||||
await checkError('/Alte/Seite.html'); await checkError('/eins/zwei/drei/vier/fuenf');
|
||||
await visitor.setViewportSize({ width: 390, height: 900 });
|
||||
await checkError('/__404-vorschau__'); await audit(visitor);
|
||||
await visitor.screenshot({ path: `${report}/default-mobile.png`, fullPage: true });
|
||||
checks.push('default 404, unusual/deep URLs, correct status/noindex, standard header/footer/fonts, desktop/mobile axe and layout');
|
||||
|
||||
await page.goto(origin + '/admin/settings#not-found');
|
||||
await page.getByRole('button', { name: '404-Vorlage anlegen', exact: true }).click();
|
||||
await page.waitForURL(/\/admin\/pages\/[a-f0-9]{32}$/);
|
||||
const id = new URL(page.url()).pathname.split('/').pop();
|
||||
const selectHeading = async () => {
|
||||
await page.locator('#structure [data-block-id="error-heading"]').click();
|
||||
await page.locator('[data-panel="content"]').click();
|
||||
};
|
||||
await selectHeading(); await page.getByLabel('Text', { exact: true }).fill('Hier bist du falsch abgebogen');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
|
||||
await checkError('/404', 'Hier bist du falsch abgebogen');
|
||||
await audit(visitor); await visitor.screenshot({ path: `${report}/custom-mobile.png`, fullPage: true });
|
||||
await visitor.setViewportSize({ width: 1600, height: 1050 });
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen'); await audit(visitor);
|
||||
await visitor.screenshot({ path: `${report}/custom-desktop.png`, fullPage: true });
|
||||
const sitemap = await (await context.request.get(origin + '/sitemap.xml')).text();
|
||||
assert.ok(!sitemap.includes(origin + '/404'));
|
||||
checks.push('create an assigned draft, edit in normal editor, keep draft private, publish immediately, assigned own URL remains 404 and out of sitemap');
|
||||
|
||||
await page.goto(origin + '/admin/settings#not-found');
|
||||
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
|
||||
const clear = () => page.getByRole('button', { name: '404-Seite suchen: Auswahl entfernen', exact: true });
|
||||
const saveAssignment = async () => {
|
||||
await page.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin/settings#not-found');
|
||||
};
|
||||
await clear().click(); await saveAssignment();
|
||||
await page.getByText('Aktiv: Standard-404 im Website-Design.', { exact: true }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad');
|
||||
const assignExisting = async () => {
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).fill('Seite nicht gefunden');
|
||||
await page.locator('#not-found-picker-results [role="option"]').first().waitFor();
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).press('ArrowDown');
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).press('Enter');
|
||||
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
|
||||
await saveAssignment();
|
||||
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
|
||||
};
|
||||
await assignExisting(); await page.reload();
|
||||
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
|
||||
const stale = await context.newPage(); await stale.goto(origin + '/admin/settings#not-found');
|
||||
await clear().click(); await saveAssignment();
|
||||
await stale.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
|
||||
await stale.getByText('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.', { exact: true }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad'); await stale.close();
|
||||
await assignExisting();
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1100 }); await page.locator('#not-found').scrollIntoViewIfNeeded();
|
||||
await audit(page); await page.locator('#not-found').screenshot({ path: `${report}/settings-${width}.png` });
|
||||
}
|
||||
checks.push('live search without submitting, keyboard assignment, reload persistence, reset to default and protection against stale settings forms');
|
||||
|
||||
const withoutScripts = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 900 } });
|
||||
const plain = await withoutScripts.newPage();
|
||||
await checkError('/Alte/Seite.html', 'Hier bist du falsch abgebogen', plain);
|
||||
await plain.getByRole('link', { name: 'Zur Startseite', exact: true }).click();
|
||||
await plain.waitForURL(origin + '/'); await withoutScripts.close();
|
||||
const normal = await visitor.goto(origin); assert.equal(normal.status(), 200);
|
||||
assert.equal(await visitor.locator('link[rel="canonical"]').getAttribute('href'), origin + '/');
|
||||
await audit(visitor);
|
||||
checks.push('public 404 and working home link without JavaScript; normal home remains 200 with canonical and passes axe');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Custom 404 checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ final class DocumentValidator
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -124,6 +124,15 @@ final class DocumentValidator
|
||||
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
|
||||
$this->text($props['text'] ?? null, $limit);
|
||||
}
|
||||
if (!$plugin && array_key_exists('newTab', $props)) {
|
||||
$this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.');
|
||||
$this->require(is_bool($props['newTab']), 'In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
foreach (($block['style'] ?? []) as $values) {
|
||||
if ('none' === ($values['backgroundImage'] ?? null)) {
|
||||
$this->require($schemaVersion >= 20, 'Ein entferntes Hintergrundbild braucht Schema v20.');
|
||||
}
|
||||
}
|
||||
if ('heading' === $type) {
|
||||
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
||||
$headings += 1 === $props['level'] ? 1 : 0;
|
||||
|
||||
@@ -39,17 +39,22 @@ final class MediaLibrary
|
||||
*
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, pages: int, perPage: int}
|
||||
*/
|
||||
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE): array
|
||||
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE, string $query = ''): array
|
||||
{
|
||||
$where = $withArchived ? '' : ' WHERE archived = 0';
|
||||
$total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'.$where);
|
||||
$qb = $this->db->createQueryBuilder()->from('media');
|
||||
if (!$withArchived) {
|
||||
$qb->andWhere('archived = 0');
|
||||
}
|
||||
if ('' !== trim($query)) {
|
||||
$qb->andWhere('(LOWER(title) LIKE :query ESCAPE \'!\' OR LOWER(alt) LIKE :query ESCAPE \'!\')')
|
||||
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
|
||||
}
|
||||
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
|
||||
$perPage = max(1, min(96, $perPage));
|
||||
$pages = max(1, (int) ceil($total / $perPage));
|
||||
$page = max(1, min($pages, $page));
|
||||
$items = $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM media'.$where.' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?',
|
||||
[$perPage, ($page - 1) * $perPage],
|
||||
);
|
||||
$items = $qb->select('*')->orderBy('created_at', 'DESC')->addOrderBy('id', 'DESC')
|
||||
->setMaxResults($perPage)->setFirstResult(($page - 1) * $perPage)->executeQuery()->fetchAllAssociative();
|
||||
|
||||
return [
|
||||
'items' => array_map($this->present(...), $items),
|
||||
|
||||
@@ -132,7 +132,7 @@ final class MenuRepository
|
||||
if ('' === trim($label)) {
|
||||
continue;
|
||||
}
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children];
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : [])];
|
||||
}
|
||||
|
||||
return $links;
|
||||
@@ -153,6 +153,9 @@ final class MenuRepository
|
||||
if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) {
|
||||
throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.');
|
||||
}
|
||||
if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
|
||||
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
if ('page' === ($item['type'] ?? null)) {
|
||||
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) {
|
||||
throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.');
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/** A normal block page supplies the content; an error response always keeps the site shell. */
|
||||
final class NotFoundPage
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private PageRepository $pages, private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function selected(): ?array
|
||||
{
|
||||
$id = $this->settings->load()['notFoundPageId'];
|
||||
|
||||
return '' === $id ? null : ($this->pages->choices(id: $id)[0] ?? null);
|
||||
}
|
||||
|
||||
public function page(int $status = 404): array
|
||||
{
|
||||
$id = $this->settings->load()['notFoundPageId'];
|
||||
$published = 404 === $status && '' !== $id ? $this->pages->findPublishedById($id) : null;
|
||||
|
||||
return [
|
||||
'id' => 'error-'.$status, 'path' => '', 'document' => self::document($status),
|
||||
...($published ?? []),
|
||||
'locale' => 'de', 'type' => 'page', 'robots' => 'noindex', 'seo_title' => '',
|
||||
'header_area' => null, 'footer_area' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function document(int $status = 404): array
|
||||
{
|
||||
$title = 410 === $status ? 'Diese Seite ist nicht mehr verfügbar.' : 'Seite nicht gefunden';
|
||||
|
||||
return [
|
||||
'schemaVersion' => 10, 'title' => $title, 'description' => 'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.',
|
||||
'blocks' => [[
|
||||
'id' => 'error-section', 'type' => 'section',
|
||||
'style' => ['desktop' => ['textAlign' => 'center', 'minHeight' => '45vh', 'padding' => ['top' => '80px', 'bottom' => '80px']]],
|
||||
'children' => [
|
||||
['id' => 'error-code', 'type' => 'text', 'props' => ['text' => (string) $status]],
|
||||
['id' => 'error-heading', 'type' => 'heading', 'props' => ['text' => $title, 'level' => 1]],
|
||||
['id' => 'error-message', 'type' => 'text', 'props' => ['text' => 'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.']],
|
||||
['id' => 'error-home', 'type' => 'button', 'props' => ['text' => 'Zur Startseite', 'href' => '/']],
|
||||
],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
public function create(string $expectedId): string
|
||||
{
|
||||
return $this->db->transactional(function () use ($expectedId): string {
|
||||
if (null !== $this->selected()) {
|
||||
throw new InvalidPage('Es ist bereits eine 404-Seite zugewiesen. Du kannst sie direkt bearbeiten.');
|
||||
}
|
||||
$slug = '404';
|
||||
for ($suffix = 2; false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', 'de', $slug]); ++$suffix) {
|
||||
$slug = '404-'.$suffix;
|
||||
}
|
||||
$id = $this->pages->createPage($slug, null, 'page', self::document());
|
||||
$this->db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $id]);
|
||||
$this->settings->assignNotFoundPage($id, $expectedId);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -21,7 +21,7 @@ final class RichText
|
||||
'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
|
||||
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
|
||||
'ul' => [], 'ol' => [], 'li' => [],
|
||||
'a' => ['href'],
|
||||
'a' => ['href', 'target'],
|
||||
];
|
||||
/** Tags whose content is dropped along with them; unwrapping them would leak code as text. */
|
||||
private const DROP_ENTIRELY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
|
||||
@@ -121,7 +121,10 @@ final class RichText
|
||||
continue;
|
||||
}
|
||||
$child->setAttribute('href', $href);
|
||||
if (str_starts_with($href, 'https://')) {
|
||||
if ('_blank' !== $child->getAttribute('target')) {
|
||||
$child->removeAttribute('target');
|
||||
}
|
||||
if ('_blank' === $child->getAttribute('target') || str_starts_with($href, 'https://')) {
|
||||
$child->setAttribute('rel', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
|
||||
@@ -53,6 +54,7 @@ final class SiteSettings
|
||||
'robotsExtra' => '',
|
||||
// Which stored page answers at the site root. Everything else derives from this.
|
||||
'homePath' => 'home',
|
||||
'notFoundPageId' => '',
|
||||
// The consent question. Off by default, because a site that loads nothing from third
|
||||
// parties has nothing to ask about, and a banner without a reason is only in the way.
|
||||
'consentBanner' => false,
|
||||
@@ -96,6 +98,8 @@ final class SiteSettings
|
||||
public function save(array $input): void
|
||||
{
|
||||
$values = self::DEFAULTS;
|
||||
// Assigned separately, so saving company data cannot clear the error page.
|
||||
$values['notFoundPageId'] = $this->load()['notFoundPageId'];
|
||||
foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
|
||||
$values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
|
||||
}
|
||||
@@ -194,9 +198,13 @@ final class SiteSettings
|
||||
throw new InvalidPage('Die Startseite braucht eine gültige Adresse.');
|
||||
}
|
||||
// A root that points nowhere would answer every visit with 404, so it must exist.
|
||||
if (false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home])) {
|
||||
$homeId = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home]);
|
||||
if (false === $homeId) {
|
||||
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home));
|
||||
}
|
||||
if ($homeId === $values['notFoundPageId']) {
|
||||
throw new InvalidPage('Die 404-Seite kann nicht gleichzeitig die Startseite sein.');
|
||||
}
|
||||
$values['homePath'] = $home;
|
||||
|
||||
$values['consentBanner'] = (bool) ($input['consentBanner'] ?? false);
|
||||
@@ -237,6 +245,41 @@ final class SiteSettings
|
||||
]);
|
||||
}
|
||||
|
||||
/** Change only the 404 assignment, preserving settings saved in other forms. */
|
||||
public function assignNotFoundPage(string $id, string $expectedId): void
|
||||
{
|
||||
$this->db->transactional(function () use ($id, $expectedId): void {
|
||||
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$stored = is_array($stored) ? $stored : [];
|
||||
$current = $stored['notFoundPageId'] ?? '';
|
||||
if ($current !== $expectedId) {
|
||||
throw new StaleWrite('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.');
|
||||
}
|
||||
if ('' !== $id) {
|
||||
$page = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$id, 'default']);
|
||||
if (false === $page || 'page' !== $page['type']) {
|
||||
throw new InvalidPage('Bitte wähle eine vorhandene Seite als 404-Seite.');
|
||||
}
|
||||
if ($page['path'] === ($stored['homePath'] ?? self::DEFAULTS['homePath'])) {
|
||||
throw new InvalidPage('Die Startseite kann nicht gleichzeitig die 404-Seite sein.');
|
||||
}
|
||||
}
|
||||
if ($current === $id) {
|
||||
return;
|
||||
}
|
||||
$stored['notFoundPageId'] = $id;
|
||||
$changed = $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
|
||||
]);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Die Einstellungen wurden inzwischen geändert. Lade die Seite neu.');
|
||||
}
|
||||
});
|
||||
$this->loaded = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two mandatory pages are missing, by their settings key.
|
||||
*
|
||||
|
||||
@@ -84,7 +84,7 @@ final class StyleCompiler
|
||||
sprintf('"%s" is outside its range.', $key),
|
||||
),
|
||||
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
|
||||
'media' => $this->require(1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
'media' => $this->require('none' === $text || 1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
default => $this->require(false, 'Unknown style type.'),
|
||||
};
|
||||
}
|
||||
@@ -233,7 +233,7 @@ final class StyleCompiler
|
||||
$this->css(['type' => 'color'], (string) $values['gradientTo']),
|
||||
);
|
||||
}
|
||||
if (isset($values['backgroundImage']) && null !== $this->media) {
|
||||
if (isset($values['backgroundImage']) && 'none' !== $values['backgroundImage'] && null !== $this->media) {
|
||||
$item = $this->media->find((string) $values['backgroundImage']);
|
||||
if (null !== $item && [] !== $item['variants']) {
|
||||
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
||||
@@ -244,7 +244,7 @@ final class StyleCompiler
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $layers ? '' : 'background-image:'.implode(',', $layers);
|
||||
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
|
||||
}
|
||||
|
||||
private function shadow(array $values): string
|
||||
@@ -348,21 +348,19 @@ final class StyleCompiler
|
||||
*/
|
||||
private function scaleBackground(array $style): array
|
||||
{
|
||||
$image = $style['desktop']['backgroundImage'] ?? null;
|
||||
if (null === $image) {
|
||||
return $style;
|
||||
}
|
||||
$inherited = $style['desktop'] ?? [];
|
||||
foreach (['tablet', 'mobile'] as $device) {
|
||||
if (!isset($style[$device]['backgroundImage'])) {
|
||||
$style[$device]['backgroundImage'] = $image;
|
||||
if (isset($inherited['backgroundImage']) || isset($style[$device]['backgroundImage'])) {
|
||||
$style[$device]['backgroundImage'] ??= $inherited['backgroundImage'];
|
||||
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
|
||||
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
|
||||
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
|
||||
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||
$style[$device][$begleiter] = $style['desktop'][$begleiter];
|
||||
if (isset($inherited[$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||
$style[$device][$begleiter] = $inherited[$begleiter];
|
||||
}
|
||||
}
|
||||
}
|
||||
$inherited = array_replace($inherited, $style[$device] ?? []);
|
||||
}
|
||||
|
||||
return $style;
|
||||
|
||||
@@ -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,29 +28,60 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/%kant.admin_path%')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('', name: 'admin_pages', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
#[Route('/posts', name: 'admin_posts', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$pages = $this->pages->listForEditor();
|
||||
$type = 'admin_posts' === $request->attributes->get('_route') ? 'post' : 'page';
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$status = $request->query->getString('status', 'all');
|
||||
$status = in_array($status, ['all', 'draft', 'published', 'changed', 'trash'], true) ? $status : 'all';
|
||||
$result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status);
|
||||
$pages = $result['items'];
|
||||
$parentChoices = [];
|
||||
foreach ($pages as &$page) {
|
||||
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
|
||||
$page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$page['title'] = $page['document']['title'] ?? $page['slug'];
|
||||
$page['depth'] = substr_count((string) $page['path'], '/');
|
||||
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
|
||||
if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) {
|
||||
$parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null;
|
||||
}
|
||||
}
|
||||
unset($page);
|
||||
|
||||
return $this->render('admin/pages.html.twig', [
|
||||
'pages' => $pages,
|
||||
'trash' => $this->pages->trash(),
|
||||
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
|
||||
'postLayouts' => PostLayouts::all(),
|
||||
// Which header and footer areas a page can choose from.
|
||||
$view = [
|
||||
'pages' => 'trash' === $status ? [] : $pages,
|
||||
'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [],
|
||||
'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(),
|
||||
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
|
||||
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
|
||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||
]);
|
||||
];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/content-results.html.twig', $view),
|
||||
'total' => $result['total'],
|
||||
'parameters' => ['q' => $query, 'status' => $status, 'limit' => $result['limit'], 'seite' => $result['page']],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->render('admin/pages.html.twig', $view);
|
||||
}
|
||||
|
||||
#[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])]
|
||||
public function contentChoices(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
return $this->json(['items' => $this->pages->choices(
|
||||
mb_substr($request->query->getString('q'), 0, 160),
|
||||
$request->query->getString('type'), $request->query->getBoolean('parents'),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[Route('/pages', name: 'admin_create', methods: ['POST'])]
|
||||
@@ -222,7 +254,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 +349,26 @@ final class EditorController extends AbstractController
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
$request = $this->requests->getCurrentRequest();
|
||||
$post = 'post' === $request?->request->getString('_list_type', $request?->request->getString('type', 'page'));
|
||||
|
||||
return $this->redirectToRoute('admin_pages');
|
||||
if (!$request?->request->has('_list_type')) {
|
||||
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages');
|
||||
}
|
||||
|
||||
$params = [
|
||||
'q' => $request?->request->getString('_list_query', ''),
|
||||
'status' => $request?->request->getString('_list_status', 'all'),
|
||||
'seite' => $request?->request->getInt('_list_page', 1),
|
||||
'limit' => $request?->request->getInt('_list_limit', 20),
|
||||
];
|
||||
foreach (['q' => '', 'status' => 'all', 'seite' => 1, 'limit' => 20] as $key => $default) {
|
||||
if ($params[$key] === $default) {
|
||||
unset($params[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages', $params);
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}', name: 'admin_editor', methods: ['GET'])]
|
||||
|
||||
@@ -24,11 +24,18 @@ final class MediaController extends AbstractController
|
||||
// The screen renders the images itself, page by page. The dialog stays what it is: the way
|
||||
// to pick an image while editing, not the way to see what the library holds.
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||
'total' => $library['total'],
|
||||
'parameters' => ['q' => $query, 'archiv' => $archived ? '1' : '0', 'seite' => $library['page']],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->render('admin/media.html.twig', [
|
||||
'library' => $this->media->page($request->query->getInt('seite', 1), $archived),
|
||||
'archived' => $archived,
|
||||
]);
|
||||
return $this->render('admin/media.html.twig', $view);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
||||
@@ -90,7 +97,8 @@ final class MediaController extends AbstractController
|
||||
$query = array_filter([
|
||||
'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : null,
|
||||
'archiv' => $request->request->getBoolean('archiv') ? '1' : null,
|
||||
]);
|
||||
'q' => mb_substr(trim($request->request->getString('q')), 0, 160),
|
||||
], static fn ($value): bool => null !== $value && '' !== $value);
|
||||
|
||||
return $this->redirectToRoute('admin_media', $query);
|
||||
}
|
||||
@@ -103,6 +111,15 @@ final class MediaController extends AbstractController
|
||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$item = $this->media->find($id);
|
||||
|
||||
return null === $item ? $this->json(['error' => 'Bild nicht gefunden.'], 404) : $this->json(['item' => $item]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function write(Request $request, ?string $id = null): Response
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +76,7 @@ final class MenuController extends AbstractController
|
||||
$rows[] = [
|
||||
'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
|
||||
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
|
||||
'newTab' => $item['newTab'] ?? false,
|
||||
];
|
||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||
}
|
||||
@@ -97,6 +102,7 @@ final class MenuController extends AbstractController
|
||||
'pageId' => (string) ($row['pageId'] ?? ''),
|
||||
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
|
||||
'url' => trim((string) ($row['url'] ?? '')),
|
||||
'newTab' => '1' === (string) ($row['newTab'] ?? ''),
|
||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||
'position' => (int) ($row['position'] ?? 0),
|
||||
];
|
||||
@@ -106,6 +112,9 @@ final class MenuController extends AbstractController
|
||||
$items = [];
|
||||
foreach ($flat as $row) {
|
||||
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
|
||||
if ($row['newTab']) {
|
||||
$item['newTab'] = true;
|
||||
}
|
||||
if ('url' === $row['type']) {
|
||||
$item['url'] = $row['url'];
|
||||
} else {
|
||||
@@ -122,10 +131,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]);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
@@ -24,7 +25,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -45,91 +46,106 @@ final class PageController extends AbstractController
|
||||
return $this->redirectToRoute('home', status: 301);
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($page);
|
||||
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);
|
||||
$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);
|
||||
}
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
}
|
||||
if (null === $page) {
|
||||
// An address that moved or was deliberately removed answers before the 404 does.
|
||||
$redirect = $this->redirects->find($path);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
}
|
||||
$response = $this->render('not-found.html.twig', ['areas' => $areas], new Response(status: null !== $redirect ? 410 : 404));
|
||||
} else {
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'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']),
|
||||
// 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)),
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
|
||||
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
|
||||
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
|
||||
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|
||||
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
|
||||
]);
|
||||
|
||||
return $this->notFound($request, null !== $redirect ? 410 : 404);
|
||||
}
|
||||
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
|
||||
return $this->renderPage($request, $page, $path);
|
||||
}
|
||||
|
||||
public function notFound(Request $request, int $status = 404): Response
|
||||
{
|
||||
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
|
||||
}
|
||||
|
||||
/** Published pages and error content share theme, areas, block rendering and CSP. */
|
||||
private function renderPage(Request $request, array $page, string $path, int $status = 200): Response
|
||||
{
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($status >= 400 ? 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);
|
||||
}
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'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' => $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)),
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $status >= 400 ? [] : $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
], new Response(status: $status));
|
||||
|
||||
// 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']));
|
||||
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
$mitTurnstile = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
}
|
||||
|
||||
if ($status >= 400) {
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||
}
|
||||
|
||||
@@ -397,21 +413,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
|
||||
@@ -438,7 +452,7 @@ final class PageController extends AbstractController
|
||||
if (null !== $slug) {
|
||||
$term = $this->posts->findTerm($kind, $slug);
|
||||
if (null === $term) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
}
|
||||
$page = max(1, $request->query->getInt('seite', 1));
|
||||
@@ -446,7 +460,7 @@ final class PageController extends AbstractController
|
||||
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
|
||||
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
|
||||
if (0 === $result['total']) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
|
||||
@@ -465,10 +479,10 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
public function feed(): Response
|
||||
public function feed(Request $request): Response
|
||||
{
|
||||
if (!$this->posts->anyPublished()) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$result = $this->posts->published(1, null, 20);
|
||||
$response = $this->shell($this->render('feed.xml.twig', [
|
||||
|
||||
@@ -34,8 +34,8 @@ final class SeoController extends AbstractController
|
||||
// Only published pages that are allowed to be indexed. A draft or a noindex page in the
|
||||
// sitemap would ask a search engine to fetch something it must not list.
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL ORDER BY p.path",
|
||||
['default'],
|
||||
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY p.path",
|
||||
['default', $this->settings->load()['notFoundPageId']],
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
||||
@@ -99,8 +99,8 @@ final class SeoController extends AbstractController
|
||||
}
|
||||
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
|
||||
$rows = $this->db->fetchAllAssociative(
|
||||
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? ORDER BY p.path",
|
||||
['default', $type],
|
||||
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? AND p.id <> ? ORDER BY p.path",
|
||||
['default', $type, $settings['notFoundPageId']],
|
||||
);
|
||||
if ([] === $rows) {
|
||||
continue;
|
||||
|
||||
@@ -5,11 +5,13 @@ namespace App\Controller;
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -17,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme, private NotFoundPage $notFoundPage)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,6 +37,7 @@ final class SettingsController extends AbstractController
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $this->settings->load(),
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
'choosableHome' => $choosable,
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
@@ -63,6 +66,34 @@ final class SettingsController extends AbstractController
|
||||
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/not-found', name: 'admin_not_found_save', methods: ['POST'])]
|
||||
public function saveNotFound(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$expectedId = $request->request->getString('expectedId');
|
||||
if ('create' === $request->request->getString('action')) {
|
||||
$id = $this->notFoundPage->create($expectedId);
|
||||
$this->addFlash('success', 'Die 404-Vorlage wurde als Entwurf angelegt und zugewiesen. Nach dem Veröffentlichen ersetzt sie die Standard-404.');
|
||||
|
||||
return $this->redirectToRoute('admin_editor', ['id' => $id]);
|
||||
}
|
||||
$id = $request->request->getString('notFoundPageId');
|
||||
$this->settings->assignNotFoundPage($id, $expectedId);
|
||||
$selection = $this->notFoundPage->selected();
|
||||
$message = '' === $id ? 'Die Standard-404 im Website-Design ist wieder aktiv.'
|
||||
: ($selection['published'] ? 'Die 404-Seite wurde zugewiesen.' : 'Die Seite wurde zugewiesen. Bis zu ihrer Veröffentlichung erscheint die Standard-404.');
|
||||
$this->addFlash('success', $message);
|
||||
} catch (InvalidPage|StaleWrite $error) {
|
||||
$this->addFlash('error', $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'not-found']);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
public function saveRedirect(Request $request): Response
|
||||
{
|
||||
|
||||
@@ -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,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Controller\PageController;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/** Also handle human-facing unknown URLs outside the content slug grammar. */
|
||||
final class PublicNotFoundSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private PageController $pages, private string $adminPath)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::EXCEPTION => ['onException', 10]];
|
||||
}
|
||||
|
||||
public function onException(ExceptionEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
if (!$event->isMainRequest() || !$event->getThrowable() instanceof NotFoundHttpException
|
||||
|| $request->attributes->has('_route') || !in_array($request->getMethod(), ['GET', 'HEAD'], true)
|
||||
|| $request->isXmlHttpRequest() || 'html' !== $request->getPreferredFormat('html')) {
|
||||
return;
|
||||
}
|
||||
$path = $request->getPathInfo();
|
||||
if (preg_match('#^/(?:'.preg_quote($this->adminPath, '#').'|media|assets|formular|install)(?:/|$)#', $path)
|
||||
|| preg_match('/\.(?:css|js|mjs|json|xml|txt|png|jpe?g|webp|gif|svg|ico|woff2?|ttf|pdf)$/iD', $path)) {
|
||||
return;
|
||||
}
|
||||
$event->setResponse($this->pages->notFound($request));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ final class ConnectionFactory
|
||||
}
|
||||
$connection = DriverManager::getConnection($params, $configuration);
|
||||
if ('pdo_sqlite' === $params['driver']) {
|
||||
// SQLite's built-in LOWER only handles ASCII: searching for "über" must also find
|
||||
// "Über uns", just as on MySQL. No database rewrite or search-specific schema needed.
|
||||
$connection->getNativeConnection()->sqliteCreateFunction('lower', static fn (?string $value): ?string => null === $value ? null : mb_strtolower($value, 'UTF-8'), 1, \PDO::SQLITE_DETERMINISTIC);
|
||||
$connection->executeStatement('PRAGMA foreign_keys = ON');
|
||||
$connection->executeStatement('PRAGMA busy_timeout = 5000');
|
||||
}
|
||||
|
||||
@@ -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', 'live-search'] %}
|
||||
"/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>
|
||||
@@ -0,0 +1,221 @@
|
||||
{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %}
|
||||
<div class="section-head"><h2>{{ status == 'trash' ? 'Papierkorb' : plural }} <span class="count">{{ result.total }}</span></h2><span class="muted">{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}</span></div>
|
||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
|
||||
<div class="rows">
|
||||
{% for page in pages %}
|
||||
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
||||
an attribute here would simply be dropped and the tree would look flat. #}
|
||||
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
||||
<div class="row-line">
|
||||
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
||||
<div class="row-main">
|
||||
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
||||
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
|
||||
</div>
|
||||
{% if page.published_revision_id is null %}
|
||||
<span class="pill pill-off">Nicht veröffentlicht</span>
|
||||
{% elseif page.published_revision_id == page.draft_revision_id %}
|
||||
<span class="pill pill-live">Veröffentlicht</span>
|
||||
{% else %}
|
||||
<span class="pill pill-draft">Entwurf geändert</span>
|
||||
{% endif %}
|
||||
<div class="row-actions">
|
||||
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
||||
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
|
||||
{# One disclosure per row instead of six popovers next to each other: everything that is
|
||||
not the daily „bearbeiten“ lives here and opens in place. #}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
|
||||
<div class="sheet-body">
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
|
||||
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
|
||||
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
|
||||
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="robots-{{ page.id }}">Suchmaschinen</label>
|
||||
<select id="robots-{{ page.id }}" name="robots">
|
||||
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
|
||||
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
|
||||
</select>
|
||||
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
||||
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
||||
</div>
|
||||
{% if page.type == 'post' %}
|
||||
<div>
|
||||
<label for="date-{{ page.id }}">Veröffentlicht am</label>
|
||||
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="author-{{ page.id }}">Autor</label>
|
||||
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="categories-{{ page.id }}">Kategorien</label>
|
||||
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tags-{{ page.id }}">Schlagwörter</label>
|
||||
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
|
||||
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="media-{{ page.id }}">Beitragsbild</label>
|
||||
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
||||
</div>
|
||||
<div>
|
||||
<label for="excerpt-{{ page.id }}">Auszug</label>
|
||||
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
|
||||
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="primary" type="submit">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<div class="sheet-part">
|
||||
<h4>Adresse</h4>
|
||||
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="slug-{{ page.id }}">Adresssegment</label>
|
||||
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
|
||||
</div>
|
||||
<div>
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'parent-' ~ page.id, name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true, selected: parentChoices[page.parent_id]|default(null)} %}
|
||||
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Adresse ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Kopf- und Fußbereich</h4>
|
||||
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
|
||||
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
|
||||
<div>
|
||||
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
|
||||
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
|
||||
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
|
||||
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
|
||||
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
|
||||
{% endif %}{% endfor %}
|
||||
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="secondary" type="submit">Bereiche speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Veröffentlichung planen</h4>
|
||||
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="field">
|
||||
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
|
||||
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
|
||||
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Zeitpunkt speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Entfernen</h4>
|
||||
<div class="sheet-actions">
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="secondary" type="submit">In den Papierkorb</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
{# The class is what the security test looks for: an editor must not even be
|
||||
offered this. #}
|
||||
<div class="delete-panel">
|
||||
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
{% if status != 'trash' %}<div class="empty"><h3>{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}</h3>{% if result.total == 0 %}<p>{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}</p>{% endif %}</div>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if trash is not empty %}
|
||||
|
||||
<div class="rows">
|
||||
{% for entry in trash %}
|
||||
<article class="row">
|
||||
<span class="row-icon" aria-hidden="true">🗑</span>
|
||||
<div class="row-main">
|
||||
<h3>/{{ entry.path }}</h3>
|
||||
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="row-actions">
|
||||
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<button class="secondary" type="submit">Wiederherstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status == 'trash' and trash is empty %}<div class="empty"><h3>Der Papierkorb ist leer.</h3></div>{% endif %}
|
||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %}
|
||||
@@ -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,59 @@
|
||||
<div class="section-head">
|
||||
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
||||
<span class="muted">{{ library.total ? (library.page - 1) * library.perPage + 1 : 0 }}–{{ min(library.page * library.perPage, library.total) }} von {{ library.total }}</span>
|
||||
</div>
|
||||
|
||||
{% if library.items is empty %}
|
||||
<div class="empty">
|
||||
<h3>{{ query ? 'Keine passenden Bilder.' : (archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.') }}</h3>
|
||||
<p>{{ query ? 'Ändere oder leere die Suche.' : (is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.') }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="library-grid">
|
||||
{% for item in library.items %}
|
||||
<li class="library-tile">
|
||||
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
||||
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
||||
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
||||
</a>
|
||||
<div class="library-body">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
||||
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
||||
<code>{{ item.id }}</code>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="title-{{ item.id }}">Bildtitel</label>
|
||||
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<label for="alt-{{ item.id }}">Alternativtext</label>
|
||||
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
||||
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
||||
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if library.pages > 1 %}
|
||||
<nav class="pager" aria-label="Seiten der Mediathek">
|
||||
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null, q: query}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||||
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
||||
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null, q: query}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'media' %}
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||
{% block content %}
|
||||
@@ -9,7 +9,7 @@
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
{# A drop area rather than the browser's file widget. The input stays a real file input and
|
||||
stays focusable, so the form works by keyboard and without JavaScript; the script only adds
|
||||
@@ -30,66 +30,12 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-head">
|
||||
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
||||
<span class="muted">
|
||||
{% if archived %}<a href="{{ path('admin_media') }}">Archivierte ausblenden</a>{% else %}<a href="{{ path('admin_media', {archiv: 1}) }}">Archivierte anzeigen</a>{% endif %}
|
||||
{% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if library.items is empty %}
|
||||
<div class="empty">
|
||||
<h3>{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}</h3>
|
||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="library-grid">
|
||||
{% for item in library.items %}
|
||||
<li class="library-tile">
|
||||
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
||||
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
||||
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
||||
</a>
|
||||
<div class="library-body">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
||||
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
||||
<code>{{ item.id }}</code>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="title-{{ item.id }}">Bildtitel</label>
|
||||
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<label for="alt-{{ item.id }}">Alternativtext</label>
|
||||
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
||||
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
||||
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if library.pages > 1 %}
|
||||
<nav class="pager" aria-label="Seiten der Mediathek">
|
||||
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||||
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
||||
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
<form method="get" action="{{ path('admin_media') }}" class="list-toolbar" role="search" data-live-search>
|
||||
<div class="list-search"><label for="media-list-search">Bilder durchsuchen</label><input id="media-list-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel oder Alternativtext …"></div>
|
||||
<div><label for="media-list-archive">Status</label><select id="media-list-archive" name="archiv" data-search-default="0"><option value="0"{{ not archived ? ' selected' : '' }}>Aktive Bilder</option><option value="1"{{ archived ? ' selected' : '' }}>Mit archivierten Bildern</option></select></div>
|
||||
<button class="secondary search-submit" type="submit">Anwenden</button>
|
||||
<a class="text-button" data-search-reset href="{{ path('admin_media') }}"{{ not query and not archived ? ' hidden' : '' }}>Zurücksetzen</a>
|
||||
</form>
|
||||
{% include 'admin/search-feedback.html.twig' %}
|
||||
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
<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 %}
|
||||
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
|
||||
</div>
|
||||
</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,35 @@
|
||||
{% 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>
|
||||
<label class="check-label" for="link-new-tab"><input id="link-new-tab" type="checkbox"> In neuem Tab öffnen</label>
|
||||
<button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
|
||||
</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 %}
|
||||
+24
-239
@@ -1,14 +1,18 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'pages' %}
|
||||
{% block title %}Seiten{% endblock %}
|
||||
{% block heading %}Seiten und Beiträge{% endblock %}
|
||||
{% block lead %}Wähle eine Seite, gestalte deinen Entwurf, veröffentliche, wenn alles passt. Entwürfe bleiben bis dahin privat.{% endblock %}
|
||||
{% set nav = contentType == 'post' ? 'posts' : 'pages' %}
|
||||
{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %}
|
||||
{% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %}
|
||||
{% block title %}{{ plural }}{% endblock %}
|
||||
{% block heading %}{{ plural }}{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="card"{% if app.flashes is not empty %} open{% endif %}>
|
||||
<summary>Neue Seite oder neuen Beitrag anlegen</summary>
|
||||
<summary>{{ contentType == 'post' ? 'Neuen Beitrag' : 'Neue Seite' }} anlegen</summary>
|
||||
<form action="{{ path('admin_create') }}" method="post" class="create-form card-body">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
@@ -21,250 +25,31 @@
|
||||
<input id="new-slug" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" autocomplete="off" aria-describedby="new-slug-help">
|
||||
<p class="field-help" id="new-slug-help">Kleinbuchstaben, Ziffern und Bindestriche, zum Beispiel <code>ueber-uns</code>. Leer lassen, um sie aus dem Titel zu bilden.</p>
|
||||
</div>
|
||||
<input type="hidden" id="new-type" name="type" value="{{ contentType }}">
|
||||
{% if contentType == 'post' %}
|
||||
<div>
|
||||
<label for="new-type">Art</label>
|
||||
<select id="new-type" name="type">
|
||||
<option value="page">Seite</option>
|
||||
<option value="post">Beitrag</option>
|
||||
</select>
|
||||
<p class="field-help">Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-layout">Aufbau (nur für Beiträge)</label>
|
||||
<label for="new-layout">Beitragsvorlage</label>
|
||||
<select id="new-layout" name="layout">
|
||||
{% for key, layout in postLayouts %}<option value="{{ key }}"{{ key == 'bildkopf' ? ' selected' : '' }}>{{ layout.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">{% for key, layout in postLayouts %}<strong>{{ layout.name }}:</strong> {{ layout.description }}{% if not loop.last %} <br>{% endif %}{% endfor %} Jeder Block lässt sich danach ändern.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-parent">Übergeordnete Seite</label>
|
||||
<select id="new-parent" name="parent_id">
|
||||
<option value="">— keine, direkt unter der Startseite —</option>
|
||||
{% for parent in parents %}<option value="{{ parent.id }}">{{ parent.title }} (/{{ parent.path }})</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Bestimmt die Adresse, zum Beispiel <code>/leistungen/webdesign</code>.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'new-parent', name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true} %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="primary" type="submit">Anlegen und bearbeiten</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-head"><h2>Alle Seiten <span class="count">{{ pages|length }}</span></h2><span class="muted">Untergeordnete Seiten stehen eingerückt.</span></div>
|
||||
<div class="rows">
|
||||
{% for page in pages %}
|
||||
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
||||
an attribute here would simply be dropped and the tree would look flat. #}
|
||||
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
||||
<div class="row-line">
|
||||
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
||||
<div class="row-main">
|
||||
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
||||
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
|
||||
</div>
|
||||
{% if page.published_revision_id is null %}
|
||||
<span class="pill pill-off">Nicht veröffentlicht</span>
|
||||
{% elseif page.published_revision_id == page.draft_revision_id %}
|
||||
<span class="pill pill-live">Veröffentlicht</span>
|
||||
{% else %}
|
||||
<span class="pill pill-draft">Entwurf geändert</span>
|
||||
{% endif %}
|
||||
<div class="row-actions">
|
||||
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
||||
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
|
||||
{# One disclosure per row instead of six popovers next to each other: everything that is
|
||||
not the daily „bearbeiten“ lives here and opens in place. #}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
|
||||
<div class="sheet-body">
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
|
||||
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
|
||||
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
|
||||
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="robots-{{ page.id }}">Suchmaschinen</label>
|
||||
<select id="robots-{{ page.id }}" name="robots">
|
||||
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
|
||||
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
|
||||
</select>
|
||||
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
||||
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
||||
</div>
|
||||
{% if page.type == 'post' %}
|
||||
<div>
|
||||
<label for="date-{{ page.id }}">Veröffentlicht am</label>
|
||||
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="author-{{ page.id }}">Autor</label>
|
||||
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="categories-{{ page.id }}">Kategorien</label>
|
||||
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tags-{{ page.id }}">Schlagwörter</label>
|
||||
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
|
||||
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="media-{{ page.id }}">Beitragsbild</label>
|
||||
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
||||
</div>
|
||||
<div>
|
||||
<label for="excerpt-{{ page.id }}">Auszug</label>
|
||||
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
|
||||
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="primary" type="submit">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<div class="sheet-part">
|
||||
<h4>Adresse</h4>
|
||||
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="slug-{{ page.id }}">Adresssegment</label>
|
||||
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
|
||||
</div>
|
||||
<div>
|
||||
<label for="parent-{{ page.id }}">Übergeordnete Seite</label>
|
||||
<select id="parent-{{ page.id }}" name="parent_id">
|
||||
<option value="">— keine, direkt unter der Startseite —</option>
|
||||
{% for parent in parents|filter(candidate => candidate.id != page.id) %}
|
||||
<option value="{{ parent.id }}"{{ page.parent_id == parent.id ? ' selected' : '' }}>{{ parent.title }} (/{{ parent.path }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Adresse ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Kopf- und Fußbereich</h4>
|
||||
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
|
||||
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
|
||||
<div>
|
||||
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
|
||||
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
|
||||
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
|
||||
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
|
||||
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
|
||||
{% endif %}{% endfor %}
|
||||
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="secondary" type="submit">Bereiche speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Veröffentlichung planen</h4>
|
||||
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="field">
|
||||
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
|
||||
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
|
||||
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Die Veröffentlichung erfolgt, sobald <code>cms:publish-due</code> läuft.</p>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Zeitpunkt speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Entfernen</h4>
|
||||
<div class="sheet-actions">
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="secondary" type="submit">In den Papierkorb</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
{# The class is what the security test looks for: an editor must not even be
|
||||
offered this. #}
|
||||
<div class="delete-panel">
|
||||
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="empty"><h3>Noch keine Seiten vorhanden.</h3><p>Lege oben deine erste Seite an.</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if trash is not empty %}
|
||||
<div class="section-head"><h2>Papierkorb <span class="count">{{ trash|length }}</span></h2><span class="muted">Nicht öffentlich erreichbar, alle Fassungen erhalten.</span></div>
|
||||
<div class="rows">
|
||||
{% for entry in trash %}
|
||||
<article class="row">
|
||||
<span class="row-icon" aria-hidden="true">🗑</span>
|
||||
<div class="row-main">
|
||||
<h3>/{{ entry.path }}</h3>
|
||||
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="row-actions">
|
||||
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<button class="secondary" type="submit">Wiederherstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search" data-live-search>
|
||||
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
|
||||
<div><label for="page-status">Status</label><select id="page-status" name="status" data-search-default="all">{% for value, label in {all: 'Alle aktiven', draft: 'Entwürfe', published: 'Veröffentlicht', changed: 'Entwurf geändert', trash: 'Papierkorb'} %}<option value="{{ value }}"{{ status == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label for="page-limit">Pro Seite</label><select id="page-limit" name="limit" data-search-default="20">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
|
||||
<button class="secondary search-submit" type="submit">Anwenden</button>
|
||||
<a class="text-button" data-search-reset href="{{ path(listRoute) }}"{{ not query and status == 'all' and result.limit == 20 ? ' hidden' : '' }}>Zurücksetzen</a>
|
||||
</form>
|
||||
{% include 'admin/search-feedback.html.twig' %}
|
||||
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/content-results.html.twig' %}</div>
|
||||
{% endblock %}
|
||||
@@ -19,7 +19,7 @@
|
||||
<select id="new-kind" name="kind">
|
||||
{% for value, label in kinds %}<option value="{{ value }}"{{ value == 'section' ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Kopf- und Fußbereich gibt es je einmal.</p>
|
||||
<p class="field-help">Kopf- und Fußbereiche können je Seite zugewiesen werden. Sektionen lassen sich auf mehreren Seiten verknüpfen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-slug">Adresse</label>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<p id="search-status" class="field-help search-status" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div id="search-error" class="notice error" hidden><p role="alert"></p><button id="search-retry" class="secondary" type="button">Erneut suchen</button></div>
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Einstellungen{% endblock %}
|
||||
{% block heading %}Unternehmen und Adressen{% endblock %}
|
||||
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
@@ -20,6 +21,36 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="stack-section" id="not-found" aria-labelledby="not-found-title">
|
||||
<div class="section-head"><h2 id="not-found-title">404-Seite</h2><a class="text-button" href="/__404-vorschau__" target="_blank" rel="noopener">404-Vorschau ↗</a></div>
|
||||
<div class="card card-body">
|
||||
<p>Bei unbekannten Adressen erscheint diese Seite mit dem Standard-Header, Standard-Footer und Design deiner Website.</p>
|
||||
{% if notFoundSelection %}
|
||||
<p class="notice">{{ notFoundSelection.published ? 'Aktiv:' : 'Zugewiesener Entwurf:' }} <strong>{{ notFoundSelection.title }}</strong>{% if not notFoundSelection.published %} · Bis zur Veröffentlichung erscheint die Standard-404.{% endif %}</p>
|
||||
<p><a class="secondary" href="{{ path('admin_editor', {id: notFoundSelection.id}) }}">404-Seite bearbeiten</a></p>
|
||||
{% else %}
|
||||
<p class="notice">Aktiv: Standard-404 im Website-Design.</p>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<form action="{{ path('admin_not_found_save') }}" method="post" id="not-found-form">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<input type="hidden" name="expectedId" value="{{ settings.notFoundPageId }}">
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'not-found-picker', name: 'notFoundPageId', type: 'page', label: '404-Seite suchen', selected: notFoundSelection} only %}
|
||||
<p class="field-help">Nur der veröffentlichte Stand wird angezeigt. Ohne Auswahl wird die Standard-404 verwendet.</p>
|
||||
<button class="primary" type="submit">404-Zuordnung speichern</button>
|
||||
</form>
|
||||
{% if not notFoundSelection %}
|
||||
<form action="{{ path('admin_not_found_save') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<input type="hidden" name="expectedId" value="{{ settings.notFoundPageId }}">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<p class="field-help">Noch keine passende Seite? Lege eine fertige Vorlage an und bearbeite sie im Editor.</p>
|
||||
<button class="secondary" type="submit">404-Vorlage anlegen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}<p class="field-help">Die Zuordnung kann von einem Benutzer mit Veröffentlichungsrechten geändert werden.</p>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack-section">
|
||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
||||
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -53,17 +53,6 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
||||
{% endif %}
|
||||
{% elseif type == 'button' %}
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
{% elseif type == 'image' %}
|
||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||
@@ -38,19 +38,20 @@
|
||||
{% set annahme = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px' %}
|
||||
{% set imageSizes = festeBreite ends with 'px' ? festeBreite : (eager or inHeader|default(false) ? annahme : 'auto, ' ~ annahme) %}
|
||||
{% set klick = props.click|default(props.href|default('') ? 'link' : 'keine') %}
|
||||
{% set imageLinked = klick == 'link' and props.href|default('') %}
|
||||
{% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %}
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">
|
||||
{%- if klick == 'vergroessern' -%}
|
||||
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der
|
||||
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
|
||||
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild') }} vergrößern">
|
||||
{%- elseif props.href|default('') -%}
|
||||
<a class="image-link" href="{{ props.href }}">
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{%- elseif imageLinked -%}
|
||||
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{% elseif type == 'iconcard' %}
|
||||
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
||||
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
|
||||
<h3>{% if props.href|default('') %}<a href="{{ props.href }}">{{ props.title }}</a>{% else %}{{ props.title }}{% endif %}</h3>
|
||||
<h3>{% if props.href|default('') %}<a href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.title }}</a>{% else %}{{ props.title }}{% endif %}</h3>
|
||||
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
||||
</div>
|
||||
{% elseif type == 'stat' %}
|
||||
@@ -73,7 +74,7 @@
|
||||
<h2>{{ props.title }}</h2>
|
||||
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
||||
</div>
|
||||
<a class="button" href="{{ props.href }}">{{ props.buttonText }}</a>
|
||||
<a class="button" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.buttonText }}</a>
|
||||
</div>
|
||||
{% elseif type == 'contact' %}
|
||||
<address id="{{ block.id }}" class="block-contact{{ designClasses }}">
|
||||
@@ -160,7 +161,7 @@
|
||||
something carries its label as text for screen readers. #}
|
||||
{% set iconBody %}{% include 'blocks/icon.html.twig' with {name: props.icon} only %}{% if not props.decorative %}<span class="visually-hidden">{{ props.label }}</span>{% endif %}{% endset %}
|
||||
{% if props.href|default('') %}
|
||||
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
||||
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
||||
{% else %}
|
||||
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% if newTab|default(false) %} target="_blank" rel="noopener"{% endif -%}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
<details class="menu-branch">
|
||||
<summary>{{ link.label }}</summary>
|
||||
<div class="menu-branch-body">
|
||||
<a href="{{ link.href }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
{% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user