From 495b26896e3413079918ffba2ddb9e063ddd9ae4 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 14 Sep 2026 10:49:54 +0200 Subject: [PATCH] =?UTF-8?q?Fassung=201.7.0:=20verl=C3=A4ssliche=20Installa?= =?UTF-8?q?tion,=20=C3=BCbersichtliche=20Verwaltung=20und=20bedienbare=20B?= =?UTF-8?q?ewegung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 9 - .env.example | 9 +- .gitignore | 3 + README.md | 26 ++- VERSION | 2 +- bin/setup.php | 16 +- config/bootstrap.php | 31 ++- config/services.yaml | 3 + docs/ARBEITSPLAN-ADMIN-QUALITAET.md | 60 ++++++ docs/ARCHITECTURE.md | 26 ++- docs/STATUS.md | 15 ++ docs/UPDATE.md | 8 + package.json | 3 +- public/assets/admin.css | 26 +++ public/assets/editor.css | 2 + public/assets/editor.js | 31 ++- public/assets/enhancements.css | 71 +------ public/assets/media.js | 2 +- public/assets/menu-editor.js | 154 +++++++++++++++ public/assets/motion.js | 35 ++++ public/assets/page-picker.js | 67 +++++++ public/assets/site.css | 7 +- public/assets/slider.js | 142 +++++++------- scripts/admin-quality-check.mjs | 233 +++++++++++++++++++++++ scripts/build-release.php | 6 +- scripts/editor-check.mjs | 26 ++- src/Command/AdminPathCommand.php | 14 +- src/Content/PageRepository.php | 58 ++++++ src/Controller/EditorController.php | 61 +++++- src/Controller/MenuController.php | 42 ++-- src/Controller/PageController.php | 27 +-- src/Controller/SystemController.php | 38 +++- src/Ops/Environment.php | 112 +++++++++++ src/Ops/Installation.php | 35 +--- src/Ops/Updater.php | 59 +++++- templates/admin/base.html.twig | 3 +- templates/admin/content-picker.html.twig | 11 ++ templates/admin/editor.html.twig | 11 +- templates/admin/forms.html.twig | 16 +- templates/admin/menu-entry.html.twig | 24 +++ templates/admin/menus.html.twig | 79 +++----- templates/admin/pager.html.twig | 4 +- templates/admin/pages.html.twig | 83 ++++---- templates/admin/partials.html.twig | 2 +- templates/admin/shell.html.twig | 6 +- templates/admin/system.html.twig | 12 ++ templates/page.html.twig | 3 +- tests/CmsTest.php | 99 ++++++---- tests/EditorSecurityTest.php | 28 +++ tests/EnvironmentTest.php | 122 ++++++++++++ tests/InstallFlowTest.php | 4 +- tests/InstallationTest.php | 6 +- tests/SystemScreenTest.php | 20 ++ tests/UpdaterTest.php | 34 +++- tests/pilot-fixture.php | 29 +++ 55 files changed, 1632 insertions(+), 423 deletions(-) delete mode 100644 .env create mode 100644 docs/ARBEITSPLAN-ADMIN-QUALITAET.md create mode 100644 public/assets/menu-editor.js create mode 100644 public/assets/motion.js create mode 100644 public/assets/page-picker.js create mode 100644 scripts/admin-quality-check.mjs create mode 100644 src/Ops/Environment.php create mode 100644 templates/admin/content-picker.html.twig create mode 100644 templates/admin/menu-entry.html.twig create mode 100644 tests/EnvironmentTest.php create mode 100644 tests/pilot-fixture.php diff --git a/.env b/.env deleted file mode 100644 index 90a3775..0000000 --- a/.env +++ /dev/null @@ -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 `; -# der Befehl schreibt .env.local und prueft, dass die Adresse frei ist. -ADMIN_PATH=admin diff --git a/.env.example b/.env.example index 937b1c8..5ac1842 100644 --- a/.env.example +++ b/.env.example @@ -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 ` - der Befehl schreibt sie nach .env.local. +# `php bin/console cms:admin:path ` - der Befehl schreibt sie nach .env. #ADMIN_PATH=admin diff --git a/.gitignore b/.gitignore index f95e4b5..be56e36 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ /var/ /node_modules/ /.env.local +/.env +/.env.lock +/.env.*.tmp /.env.*.local /phpunit.cache/ /reports/ diff --git a/README.md b/README.md index 642b015..a7aec70 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0 Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage. -`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht. +`bin/setup.php` erzeugt ein zufälliges Secret und `var/`. Die Konfiguration steht ausschließlich in der nicht versionierten `.env`; vorhandene Einstellungen und Secrets bleiben erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht. **Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token. @@ -71,7 +71,7 @@ Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersic Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen. -Unter **Design → Effekte** Schatten, Hover-Anheben/Bildzoom und Einblendung beim Laden wählen. Effekte gelten für alle Geräte; bei aktivierter Bewegungsreduktion werden Animationen abgeschaltet. Die öffentliche Seite benötigt dafür kein JavaScript. Lightbox, Slideshow, Video/PDF-Upload und Bildzuschnitt sind noch nicht enthalten. +Unter **Design → Bewegung und Effekte** Schatten, Hover-Effekte und **Einblendung beim Erscheinen** wählen. Die gewählte Richtung, Zoom, Unschärfe oder Überblendung startet beim Eintreten in den Bildschirm. Das kleine Bewegungsmodul lädt nur auf Seiten mit Einblendungen; ohne JavaScript bleiben die Inhalte sichtbar. Slider haben Pfeile, optionale Punkte sowie bei aktivierter Automatik Pause/Wiedergabe. Manuelle Bedienung pausiert die Automatik, Wiedergabe setzt sie fort. Bewegungsreduktion schaltet Einblendungen und Automatik aus; manuelles Blättern bleibt möglich. Im Editor startet **Bewegung testen** diese Vorschau, **Bewegung stoppen** beendet sie. Die Vorschau-Sandbox bleibt ohne Skriptfreigabe. Medien liegen standardmäßig in `var/media`; mit `MEDIA_DIRECTORY` lässt sich ein absoluter Ordner außerhalb von `public` konfigurieren. Er muss für PHP beschreibbar sein. Auf dem Hosting `upload_max_filesize=8M`, `post_max_size=10M` und ausreichend Arbeitsspeicher (lokal 256M) einstellen, soweit der Tarif dies erlaubt. Datenbank und Medienordner gemeinsam sichern. Archivieren erhält Bild-URLs; es ist keine Dateilöschung. @@ -100,7 +100,7 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w **Datenbank:** SQLite ab Fassung 3.16 (Doctrine liest das Schema mit Tabellenfunktionen, die es erst seit dieser Fassung gibt) oder MySQL/MariaDB. Auf älteren Systemen – manche Shared Hoster liefern noch SQLite 3.7 – bleibt MySQL; die Systemprüfung des Installers nennt die gefundene Fassung und bietet SQLite gar nicht erst an, wenn sie zu alt ist. -Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse. +Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse. `composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen. @@ -143,8 +143,24 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen: - Apache mit Rewrite-Unterstützung für `public/.htaccess` oder entsprechende FastCGI/Nginx-Konfiguration. - Schreibzugriff für PHP auf `var/`, HTTPS und eine Möglichkeit, Migrationen/Cache-Kommandos kontrolliert auszuführen. -Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration. +Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsvariablen dürfen sie gezielt überschreiben: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration. -**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-.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-.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis. Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore `. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. + +## Verwaltung ab 1.7.0 + +**Seiten** und **Beiträge** sind getrennte Menüpunkte. Suche, Status einschließlich Papierkorb und 20/50 Einträge begrenzen jede Ergebnisliste bereits in der Datenbank. Vor/Zurück erhält die Filter; übergeordnete Seiten lassen sich unabhängig von der aktuellen Ergebnisseite suchen. + +Unter **Menüs** zuerst den Menüplatz wählen, links Inhalte suchen und zum Menü hinzufügen. Rechts lassen sich Beschriftung, Ziel, Reihenfolge und Unterpunkte bearbeiten. Pfeilbuttons sind per Tastatur erreichbar; Rückgängig stellt entfernte oder umgeordnete Einträge wieder her. Erst **Menü speichern** ändert die Website. Validierungs-, Verbindungs- und Versionsfehler erhalten die lokalen Eingaben. Ein paralleler Speicherstand muss bewusst neu geladen werden. + +**Bereiche** verwenden denselben Editor; Speichern wirkt sofort auf allen verknüpften Seiten. Unter **Formulare** ist die Meldung nach dem Absenden ein mehrzeiliges Feld über die verfügbare Formularbreite. + +### Konfigurationswechsel von älteren Fassungen + +Beim ersten Start von 1.7.0 wird eine vorhandene `.env.local` einmalig mit `.env` zusammengeführt. Die bisher wirksamen Werte aus `.env.local` bleiben dabei erhalten, insbesondere Datenbank, Medienordner und Secret. Beide Originale liegen anschließend unter `var/backups/environment--/`; 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/`. diff --git a/VERSION b/VERSION index fdd3be6..bd8bf88 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.2 +1.7.0 diff --git a/bin/setup.php b/bin/setup.php index 96e7d84..f91e017 100644 --- a/bin/setup.php +++ b/bin/setup.php @@ -1,13 +1,13 @@ 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; } diff --git a/config/services.yaml b/config/services.yaml index 45073c1..3dd74b7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -63,6 +63,9 @@ services: App\Controller\SystemController: arguments: $adminPath: '%kant.admin_path%' + $databaseUrl: '%env(DATABASE_URL)%' + $mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%' + $projectDir: '%kernel.project_dir%' App\Command\AdminPathCommand: arguments: $projectDir: '%kernel.project_dir%' diff --git a/docs/ARBEITSPLAN-ADMIN-QUALITAET.md b/docs/ARBEITSPLAN-ADMIN-QUALITAET.md new file mode 100644 index 0000000..a2a31bc --- /dev/null +++ b/docs/ARBEITSPLAN-ADMIN-QUALITAET.md @@ -0,0 +1,60 @@ +# Arbeitsplan: Installation, Verwaltung und Bewegung + +Auftrag vom 14.09.2026. Ausgangspunkt: Release 1.6.2 plus bereits vorhandene lokale Änderungen an der Systemdiagnose. Diese bleiben erhalten und werden an den neuen Konfigurationsvertrag angepasst. Vorhandener Patch liegt lokal unter `var/work-before-usability/`. + +## 1. Fehler reproduzieren und Daten schützen + +- [x] Bereichsfehler auf den Seitenzugriff in der gemeinsamen Vorlage zurückführen und Öffnen/Vorschau/Speichern im Browser prüfen. +- [x] Kant-IT-Datenbank für die Prüfung isoliert kopieren; echte Inhalte und Zugangsdaten nicht verändern. +- [x] Slider und Einblendungen auf der Kant-IT-Startseite mit Positionen und Zeitverlauf prüfen. + +## 2. Eine eindeutige Installationskonfiguration + +- [x] `.env` ist die Konfigurationsdatei der Installation. Installer, Setup und Admin-Pfad-Befehl schreiben dort; keine neue `.env.local`. +- [x] Bestehende wirksame Einstellungen aus älteren Installationen einmalig und mit Sicherung in `.env` zusammenführen. Datenbankverbindung und Secret erhalten; danach keine versteckte Überschreibung. +- [x] Updates erhalten `.env`; echte Server-Umgebungsvariablen bleiben für Hosting/Testbetrieb möglich. Anzeige im Systembereich muss die tatsächliche Quelle nennen, ohne Passwörter auszugeben. +- [x] Neuinstallation, bestehende Installation, wiederholtes Booten, Updateerhalt und Fehler beim Schreiben prüfen. + +## 3. Bereiche zuverlässig bearbeiten + +- [x] Gemeinsamen Editor ohne Zugriff auf nicht vorhandene Seitenvariablen rendern; benannte Routen auch bei geändertem Admin-Pfad verwenden. +- [x] Kopf, Fuß und verknüpfte Sektion öffnen, Inhalt ändern, Vorschau prüfen, speichern und neu laden; Versionskonflikte erhalten. +- [x] Sofort wirksames Speichern bei Bereichen in der Oberfläche korrekt benennen. + +## 4. Seiten und Beiträge übersichtlich verwalten + +- [x] Eigene Navigation und Ansichten für Seiten und Beiträge; passende Anlageformulare. +- [x] Datenbankseitige Pagination, Suche, Statusfilter und Ergebniszahl; keine vollständigen Dokumentlisten für jede Bildschirmseite laden. +- [x] Filter und Seitenzahl beim Blättern erhalten; leere Suche und ungültige Seitenzahlen behandeln. Papierkorb ebenfalls begrenzen. +- [x] Übergeordnete Seiten bleiben unabhängig von der aktuellen Ergebnisseite wählbar. + +## 5. Menüeditor mit verständlichem Aufbau + +- [x] Seiten/Beiträge nach Titel oder Adresse suchen und mit eindeutiger Vorschau auswählen. +- [x] Inhalt auswählen → zum Menü hinzufügen → Reihenfolge/Unterpunkte ändern → speichern. Eigene Links getrennt anbieten. +- [x] Bestehende Ziele und Hierarchien erhalten, Umordnen auch mit Tastatur/Buttons; Speicherfehler und ungespeicherte Änderungen sichtbar behandeln. + +## 6. Formulare, Animationen und Slider + +- [x] Meldung nach dem Absenden als ausreichend breites mehrzeiliges Feld anbieten. +- [x] Einblendungen sichtbar beim Eintreten in den Bildschirm starten; Effektauswahl tatsächlich unterscheiden. Inhalte ohne JavaScript lesbar lassen und Bewegungsreduktion respektieren. +- [x] Slider mit sichtbarer Bedienung, funktionierendem Wechsel und nachvollziehbarer Pause/Automatik. Auf der realen Kant-IT-Seite über mehrere Wechsel prüfen. +- [x] Bewegung in der Editorvorschau gezielt prüfbar machen, ohne die Sandbox für Inhaltsskripte zu öffnen. + +## 7. Abnahme und Übergabe + +- [x] Relevante PHP-/JavaScript-Tests und vollständige Browserabläufe, einschließlich Tastatur, Speicherung, Fehlerzuständen und unterschiedlichen Bildschirmbreiten. +- [x] Änderungen der öffentlichen Ausgabe auf Kant-IT-Inhalten visuell und mit den passenden Qualitätsprüfungen kontrollieren; keine ungeprüften Scores behaupten. +- [x] Tatsächliche Ergebnisse, Grenzen und Migration dokumentieren; Versionsnummer vor Veröffentlichung mit dem Repository abgleichen. + +Jeder Abschnitt wird erst als erledigt markiert, wenn seine Prüfung bestanden ist. Der Plan ersetzt keine Umsetzung; Befunde und Änderungen werden darin nachgeführt. + +## Ergebnis der Abnahme + +Umgesetzt als 1.7.0, ohne neues Datenbank- oder Dokumentformat. Begründungen und Konfigurationsmigration: ADR 076–078. Die ursprünglichen lokalen Änderungen an Systemdiagnose/STATUS wurden integriert, die veraltete lokale Versionsnummer 1.4.7 mit der bestehenden Releasefolge abgeglichen. + +Geprüft: PHP-Vollsuite 461/461, JavaScript 30/30, alle 43 Twig-Dateien und Produktionscontainer. Komplette Editorprüfung einschließlich unterschiedlicher Einblendungen und Bewegungsreduktion. Kant-IT-Browserabläufe mit 4 Seiten/29 Beiträgen, Menüfehlern und Versionskonflikt, mehrzeiliger Formularmeldung, Kopf/Fuß und neu angelegter Sektion samt echtem Textinhalt. Desktop, 1024 und 390 px; axe ohne Verstöße. Öffentliche Inhalte und Slider bleiben ohne JavaScript erreichbar. + +Kant-IT-Startseite, Produktionsmodus, Lighthouse 13.4.1: jeweils drei Läufe mobil/desktop, Leistungsmedian jeweils 99; Barrierefreiheit, Best Practices, SEO und Agentic Browsing jeweils 100. Lokale Labormessung, keine Felddaten/CWV-Abnahme für Kundenseiten. Messungen: `reports/admin-quality/lighthouse/`; Browserbericht und Bilder im übergeordneten Ordner. Der zusätzliche Updater-Test verändert die Datenbank vor einem Migrationsfehler, prüft echte Wiederherstellung und einen erhaltenen Wartungszustand, falls die Sicherung fehlt. + +Veröffentlichungsnachweis und bekannte Grenzen stehen im aktuellen Abschnitt von STATUS.md. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 292f619..5935d0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,30 @@ # Architekturentscheidungen -Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen. +Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen. + +## ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026) + +Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. `motion.js` beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert. + +Slider behalten Scroll-Snap als Grundlage. Ein kleines Modul ergänzt sichtbare Pfeile, Zähler, optionale Punkte sowie Pause/Wiedergabe bei Autoplay. Manuelle Bedienung stoppt auch einen bereits geplanten Timer. Automatik wartet bei Hover, außerhalb des Bildschirms oder in unsichtbaren Tabs; Bewegungsreduktion deaktiviert sie. Ohne Skript bleibt der Inhalt scrollbar. Im Editor startet der Benutzer diese Module ausdrücklich aus dem Elternfenster; die iframe-Sandbox bleibt `allow-same-origin` ohne `allow-scripts`. Neue Vorschauen räumen Observer, Timer und Ereignisse der alten Instanz auf. + +## ADR 077 – Begrenzte Inhaltslisten und Menüaufbau mit Suche (14.09.2026) + +Seiten und Beiträge erhalten getrennte Routen und Navigation. Die Datenbank führt Typ, Suche, Status, COUNT und LIMIT/OFFSET aus; nur 20 oder 50 Dokumente werden pro Ansicht dekodiert. Stabile Sortierung verhindert Überschneidungen bei identischen Zeitstempeln. Papierkorb und leere Suchergebnisse folgen demselben Vertrag. Keine Schemaänderung. Unterlagen mit Titeln und Pfaden werden höchstens in Gruppen von 20 als authentifizierte Suchvorschläge geliefert. Eine separate ID-Abfrage hält bereits ausgewählte Eltern unabhängig von der Ergebnisseite sichtbar. + +Der Menüeditor bearbeitet einen Menüplatz je Ansicht. Suche und eigene Links stehen links, Reihenfolge und Felder rechts. Hinzufügen, Haupt-/Unterpunkte, Umordnen und Entfernen sind per Buttons/Tastatur bedienbar und rückgängig zu machen. Haupteinträge ziehen ihre Kinder mit; beim Entfernen bleiben deren Links als Hauptpunkte erhalten. Speichern verwendet weiterhin die validierte Menüstruktur und erwartete Version, liefert bei JavaScript JSON und behält bei 422/409 oder Verbindungsfehlern die Eingaben. Keine neuen Datenformate. + +Der Fehler 500 bei Bereichen entstand durch einen unbedingten Zugriff auf `page.slug` in der gemeinsamen Editorvorlage. Bereiche haben keine Seite. Routen und öffentliche Links richten sich jetzt nach dem bearbeiteten Objekt; Speichern eines Bereichs ist sichtbar als sofort wirksame Änderung bezeichnet. Der API-Pfad wird aus benannten Routen abgeleitet, auch nach Verlegung der Verwaltung. + +## ADR 076 – Eine Installationsdatei und sichtbare Datenbankquelle (14.09.2026) + +`.env` ist die einzige automatisch geladene Konfigurationsdatei. Sie gehört zur Installation und wird nicht mehr von Git versioniert. Installer, Setup und Admin-Pfad-Befehl schreiben atomar und unter einer Dateisperre in diese Datei, ohne bestehende fremde Schlüssel zu verwerfen. Ein Marker unterscheidet eine vom Installer bestätigte Datenbank von der bloßen Vorlage. Echte Prozess-/Servervariablen bleiben bewusst höher priorisiert. + +Beim ersten Boot mit einer alten `.env.local` bleiben deren bisher wirksame Werte erhalten: beide Originale sichern, zusammengeführte `.env` schreiben, erst danach `.env.local` entfernen. Sicherungen liegen außerhalb des Webroots unter `var/backups/environment-*`. Secret und Datenbank werden nicht neu erzeugt oder gewechselt. Ein Schreibfehler bricht die Migration ab, statt still auf Standarddaten zu wechseln. Andere Symfony-Overlays werden nicht mehr implizit geladen; eine manuelle Nutzung benötigt vor dem Wechsel die Übernahme in `.env`/Servervariablen. Rückweg: Code und beide Konfigurationssicherungen gemeinsam wiederherstellen. + +Der Git-Updater sichert Konfigurationsinhalte vor dem Checkout und stellt sie vor jedem neuen PHP-Prozess wieder her, auch beim Rollback. Das behandelt ausdrücklich ältere Repositorys mit getrackter `.env`. Der Archiv-Updater nimmt diese Dateien weiterhin vom Dateiaustausch aus. Zusätzlich wurde ein vorhandener Rückwegfehler behoben: Der Git-Updater speicherte nur einen Beschreibungstext und leitete daraus einen ungültigen Datenbank-Sicherungspfad ab. Der tatsächliche Sicherungsordner wird jetzt getrennt geführt. Ein fehlgeschlagenes Zurückschreiben lässt den Wartungszustand aktiv; der Regressionstest verändert die Datenbank vor dem simulierten Migrationsfehler und prüft ihre Wiederherstellung. Bereits installierter alter Git-Updater-Code kann diesen neuen Schutz beim erstmaligen Wechsel noch nicht ausführen; daher diesen Übergang per Archiv-Update durchführen oder die Konfiguration vor dem manuellen Checkout sichern und anschließend wiederherstellen (UPDATE.md). + +Die bereits begonnene Systemdiagnose bleibt erhalten: tatsächliche Datenbankart, SQLite-Pfad/Größe/Schreibrecht beziehungsweise maskierte Serveradresse sowie die tatsächliche Quelle. Die Quelle stammt aus den vom Dotenv-Lader gesetzten Variablen; sie wird nicht aus der bloßen Existenz einer Datei geraten. Passwort und Secret werden nicht angezeigt. ## ADR 075 – Leere Container sind Arbeitsflächen (14.09.2026) diff --git a/docs/STATUS.md b/docs/STATUS.md index 196d38d..963ed8d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,19 @@ # Projektstatus +## 1.7.0 – Installation, Verwaltungsabläufe und Bewegung (14.09.2026) + +**Umgesetzt, Veröffentlichung wird geprüft:** `.env` ist die einzige automatisch geladene Installationsdatei und aus Git entfernt. Setup, Webinstaller und Admin-Pfad-Befehl schreiben dort. Alte `.env.local` wird einmalig mit Sicherung beider Originale zusammengeführt; wirksame Datenbank und Secret bleiben erhalten. System zeigt die tatsächliche Datenbank samt Quelle. Der Git-Updater erhält Konfiguration vor dem neuen PHP-Prozess und beim Rückweg; ein entdeckter falscher Datenbank-Sicherungspfad wurde ebenfalls korrigiert. Ein fehlgeschlagenes Zurückschreiben lässt die Website im Wartungszustand. Vorgehen beim erstmaligen Wechsel von altem Git-Updater-Code und bei FTP: UPDATE.md. Keine Schema-/Datenbankmigration. + +**Verwaltung:** Fehler 500 beim Öffnen von Bereichen behoben. Seiten/Beiträge mit eigener Navigation, datenbankseitiger Pagination (20/50), Suche, Status, begrenztem Papierkorb und Filtererhalt. Elternauswahl unabhängig von der Ergebnisseite. Menüeditor mit Suchauswahl per Tastatur, eigenen Links, klarer Reihenfolge, Haupt-/Unterpunkten und Rückgängig; Validierungs-/Verbindungs-/Versionsfehler erhalten Eingaben. Formularmeldung mehrzeilig über die verfügbare Breite; Felddefinitionen auf schmalen Bildschirmen als beschriftete Gruppen. Mobil zugänglicher Kontolink. + +**Bewegung:** Richtungen und Effekte werden beim Erscheinen tatsächlich unterschieden. Slider haben Pfeile, Zähler, optionale Punkte und Pause/Wiedergabe. Ein bereits geplanter Wechsel wird bei manueller Bedienung abgebrochen. Automatik wartet bei Hover, außerhalb des Bildschirms und in unsichtbaren Tabs; reduzierte Bewegung stoppt sie. Bewegung im Editor ausdrücklich testbar, iframe-Sandbox weiterhin ohne Skriptfreigabe. Kein JavaScript für die Lesbarkeit der Inhalte erforderlich. + +**Geprüft:** PHP 8.4.22 Vollsuite 461 Tests/3577 Assertions bestanden; anschließend 10/10 Tests des finalen Updaters mit 206 Assertions, einschließlich echter Datenbankänderung vor dem Fehler und ausgefallenem Restore. `npm.cmd test` 30/30. Alle 43 Twig-Dateien und Produktionscontainer gültig. Vollständiger `npm.cmd run test:editor` bestanden, einschließlich neuer Links-/Rechtseinblendung, reduzierter Bewegung, Drag-and-drop, Undo/Redo, Speicher-/Veröffentlichungs-/Konfliktabläufen und sämtlichen axe-Prüfungen. `node scripts/admin-quality-check.mjs --audit` bestanden: isolierte Kant-IT-Kopie mit 4 Seiten/29 Beiträgen; Pagination ohne Überschneidungen, leere Suche, Seitengrößen und Grenzwerte; Menüsuche per Tastatur, Hierarchie/Undo/Speicherung sowie 422/409 ohne Eingabeverlust; breite Formularmeldung nach Neuladen; echte gemeinsame Sektion mit Text; Kopf/Fuß ändern und neu laden; mehrfaches Slider-Autoplay, manuelle Pause, Fortsetzen, reduzierte Bewegung und bedienbare Sandbox-Vorschau. Desktop/1024/390 px ohne Seitenüberlauf oder axe-Verstoß. Zusätzliche Prüfung ohne JavaScript und visuelle Screenshots bestanden. Kunden-/Pilotdatenbank bleibt eine nur gelesene Quelle; Testkonten und Änderungen liegen ausschließlich in temporären Kopien, deren Hintergrundaufgaben abgeschaltet sind. + +**Öffentliche Messung:** Kant-IT-Startseite mit Originalmedien, Produktionsmodus, Lighthouse **13.4.1**, je drei Läufe: Leistung mobil **99/99/99**, Desktop **99/99/97**, Median jeweils **99**. Barrierefreiheit, Best Practices, SEO und Agentic Browsing in allen sechs Läufen **100**. Lokaler PHP-Server mit Kompression/Cachekopfzeilen über `bin/dev-router.php`; keine Garantie für Hosting oder beliebige Kundeninhalte. Berichte: `reports/admin-quality/lighthouse/summary.json`, einzelne Läufe daneben; Editor: `reports/editor/result.json`; Verwaltungsabläufe: `reports/admin-quality/result.json`. Screenshots der Verwaltung, der mobilen Formulardefinition und des Piloten visuell geprüft. + +**Grenzen / nächster Schritt:** Kein neuer Firefox-/Safari-/Screenreader- oder echter Touchgerätetest, keine CWV-Felddaten. Der Pilot enthält vereinzelt bereits gespeicherte HTML-Entities als Klartext. Die übrigen offenen Entwicklungsplan-Pakete bleiben bestehen. Installations- und Bedienkorrekturen nach Veröffentlichung über den vorhandenen Archiv-Updater auf der gewünschten Installation übernehmen. Arbeitsplan: ARBEITSPLAN-ADMIN-QUALITAET.md; Entscheidungen: ADR 076–078. + ## 1.6.2 – Sichtbare leere Sektionen und Spalten (14.09.2026) **Veröffentlicht:** Commit `6a044e5` und annotierter Tag `v1.6.2` sind auf `origin` hochgeladen. [Release 1.6.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.2) enthält ZIP, SHA-256-Datei und `install.php`. Aus dem exakten Commit mit Produktionsabhängigkeiten gebaut: 3521 Archivdateien, 199 Quelldateien gegen den Commit verglichen, Archivintegrität und Produktionscontainer geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und gegen die lokalen Dateien geprüft; 1.6.2 ist laut API das neueste stabile Release. ZIP-SHA-256: `6af093d752fafa76c720c203e1e3ced03e37ace3481a405b7d6eed378255e449`. Das Update ist bereitgestellt, nicht auf Kundenwebsites installiert. @@ -70,6 +84,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. - **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen. - **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt. - **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden. +- **System nennt die tatsächlich benutzte Datenbank und deren Quelle** (1.7.0, ADR 076): Art, Dateipfad, Größe, Schreibrecht und `.env` beziehungsweise Server-Umgebung. Die bereits lokal begonnene Diagnose wurde in den neuen Konfigurationsvertrag übernommen. Verdeckte Datei-Overlays entfallen; die bisher wirksamen Werte werden einmalig gesichert und in `.env` zusammengeführt. - **Das Update schreibt mit** (1.4.6, ADR 071): Jeder Schritt des Archiv-Updaters geht sofort nach `var/updates.log` – während des Laufs, nicht am Ende. Auf Umuts Hosting war die Ergebnisseite tot, und damit war auch verloren, was der Updater berichtet hatte; genau so wurde der nächste Fehlschlag dann doch noch lesbar („`bin` ließ sich nicht beiseitelegen – der alte Stand wurde zurückgeschoben"). Die Ergebnisseite versteht zusätzlich die Schreibweise bis 1.4.4: Beim Update schreibt der **alte** Code die Schritte und der **neue** zeigt sie an. - **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde. - **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`. diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 22ea6d7..036f8e3 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -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. diff --git a/package.json b/package.json index 1149d35..3acbf77 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "test:editor": "node scripts/editor-check.mjs", "test:browser": "node scripts/browser-check.mjs", "test:links": "node scripts/link-check.mjs", - "audit": "node scripts/audit.mjs" + "audit": "node scripts/audit.mjs", + "test:admin": "node scripts/admin-quality-check.mjs" }, "devDependencies": { "@axe-core/playwright": "^4.10.0", diff --git a/public/assets/admin.css b/public/assets/admin.css index 4cdd21c..3b894db 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -705,3 +705,29 @@ details.card > summary:hover { background: #fafbf8 } .shell-version { display: none } .shell-user strong { display: none } } + +/* Search and forms share usable widths throughout administration. */ +.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical} +.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px} +.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%} +.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none} +.picker-results{position:absolute;z-index:30;top:100%;left:0;right:0;min-width:220px;max-height:300px;overflow-y:auto;background:white;border:1px solid #a7b9af;border-radius:8px;box-shadow:0 10px 30px #18382b24;padding:5px} +.picker-results[hidden]{display:none}.picker-results button{display:flex;flex-direction:column;align-items:start;width:100%;padding:10px;text-align:left;color:#163d30;background:white;border:0;border-radius:5px} +.picker-results button:hover,.picker-results button[aria-selected=true]{background:#e8f2e9}.picker-results span{font-size:12px;color:#496458;overflow-wrap:anywhere}.picker-status:empty{display:none} +@media(max-width:650px){.list-toolbar>div,.list-toolbar .list-search{min-width:0;flex-basis:100%}.picker-results{min-width:0}.picker-input button{min-width:44px;min-height:44px}} + +/* One menu at a time: content picker on the left, ordered structure on the right. */ +.menu-slots{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 24px}.menu-slots a{padding:10px 16px;border:1px solid var(--line);border-radius:8px;text-decoration:none;background:white}.menu-slots a[aria-current=page]{background:var(--shell);color:white;border-color:var(--shell)} +.menu-workspace{display:grid;grid-template-columns:minmax(260px,320px) minmax(0,1fr);gap:24px;align-items:start}.menu-add{display:grid;gap:16px;position:sticky;top:20px}.menu-add .card{margin:0}.menu-add .content-picker{margin-bottom:16px}.menu-structure{min-width:0}.menu-items{list-style:none;padding:0;margin:24px 0;display:grid;gap:12px}.menu-entry{border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.menu-entry.menu-child{margin-left:32px;border-left:3px solid #8eaa99}.menu-entry summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px;cursor:pointer}.menu-entry summary::after{content:'⌄';font-size:18px}.menu-entry-title{overflow-wrap:anywhere;flex:1}.menu-entry-fields{padding:0 16px 16px}.menu-entry-fields .content-picker{margin-top:14px}.menu-entry-actions{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;border-top:1px solid var(--line);background:#f7f9f6;border-radius:0 0 9px 9px}.menu-entry-actions button{min-height:36px}.menu-entry-actions .danger-text{margin-left:auto}.menu-save{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding-top:16px;border-top:1px solid var(--line)}.menu-save [role=status]{font-size:13px;color:var(--muted)} +@media(max-width:1100px){.menu-workspace{grid-template-columns:minmax(220px,280px) minmax(0,1fr)}} +@media(max-width:850px){.menu-workspace{grid-template-columns:1fr}.menu-add{position:static;grid-template-columns:1fr 1fr}.menu-add>p{grid-column:1/-1}} +@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}} + +/* Field definitions stay editable on narrow screens instead of stretching the entire page. */ +.form-def{min-width:0}.field-table{table-layout:fixed;width:100%}.field-table input:not([type=checkbox]),.field-table select{width:100%;min-width:0;max-width:100%} +@media(max-width:1100px){ +.field-table,.field-table tbody{display:block}.field-table thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)} +.field-table tbody{display:grid;gap:16px}.field-table tr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px} +.field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left} +} +@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}} diff --git a/public/assets/editor.css b/public/assets/editor.css index f32b716..987c3f4 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -309,3 +309,5 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)} .design-context-toggle{margin:10px 0;width:100%;white-space:normal} @media(max-width:850px){.panel-resizer{display:none}.editor-layout{display:flex}.left-panel,.right-panel{width:50%}.canvas-scroll{padding:0}.left-panel>.panel-section:not(.structure-section){max-height:50%}} @media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}} + +.canvas-toolbar{flex-wrap:wrap;gap:8px}.canvas-toolbar #preview-motion{white-space:nowrap;min-height:36px} diff --git a/public/assets/editor.js b/public/assets/editor.js index b83c318..a3cc607 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -5,6 +5,8 @@ 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']; @@ -16,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; @@ -26,6 +29,21 @@ 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; @@ -56,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; @@ -77,6 +95,7 @@ function controls() { $('#select-page').disabled = !ready || busy; $('#add-root-section').disabled = !ready || busy; $('#open-media-library').disabled = !ready || busy; + $('#preview-motion').disabled = !ready || busy; } function renderTree() { @@ -440,7 +459,7 @@ function renderLegacyEffects(block, panel) { none: 'Keiner', lift: 'Leicht anheben', sink: 'Leicht absenken', zoom: 'Bild vergrößern', glow: 'Leuchten', tilt: 'Leicht kippen', 'grow-border': 'Rahmen einfahren', }], - ['entrance', 'Einblendung beim Laden', { + ['entrance', 'Einblendung beim Erscheinen', { none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten', sink: 'Von oben', left: 'Von links', right: 'Von rechts', zoom: 'Heranzoomen', flip: 'Kippen', blur: 'Aus der Unschärfe', }], @@ -453,7 +472,7 @@ function renderLegacyEffects(block, panel) { select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); }); panel.append(labelEl, select); } - const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder. Einblendungen laufen beim Laden der Seite, nicht beim Scrollen: eine öffentliche Seite führt kein JavaScript aus.'; panel.append(note); + const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note); } function chooseMedia(multiple) { @@ -654,6 +673,7 @@ async function updatePreview() { 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.'; @@ -663,6 +683,7 @@ async function updatePreview() { }; // 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. @@ -696,7 +717,7 @@ function changed() { 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({ @@ -1024,7 +1045,7 @@ $('#confirm-preset').addEventListener('click', async () => { const name = $('#preset-name').value.trim(); const error = $('#preset-error'); try { - const response = await fetch('/admin/api/style-presets', { + const response = await fetch(adminBase + '/api/style-presets', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': designSchema.csrfToken }, body: JSON.stringify({ name, style: pendingPreset }), diff --git a/public/assets/enhancements.css b/public/assets/enhancements.css index 4600a1a..8bfb066 100644 --- a/public/assets/enhancements.css +++ b/public/assets/enhancements.css @@ -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% } - } -} diff --git a/public/assets/media.js b/public/assets/media.js index db04aa6..fe88bc0 100644 --- a/public/assets/media.js +++ b/public/assets/media.js @@ -20,7 +20,7 @@ export function openMedia({ multiple = false, onChoose } = {}) { $('#media-insert').disabled = state || !selected.size; }; async function api(path = '', body) { - const response = await fetch('/admin/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) }); + const response = await fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) }); if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.'); const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`); diff --git a/public/assets/menu-editor.js b/public/assets/menu-editor.js new file mode 100644 index 0000000..2b6f2d5 --- /dev/null +++ b/public/assets/menu-editor.js @@ -0,0 +1,154 @@ +import { setupPicker } from './page-picker.js'; + +const form = document.querySelector('#menu-editor'); +const list = form.querySelector('#menu-items'); +const status = form.querySelector('#menu-status'); +const error = form.querySelector('#menu-error'); +const history = []; +let serial = list.children.length, pendingPage = null, busy = false; +const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; }; +const rows = () => [...list.children].map(row => { + const data = { level: Number(row.dataset.level) }; + row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.value; }); + data.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? ''; + data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? ''; + return data; +}); +const state = () => ({ name: form.elements.name.value, rows: rows() }); +const key = value => JSON.stringify(value); +let last = state(), saved = key(last); +const dirty = () => key(state()) !== saved; + +function refresh() { + [...list.children].forEach((row, index) => { + row.classList.toggle('menu-child', row.dataset.level === '2'); + row.querySelectorAll('[data-key]').forEach(input => { input.name = `items[${index}][${input.dataset.key}]`; }); + row.querySelector('[data-key="position"]').value = index; + row.querySelector('[data-key="level"]').value = row.dataset.level; + const picker = row.querySelector('.picker-value'); if (picker) picker.name = `items[${index}][pageId]`; + const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag'; + row.querySelector('.menu-entry-title').textContent = name; + row.querySelector('[data-menu-action="up"]').disabled = index === 0; + row.querySelector('[data-menu-action="down"]').disabled = index === list.children.length - 1; + row.querySelector('[data-menu-action="indent"]').disabled = index === 0 || row.dataset.level === '2' || list.children[index + 1]?.dataset.level === '2'; + row.querySelector('[data-menu-action="outdent"]').disabled = row.dataset.level === '1'; + row.querySelector('[role="group"]').setAttribute('aria-label', `${name} verschieben oder entfernen`); + row.querySelectorAll('button').forEach(button => { button.disabled ||= busy; }); + row.querySelector('[data-menu-action="up"]').setAttribute('aria-label', `${name} nach oben`); + row.querySelector('[data-menu-action="down"]').setAttribute('aria-label', `${name} nach unten`); + }); + form.querySelector('#menu-count').textContent = `${list.children.length} Einträge`; + form.querySelector('#menu-empty').hidden = Boolean(list.children.length); + form.querySelector('#menu-undo').disabled = !history.length || busy; + status.textContent = busy ? 'Wird gespeichert …' : dirty() ? 'Ungespeicherte Änderungen' : 'Gespeicherter Stand'; +} + +function createRow(data) { + const id = `new-${serial++}`; + const row = el('li', 'menu-entry'); row.dataset.level = String(data.level ?? 1); + const field = (key, value, label, type = 'text') => { + const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? ''; + input.id = `menu-${key}-${id}`; + if (type !== 'hidden') { const caption = el('label', '', label); caption.htmlFor = input.id; body.append(caption, input); } + else row.append(input); + return input; + }; + for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden'); + const details = el('details'); const summary = el('summary'); + summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'muted', data.type === 'page' ? 'Inhalt' : 'Eigener Link')); + const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details); + const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80; + if (data.type === 'page') { + const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready; + const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || ''; + picker.querySelector('label').htmlFor = input.id; picker.querySelector('label').textContent = 'Verknüpfter Inhalt'; + const options = picker.querySelector('[role="listbox"]'); options.id = `${input.id}-results`; options.replaceChildren(); options.hidden = true; + input.setAttribute('aria-controls', options.id); input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); + picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = ''; + body.append(picker); setupPicker(picker); + field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65; + } else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; } + const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group'); + for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) { + const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button); + } + row.append(actions); return row; +} + +function restore(value) { + form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state(); +} +function commit(before) { + refresh(); const after = state(); + if (key(before) !== key(after)) { history.push(before); if (history.length > 50) history.shift(); } + last = after; refresh(); +} +function add(data) { + if (list.children.length >= 60) { error.textContent = 'Ein Menü fasst höchstens 60 Einträge.'; error.hidden = false; return; } + const before = state(); const row = createRow(data); list.append(row); commit(before); + row.scrollIntoView({ block: 'nearest' }); row.querySelector('summary').focus(); +} + +const addPicker = form.querySelector('.menu-add .content-picker'); +addPicker.querySelector('.picker-value').addEventListener('change', () => { + const id = addPicker.querySelector('.picker-value').value; + pendingPage = id ? { id, display: addPicker.querySelector('[role="combobox"]').value } : null; + form.querySelector('#menu-add-page').disabled = !pendingPage; +}); +form.querySelector('#menu-add-page').addEventListener('click', () => { + if (pendingPage) add({ type: 'page', label: pendingPage.display.replace(/ \(\/.*\)$/, ''), pageId: pendingPage.id, pageDisplay: pendingPage.display, level: 1 }); +}); +form.querySelector('#menu-add-url').addEventListener('click', () => { + const name = form.querySelector('#link-label'), url = form.querySelector('#link-url'); + if (!name.value.trim() || !url.value.trim()) { error.hidden = false; error.textContent = 'Bitte Beschriftung und Adresse des Links ausfüllen.'; (!name.value.trim() ? name : url).focus(); return; } + error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), level: 1 }); name.value = url.value = ''; +}); +form.addEventListener('input', event => { if (event.target.closest('.menu-structure')) refresh(); }); +form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); }); +list.addEventListener('click', event => { + const button = event.target.closest('[data-menu-action]'); if (!button || busy) return; + const row = button.closest('.menu-entry'), before = state(), action = button.dataset.menuAction; + const entries = [...list.children], index = entries.indexOf(row); + const bundle = [row]; + if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]); + if (action === 'remove') { + row.remove(); + // Removing a heading never deletes its links: promote its children visibly. + bundle.slice(1).forEach(child => { child.dataset.level = '1'; }); + } + if (action === 'indent') row.dataset.level = '2'; + if (action === 'outdent') row.dataset.level = '1'; + if (action === 'up' && index > 0) { + let target = index - 1; + if (row.dataset.level === '1') while (target > 0 && entries[target].dataset.level === '2') target--; + bundle.forEach(item => list.insertBefore(item, entries[target])); + } + if (action === 'down') { + let target = index + bundle.length; + if (entries[target]) { + target++; + if (row.dataset.level === '1') while (entries[target]?.dataset.level === '2') target++; + bundle.forEach(item => list.insertBefore(item, entries[target] || null)); + } + } + if (list.firstElementChild) list.firstElementChild.dataset.level = '1'; + commit(before); if (row.isConnected) row.querySelector('summary').focus(); +}); +form.querySelector('#menu-undo').addEventListener('click', () => { const previous = history.pop(); if (previous) restore(previous); }); +window.addEventListener('beforeunload', event => { if (dirty()) { event.preventDefault(); event.returnValue = ''; } }); +form.noValidate = true; +form.addEventListener('submit', async event => { + event.preventDefault(); if (busy) return; + const invalid = form.querySelector(':invalid'); + if (invalid) { const details = invalid.closest('details'); if (details) details.open = true; invalid.reportValidity(); return; } + refresh(); const body = new FormData(form); const snapshot = key(state()); + busy = true; error.hidden = true; form.querySelectorAll('input,button').forEach(node => { node.disabled = true; }); refresh(); + try { + const response = await fetch(form.action, { method: 'POST', body, headers: { Accept: 'application/json' }, credentials: 'same-origin' }); + if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Die Änderungen bleiben hier erhalten. Bitte erneut anmelden.'); + const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Das Menü konnte nicht gespeichert werden.'); + form.elements.expectedVersion.value = result.version; saved = snapshot; + } catch (failure) { error.textContent = failure.message || 'Verbindung unterbrochen. Die Änderungen bleiben hier erhalten.'; error.hidden = false; } + finally { busy = false; form.querySelectorAll('input,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); } +}); +refresh(); last = state(); saved = key(last); diff --git a/public/assets/motion.js b/public/assets/motion.js new file mode 100644 index 0000000..bc7a84a --- /dev/null +++ b/public/assets/motion.js @@ -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(); diff --git a/public/assets/page-picker.js b/public/assets/page-picker.js new file mode 100644 index 0000000..90aa5eb --- /dev/null +++ b/public/assets/page-picker.js @@ -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)); diff --git a/public/assets/site.css b/public/assets/site.css index c3fb761..c8a406b 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -197,7 +197,12 @@ body.consent-open{padding-bottom:260px} .slider-snap>.slider-slide{scroll-snap-align:start} .slider-slide{min-width:0} .block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */ -.slider-dots{scroll-marker-group:after;scroll-behavior:smooth;position:relative;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots::-webkit-scrollbar{display:none}.slider-dots>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px} +.slider-dots{position:relative} +@supports selector(::scroll-marker){.slider-dots:not(.slider-controlled){scroll-marker-group:after;scroll-behavior:smooth;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots:not(.slider-controlled)::-webkit-scrollbar{display:none}.slider-dots:not(.slider-controlled)>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}} +.slider-controlled{scrollbar-width:none;scrollbar-gutter:auto}.slider-controlled::-webkit-scrollbar{display:none} +.slider-controls{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px;min-height:48px;margin-top:8px;padding:4px;font:14px/1.4 system-ui,sans-serif}.slider-controls button{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:40px;padding:8px 12px;border:1px solid #b2c2b8;border-radius:7px;background:#fff;color:#193c2d;cursor:pointer;font:inherit}.slider-controls button:focus-visible{outline:3px solid #b55b12;outline-offset:3px}.slider-controls button:hover{background:#e4efe7}.slider-controls button:disabled{opacity:.6;cursor:default}.slider-position{font-variant-numeric:tabular-nums;min-width:44px;text-align:center}.slider-control-dots{display:flex;flex-wrap:wrap;gap:3px}.slider-control-dots button{min-width:30px;padding:5px;border:0;background:transparent;color:inherit;opacity:.5}.slider-control-dots button[aria-pressed=true]{opacity:1;outline:1px solid currentColor;outline-offset:-5px}.slider-controls[hidden]{display:none} +@media(max-width:650px){.slider-controls button{min-height:44px;min-width:44px}} + .slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)} .slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)} .slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)} diff --git a/public/assets/slider.js b/public/assets/slider.js index 266f26f..ad6d597 100644 --- a/public/assets/slider.js +++ b/public/assets/slider.js @@ -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(); diff --git a/scripts/admin-quality-check.mjs b/scripts/admin-quality-check.mjs new file mode 100644 index 0000000..f4626b7 --- /dev/null +++ b/scripts/admin-quality-check.mjs @@ -0,0 +1,233 @@ +import { chromium } from 'playwright'; +import AxeBuilder from '@axe-core/playwright'; +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import net from 'node:net'; + +const php = process.env.PHP_BINARY || 'php'; +const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8' })); +execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory } }); +const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); +const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { + windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin }, +}); +await mkdir('reports/admin-quality', { recursive: true }); +let browser, page; +const checks = []; +try { + for (let i = 0; i < 60; i++) { + try { if ((await fetch(origin)).ok) break; } catch {} + if (i === 59) throw new Error('Pilot test server did not start'); + await new Promise(resolve => setTimeout(resolve, 200)); + } + browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'no-preference' }); page = await context.newPage(); + page.setDefaultTimeout(15000); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + page.on('dialog', dialog => dialog.accept()); + if (process.argv.includes('--visual-only')) { + await page.goto(origin); + await page.evaluate(async () => { + const images = [...document.images]; images.forEach(image => { image.loading = 'eager'; }); + await Promise.all(images.map(image => image.decode().catch(() => {}))); await document.fonts.ready; + }); + await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true, animations: 'disabled' }); + const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1440, height: 1000 } }); + const noScript = await plain.newPage(); await noScript.goto(origin); + assert.equal(await noScript.locator('h1').isVisible(), true); + assert.equal(await noScript.locator('#hero-slider .slider-slide').count(), 4); + assert.ok(await noScript.locator('main').innerText().then(text => text.includes('Dienstleistungen') || text.includes('Full-Service'))); + await noScript.screenshot({ path: 'reports/admin-quality/pilot-without-javascript.png', fullPage: true }); + await plain.close(); + await page.goto(origin + '/admin/login'); + await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password); + await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); + await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms'); + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('.field-table tbody tr').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' }); + console.log('Public no-JavaScript baseline and visual captures passed.'); + } else { + if (!process.argv.includes('--admin-only')) { + await page.goto(origin); await page.mouse.move(1, 1); + const slider = page.locator('#hero-slider'); + const geometry = () => slider.evaluate(el => ({ left: el.scrollLeft, width: el.clientWidth, total: el.scrollWidth, slides: el.children.length, columns: getComputedStyle(el).gridAutoColumns })); + const before = await geometry(); await page.waitForTimeout(8000); const after = await geometry(); + console.log('Pilot slider:', JSON.stringify({ before, after })); + if (process.argv.includes('--probe')) { + console.log('Motion:', await page.locator('[class*="fx-entrance-"]').evaluateAll(nodes => nodes.map(el => ({ id: el.id, name: getComputedStyle(el).animationName, duration: getComputedStyle(el).animationDuration, timeline: getComputedStyle(el).animationTimeline })))); + } else { + assert.ok(after.left > before.left + 20, 'the real pilot slider advances'); + } + await page.waitForTimeout(7100); const second = await geometry(); + console.log('Second slider:', JSON.stringify(second), await slider.locator('xpath=following-sibling::*[1]').innerText()); + assert.ok(second.left > after.left + 20, 'autoplay advances more than once'); + const controls = slider.locator('xpath=following-sibling::*[1]'); + await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click(); + await page.waitForTimeout(700); const manual = await geometry(); + console.log('Manual slider:', JSON.stringify(manual), await controls.innerText()); + assert.ok(manual.left < second.left - 20); + await page.mouse.move(1, 1); await page.waitForTimeout(7200); + console.log('Paused slider:', JSON.stringify(await geometry()), await controls.innerText()); + assert.ok(Math.abs((await geometry()).left - manual.left) < 2, 'manual navigation pauses autoplay'); + await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).click(); + await page.mouse.move(1, 1); await page.waitForTimeout(7700); + assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation'); + await page.emulateMedia({ reducedMotion: 'reduce' }); + assert.equal(await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).isDisabled(), true); + const reduced = await geometry(); await page.waitForTimeout(7100); + assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay'); + await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click(); + assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'manual navigation works with reduced motion'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + checks.push({ name: 'pilot slider: repeated autoplay, manual pause, resume, reduced motion', passed: true }); + await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true }); + } + if (process.argv.includes('--audit')) { + execFileSync(process.execPath, ['scripts/audit.mjs'], { stdio: 'inherit', windowsHide: true, env: { ...process.env, CMS_URL: origin, CMS_AUDIT_DIR: 'reports/admin-quality/lighthouse' } }); + } + await page.goto(origin + '/admin/login'); + await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password); + await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin'); + for (const [name, path] of [['pages', '/admin'], ['posts', '/admin/posts'], ['menus', '/admin/menus'], ['forms', '/admin/forms'], ['areas', '/admin/partials']]) { + const response = await page.goto(origin + path); assert.equal(response.status(), 200, path); + await page.screenshot({ path: `reports/admin-quality/${name}.png`, fullPage: true }); + const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name); + checks.push({ name, passed: true }); + } + await page.goto(origin + '/admin/posts'); + const rowLinks = () => page.locator('.rows article h3 a'); + assert.equal(await rowLinks().count(), 20); + const firstIds = await rowLinks().evaluateAll(links => links.map(link => link.href)); + await page.getByRole('navigation', { name: 'Ergebnisseiten oben', exact: true }).getByRole('link', { name: 'Weiter' }).click(); + assert.equal(await rowLinks().count(), 9); + assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); + assert.equal((await rowLinks().evaluateAll(links => links.map(link => link.href))).some(id => firstIds.includes(id)), false); + await page.getByLabel('Pro Seite', { exact: true }).selectOption('50'); + await page.getByRole('button', { name: 'Anwenden', exact: true }).click(); + assert.equal(await rowLinks().count(), 29); + await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff'); + await page.getByRole('button', { name: 'Anwenden', exact: true }).click(); + assert.equal(await rowLinks().count(), 0); + await page.goto(origin + '/admin/posts?seite=999'); assert.equal(await rowLinks().count(), 9); + await page.goto(origin + '/admin'); assert.equal(await rowLinks().count(), 4); + checks.push({ name: 'separate pages/posts, bounded pages, no overlap, size/search/clamping', passed: true }); + + await page.goto(origin + '/admin/menus'); + const entries = page.locator('#menu-items > li'); const initialCount = await entries.count(); + const search = page.getByRole('combobox', { name: 'Inhalte suchen' }); + await search.fill('Impressum'); + await page.getByRole('option').filter({ hasText: 'Impressum' }).first().waitFor(); + await search.press('ArrowDown'); await search.press('Enter'); + await page.getByRole('button', { name: 'Zum Menü hinzufügen', exact: true }).click(); + assert.equal(await entries.count(), initialCount + 1); + await entries.last().getByRole('button', { name: 'Als Unterpunkt', exact: true }).click(); + assert.equal(await entries.last().getAttribute('data-level'), '2'); + await entries.last().getByRole('button', { name: 'Entfernen', exact: true }).click(); + assert.equal(await entries.count(), initialCount); + await page.getByRole('button', { name: 'Rückgängig', exact: true }).click(); + assert.equal(await entries.count(), initialCount + 1); + assert.equal(await entries.last().getAttribute('data-level'), '2'); + const menuSave = () => Promise.all([ + page.waitForResponse(response => response.url().includes('/menus/header') && response.request().method() === 'POST'), + page.getByRole('button', { name: 'Menü speichern', exact: true }).click(), + ]).then(([response]) => response); + assert.equal((await menuSave()).status(), 200); + await page.reload(); assert.equal(await entries.count(), initialCount + 1); + assert.equal(await entries.last().getAttribute('data-level'), '2'); + await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click(); + await page.locator('#link-label').fill('QA Kontakt'); await page.locator('#link-url').fill('javascript:alert(1)'); + await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click(); + assert.equal((await menuSave()).status(), 422); + assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)'); + assert.equal(await page.locator('#menu-error').isVisible(), true); + await entries.last().locator('summary').click(); + await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt'); + assert.equal((await menuSave()).status(), 200); + const externalSave = await page.locator('#menu-editor').evaluate(async form => { + const response = await fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }); + return response.status; + }); + assert.equal(externalSave, 200); + await page.getByLabel('Name des Menüs').fill('QA lokaler Stand'); + assert.equal((await menuSave()).status(), 409); + assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand'); + assert.equal(await page.locator('#menu-error').isVisible(), true); + checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true }); + + await page.goto(origin + '/admin/forms'); + const form = page.locator('.form-def').first(), message = form.locator('[name="success"]'); + const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth })); + assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width'); + await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.'); + await form.getByRole('button', { name: 'Formular speichern', exact: true }).click(); + assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.'); + checks.push({ name: 'wide multiline form response persists', passed: true }); + + await page.goto(origin + '/admin/partials'); + await page.getByText('Neuen Bereich anlegen', { exact: true }).click(); + await page.getByLabel('Name', { exact: true }).fill('QA Gemeinsame Sektion'); + await page.getByRole('button', { name: 'Anlegen und bearbeiten', exact: true }).click(); + await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); + await page.locator('#structure .tree-item').first().click(); + await page.locator('#library [data-add="text"]').click(); + await page.getByLabel('Text', { exact: true }).fill('Dieser Inhalt wird als gemeinsame Sektion gespeichert.'); + await page.getByLabel('Text', { exact: true }).press('Tab'); + await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor(); + await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor(); + await page.reload(); + await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor(); + await page.goto(origin + '/admin/partials'); + const areaLinks = await rowLinks().evaluateAll(links => links.map(link => link.href)); + for (const url of areaLinks) { + assert.equal((await page.goto(url)).status(), 200); + await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); + await page.locator('#select-page').click(); + const title = page.getByLabel('Seitentitel', { exact: true }); + await title.fill((await title.inputValue()) + ' QA'); await title.press('Tab'); + await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor(); + await page.reload(); await page.locator('#select-page').click(); + await page.waitForFunction(() => document.querySelector('#save-status')?.textContent !== 'Wird geladen …'); + assert.match(await title.inputValue(), / QA$/); + } + checks.push({ name: 'real pilot areas open, edit, preview, save and reload', passed: true }); + + await page.goto(origin + '/admin/pages/' + fixture.homeId); + await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); + await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click(); + const preview = page.frameLocator('#preview'), previewSlider = preview.locator('#hero-slider'); + // Measure in the outer page: Playwright's in-frame animation stability check can stall in + // a no-script sandbox while a Web Animation runs. Use a real pointer on verified geometry. + const nextPreview = preview.getByRole('button', { name: 'Nächste Folie', exact: true }); + await nextPreview.waitFor(); await page.waitForTimeout(1500); + const previewBox = await nextPreview.boundingBox(); await page.waitForTimeout(150); + const settledBox = await nextPreview.boundingBox(); + assert.ok(Math.abs(settledBox.y - previewBox.y) < 1 && settledBox.width > 0, 'preview control has stable visible geometry'); + await page.mouse.click(settledBox.x + settledBox.width / 2, settledBox.y + settledBox.height / 2); + await page.waitForTimeout(800); + assert.ok(await previewSlider.evaluate(slider => slider.scrollLeft > 20)); + assert.equal(await page.locator('#preview').getAttribute('sandbox'), 'allow-same-origin'); + await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click(); + assert.equal(await preview.locator('.slider-controls').count(), 0); + checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true }); + + for (const width of [1024, 390]) { + await page.setViewportSize({ width, height: 844 }); + for (const route of ['/admin/posts', '/admin/menus', '/admin/forms']) { + await page.goto(origin + route); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`); + const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], `${route} ${width}`); + await page.screenshot({ path: `reports/admin-quality/${route.split('/').pop()}-${width}.png` }); + } + } + checks.push({ name: 'tablet/mobile layouts fit and pass axe', passed: true }); + assert.deepEqual(errors, []); + await writeFile('reports/admin-quality/result.json', JSON.stringify({ checks }, null, 2)); + console.log('Pilot and administration checks passed.'); + } +} catch (error) { + if (page) await page.screenshot({ path: 'reports/admin-quality/failure.png', fullPage: true }).catch(() => {}); + throw error; +} finally { await browser?.close(); server.kill(); } diff --git a/scripts/build-release.php b/scripts/build-release.php index e10da6a..37c8c43 100644 --- a/scripts/build-release.php +++ b/scripts/build-release.php @@ -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) { diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index ae0d534..9f1bf97 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -659,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'); diff --git a/src/Command/AdminPathCommand.php b/src/Command/AdminPathCommand.php index abc8419..4099c00 100644 --- a/src/Command/AdminPathCommand.php +++ b/src/Command/AdminPathCommand.php @@ -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; } diff --git a/src/Content/PageRepository.php b/src/Content/PageRepository.php index ee7d438..fb1fb16 100644 --- a/src/Content/PageRepository.php +++ b/src/Content/PageRepository.php @@ -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']); diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index 56538f1..e81a8bb 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -20,6 +20,7 @@ use App\Domain\StaleWrite; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; @@ -27,31 +28,53 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; #[Route('/%kant.admin_path%')] final class EditorController extends AbstractController { - public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf) + public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests) { } #[Route('', name: 'admin_pages', methods: ['GET'])] - public function index(): Response + #[Route('/posts', name: 'admin_posts', methods: ['GET'])] + public function index(Request $request): Response { - $pages = $this->pages->listForEditor(); + $type = 'admin_posts' === $request->attributes->get('_route') ? 'post' : 'page'; + $query = mb_substr(trim($request->query->getString('q')), 0, 160); + $status = $request->query->getString('status', 'all'); + $status = in_array($status, ['all', 'draft', 'published', 'changed', 'trash'], true) ? $status : 'all'; + $result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status); + $pages = $result['items']; + $parentChoices = []; foreach ($pages as &$page) { - $page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug']; + $page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR); + $page['title'] = $page['document']['title'] ?? $page['slug']; $page['depth'] = substr_count((string) $page['path'], '/'); $page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => '']; + if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) { + $parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null; + } } unset($page); return $this->render('admin/pages.html.twig', [ - 'pages' => $pages, - 'trash' => $this->pages->trash(), - 'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)), - 'postLayouts' => PostLayouts::all(), - // Which header and footer areas a page can choose from. + 'pages' => 'trash' === $status ? [] : $pages, + 'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [], + 'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(), + 'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status, + 'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages', 'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')], ]); } + #[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])] + public function contentChoices(Request $request): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_EDITOR'); + + return $this->json(['items' => $this->pages->choices( + mb_substr($request->query->getString('q'), 0, 160), + $request->query->getString('type'), $request->query->getBoolean('parents'), + )]); + } + #[Route('/pages', name: 'admin_create', methods: ['POST'])] public function create(Request $request): Response { @@ -317,8 +340,26 @@ final class EditorController extends AbstractController private function back(string $message, string $type = 'error'): Response { $this->addFlash($type, $message); + $request = $this->requests->getCurrentRequest(); + $post = 'post' === $request?->request->getString('_list_type', $request?->request->getString('type', 'page')); - return $this->redirectToRoute('admin_pages'); + if (!$request?->request->has('_list_type')) { + return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages'); + } + + $params = [ + 'q' => $request?->request->getString('_list_query', ''), + 'status' => $request?->request->getString('_list_status', 'all'), + 'seite' => $request?->request->getInt('_list_page', 1), + 'limit' => $request?->request->getInt('_list_limit', 20), + ]; + foreach (['q' => '', 'status' => 'all', 'seite' => 1, 'limit' => 20] as $key => $default) { + if ($params[$key] === $default) { + unset($params[$key]); + } + } + + return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages', $params); } #[Route('/pages/{id}', name: 'admin_editor', methods: ['GET'])] diff --git a/src/Controller/MenuController.php b/src/Controller/MenuController.php index 7594c58..763f976 100644 --- a/src/Controller/MenuController.php +++ b/src/Controller/MenuController.php @@ -18,24 +18,24 @@ final class MenuController extends AbstractController } #[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])] - public function index(): Response + public function index(Request $request): Response { - $menus = $this->menus->all(); - $rows = []; - foreach (MenuRepository::SLOTS as $slot => $label) { - $rows[$slot] = $this->flatten($menus[$slot]['items'] ?? []); - } - $pages = []; - foreach ($this->pages->listForEditor() as $page) { - $document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR); - $pages[] = ['id' => $page['id'], 'path' => $page['path'], 'title' => $document['title'] ?? $page['slug']]; + $slot = $request->query->getString('slot', 'header'); + $slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header'; + $menu = $this->menus->all()[$slot] ?? null; + $rows = $this->flatten($menu['items'] ?? []); + $selected = []; + foreach ($rows as &$row) { + if ($row['pageId']) { + $selected[$row['pageId']] ??= $this->pages->choices(id: $row['pageId'])[0] ?? ['id' => $row['pageId'], 'title' => 'Nicht mehr verfügbar', 'path' => '']; + } + $row['page'] = $selected[$row['pageId']] ?? null; } + unset($row); return $this->render('admin/menus.html.twig', [ - 'slots' => MenuRepository::SLOTS, - 'menus' => $menus, - 'rows' => $rows, - 'pages' => $pages, + 'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot], + 'menu' => $menu, 'rows' => $rows, ]); } @@ -44,7 +44,7 @@ final class MenuController extends AbstractController { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) { - return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + return 'json' === $request->getPreferredFormat() ? $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.'], 403) : $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', slot: $slot); } try { $this->menus->save( @@ -54,10 +54,14 @@ final class MenuController extends AbstractController $request->request->getInt('expectedVersion', -1), ); } catch (InvalidMenu|StaleWrite $e) { - return $this->back($e->getMessage()); + return 'json' === $request->getPreferredFormat() ? $this->json(['error' => $e->getMessage()], $e instanceof StaleWrite ? 409 : 422) : $this->back($e->getMessage(), slot: $slot); } - return $this->back('Das Menü wurde gespeichert.', 'success'); + if ('json' === $request->getPreferredFormat()) { + return $this->json(['message' => 'Menü gespeichert.', 'version' => $this->menus->all()[$slot]['version']]); + } + + return $this->back('Das Menü wurde gespeichert.', 'success', $slot); } /** @@ -122,10 +126,10 @@ final class MenuController extends AbstractController return $items; } - private function back(string $message, string $type = 'error'): Response + private function back(string $message, string $type = 'error', string $slot = 'header'): Response { $this->addFlash($type, $message); - return $this->redirectToRoute('admin_menus'); + return $this->redirectToRoute('admin_menus', 'header' === $slot ? [] : ['slot' => $slot]); } } diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index fd7575d..94e4878 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -50,10 +50,14 @@ final class PageController extends AbstractController $localFonts = []; // The page decides which header and footer it gets - including none. $areas = $this->areas($page); + $needsSlider = $needsMotion = false; if (null !== $page) { $theme = $this->theme->load(); // Theme first, block styles after, so a block always wins over the site defaults. $sections = $this->sections($page['document'], $areas); + $documents = [$page['document'], ...array_values($areas), ...array_values($sections)]; + $needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider')); + $needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc)); $styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']); foreach ([...array_values($areas), ...array_values($sections)] as $extra) { $styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra); @@ -88,7 +92,8 @@ final class PageController extends AbstractController 'needsTabs' => $this->hasType($page['document'], 'tabs'), // Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der // dafür ein Skript braucht - nach demselben Muster wie die Tabs. - 'needsSlider' => $this->hasAutoplaySlider($page['document']), + 'needsSlider' => $needsSlider, + 'needsMotion' => $needsMotion, // Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die // grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat. 'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)), @@ -124,7 +129,7 @@ final class PageController extends AbstractController // Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur // dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website. $mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form'); - $wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document'])); + $wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document'])); $frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections)); if ($mitTurnstile) { $frames[] = 'challenges.cloudflare.com'; @@ -397,21 +402,19 @@ final class PageController extends AbstractController return $gefunden; } - /** True when a slider on this page asked to advance by itself. */ - private function hasAutoplaySlider(array $document): bool + private function hasEntrance(array $document): bool { - $found = false; - $walk = function (array $blocks) use (&$walk, &$found): void { + $walk = function (array $blocks) use (&$walk): bool { foreach ($blocks as $block) { - if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) { - $found = true; + if ('none' !== ($block['layout']['effects']['entrance'] ?? 'none') || $walk($block['children'] ?? [])) { + return true; } - $walk($block['children'] ?? []); } - }; - $walk($document['blocks'] ?? []); - return $found; + return false; + }; + + return $walk($document['blocks'] ?? []); } private function hasType(array $document, string $type): bool diff --git a/src/Controller/SystemController.php b/src/Controller/SystemController.php index 43b0ab9..cc6d7d5 100644 --- a/src/Controller/SystemController.php +++ b/src/Controller/SystemController.php @@ -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 { diff --git a/src/Ops/Environment.php b/src/Ops/Environment.php new file mode 100644 index 0000000..b3366ba --- /dev/null +++ b/src/Ops/Environment.php @@ -0,0 +1,112 @@ +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.'); + } + } +} diff --git a/src/Ops/Installation.php b/src/Ops/Installation.php index 20bbd93..7bac48b 100644 --- a/src/Ops/Installation.php +++ b/src/Ops/Installation.php @@ -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); } /** diff --git a/src/Ops/Updater.php b/src/Ops/Updater.php index ab80938..33016ec 100644 --- a/src/Ops/Updater.php +++ b/src/Ops/Updater.php @@ -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)), diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index b3fc8fc..e3a39ab 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -4,6 +4,7 @@ + {% block title %}Verwaltung{% endblock %} · KantSites {% include 'favicon.html.twig' %} @@ -17,7 +18,7 @@ Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie nicht mehr. #} diff --git a/templates/admin/content-picker.html.twig b/templates/admin/content-picker.html.twig new file mode 100644 index 0000000..06baf24 --- /dev/null +++ b/templates/admin/content-picker.html.twig @@ -0,0 +1,11 @@ +{% set selected = selected|default(null) %} +
+ +
+ + +
+ + +

+
diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index 1bb9200..f2cbb83 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -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 %}{% endblock %} @@ -8,12 +9,12 @@ {% block body %} {# The API base decides what is edited. An area is shared, so it has no draft and no publication: saving is live, and the publish button is left out. #} -
+
- ← {{ area ? 'Bereiche' : 'Seiten' }} + ← {{ area ? 'Bereiche' : (page.type == 'post' ? 'Beiträge' : 'Seiten') }}

{{ subject.title }}

{{ area ? kinds[area.kind]|default('Bereich') : '/' ~ (page.slug == 'home' ? '' : page.slug) }}
-
Wird geladen …{% if is_granted('ROLE_PUBLISHER') %}{% endif %}
+
Wird geladen …{% if not area and is_granted('ROLE_PUBLISHER') %}{% endif %}
@@ -36,13 +37,13 @@
-
LIVE-VORSCHAU
↗
+
LIVE-VORSCHAU
{% if not area %}↗{% endif %}

Vorschau wird geladen …

- +

Seite veröffentlichen?

Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.

diff --git a/templates/admin/forms.html.twig b/templates/admin/forms.html.twig index ffd55fe..593ca4a 100644 --- a/templates/admin/forms.html.twig +++ b/templates/admin/forms.html.twig @@ -40,9 +40,9 @@

Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.

-
+
- +
@@ -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 %} - - + + - - - - + + + + {% endfor %} diff --git a/templates/admin/menu-entry.html.twig b/templates/admin/menu-entry.html.twig new file mode 100644 index 0000000..f9eaedf --- /dev/null +++ b/templates/admin/menu-entry.html.twig @@ -0,0 +1,24 @@ + diff --git a/templates/admin/menus.html.twig b/templates/admin/menus.html.twig index 7dca2ce..26a1152 100644 --- a/templates/admin/menus.html.twig +++ b/templates/admin/menus.html.twig @@ -2,55 +2,34 @@ {% set nav = 'menus' %} {% block title %}Menüs{% endblock %} {% block heading %}Menüs{% endblock %} -{% block lead %}Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine externe Adresse. Die Reihenfolge ergibt sich aus der Position, die Verschachtelung aus der Ebene.{% endblock %} +{% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %} +{% block scripts %}{% endblock %} {% block content %} -{% for slot, slotName in slots %} -{% set menu = menus[slot]|default(null) %} -
-

{{ slotName }} {{ rows[slot]|length }}

Platz {{ slot }}
- -
-{% endfor %} -

KantSites · Arbeitsstand

+ + {% endblock %} diff --git a/templates/admin/pager.html.twig b/templates/admin/pager.html.twig index 728765c..4b74b5c 100644 --- a/templates/admin/pager.html.twig +++ b/templates/admin/pager.html.twig @@ -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 %} {% endif %} diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index fbf1df0..223da2e 100644 --- a/templates/admin/pages.html.twig +++ b/templates/admin/pages.html.twig @@ -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 %}{% 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') %}
- Neue Seite oder neuen Beitrag anlegen + {{ contentType == 'post' ? 'Neuen Beitrag' : 'Neue Seite' }} anlegen
+
@@ -21,36 +25,33 @@

Kleinbuchstaben, Ziffern und Bindestriche, zum Beispiel ueber-uns. Leer lassen, um sie aus dem Titel zu bilden.

+ + {% if contentType == 'post' %}
- - -

Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.

-
-
- +

{% for key, layout in postLayouts %}{{ layout.name }}: {{ layout.description }}{% if not loop.last %}
{% endif %}{% endfor %} Jeder Block lässt sich danach ändern.

-
- - -

Bestimmt die Adresse, zum Beispiel /leistungen/webdesign.

-
+ {% else %} + {% include 'admin/content-picker.html.twig' with {id: 'new-parent', name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true} %} + {% endif %}
{% endif %} -

Alle Seiten {{ pages|length }}

Untergeordnete Seiten stehen eingerückt.
+ +

{{ status == 'trash' ? 'Papierkorb' : plural }} {{ result.total }}

{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}
+{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
{% for page in pages %} {# Indentation by class, not by style attribute: the administration forbids inline styles, so @@ -85,7 +86,8 @@

{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}

- + +
@@ -143,7 +145,8 @@

Adresse

- + +
@@ -151,13 +154,7 @@
- - + {% 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)} %}

Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.

@@ -171,7 +168,8 @@

Kopf- und Fußbereich

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.

- + +
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %} @@ -196,7 +194,8 @@

Veröffentlichung planen

- + +
@@ -214,7 +213,8 @@
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %} - + + @@ -224,7 +224,8 @@ offered this. #}
- + +
@@ -239,12 +240,12 @@ {% endif %} {% else %} -

Noch keine Seiten vorhanden.

Lege oben deine erste Seite an.

+ {% if status != 'trash' %}

{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}

{% if result.total == 0 %}

{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}

{% endif %}
{% endif %} {% endfor %}
{% if trash is not empty %} -

Papierkorb {{ trash|length }}

Nicht öffentlich erreichbar, alle Fassungen erhalten.
+
{% for entry in trash %}
@@ -256,7 +257,8 @@ {% if is_granted('ROLE_EDITOR') %}
- + +
@@ -266,5 +268,6 @@
{% endif %} -

KantSites · Arbeitsstand

+{% if status == 'trash' and trash is empty %}

Der Papierkorb ist leer.

{% endif %} +{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %} {% endblock %} diff --git a/templates/admin/partials.html.twig b/templates/admin/partials.html.twig index 0c6bd60..85d503d 100644 --- a/templates/admin/partials.html.twig +++ b/templates/admin/partials.html.twig @@ -19,7 +19,7 @@ -

Kopf- und Fußbereich gibt es je einmal.

+

Kopf- und Fußbereiche können je Seite zugewiesen werden. Sektionen lassen sich auf mehreren Seiten verknüpfen.

diff --git a/templates/admin/shell.html.twig b/templates/admin/shell.html.twig index 5232f8b..a9e140b 100644 --- a/templates/admin/shell.html.twig +++ b/templates/admin/shell.html.twig @@ -20,7 +20,7 @@ {{ { - 'pages': 'Seiten', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek', + 'pages': 'Seiten', 'posts': 'Beiträge', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek', 'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design', 'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer', 'account': 'Mein Konto' @@ -31,6 +31,7 @@

Fassung {{ kant_version() }}

- {{ app.user.userIdentifier }} + {{ app.user.userIdentifier }}
diff --git a/templates/admin/system.html.twig b/templates/admin/system.html.twig index fcc7522..924e1ed 100644 --- a/templates/admin/system.html.twig +++ b/templates/admin/system.html.twig @@ -22,6 +22,18 @@ – geändert. Sie wird in der robots.txt nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit php bin/console cms:admin:path admin. {% endif %} +
Datenbank
+
+ {{ database.art }} + {% if database.datei %} + {{ database.datei }} + {% if database.bytes %}· {{ (database.bytes / 1048576)|number_format(1, ',', '.') }} MB{% endif %} + {% if not database.schreibbar %}
Nicht beschreibbar. Nach einem Upload per FTP gehört die Datei dem FTP-Benutzer; PHP läuft als ein anderer. SQLite braucht Schreibrechte auf die Datei und auf den Ordner darüber.{% endif %} + {% else %} + {{ database.adresse }} + {% endif %} +
Eingestellt in {{ database.quelle }}. Die Installationskonfiguration steht in .env. Explizite Server-Umgebungsvariablen haben Vorrang. +
{% if state.worktree %}
Stand
{{ state.describe }}{% if state.commit != 'unbekannt' %} · Commit {{ state.commit }}{% endif %}
{% else %} diff --git a/templates/page.html.twig b/templates/page.html.twig index a3aa964..b698b3c 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -47,7 +47,8 @@ {% if hasPosts|default(false) %} {% endif %} {% if needsTabs|default(false) %}{% endif %} - {% if needsSlider|default(false) %}{% endif %} + {% if needsSlider|default(false) %}{% endif %} + {% if needsMotion|default(false) %}{% endif %} {% if needsZoom|default(false) %}{% endif %} {# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must show the layout, not run code that could change the page under the editor's hands. #} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 736556a..a1e9427 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -85,6 +85,43 @@ final class CmsTest extends TestCase } } + public function testEditorPaginationAndChoicesKeepTypesFiltersAndParentLookup(): void + { + $ids = []; + for ($i = 0; $i < 43; ++$i) { + $document = DemoContent::pages()['home']; + $document['title'] = 'Eintrag '.sprintf('%02d', $i); + $ids[] = $this->pages->createPage('eintrag-'.sprintf('%02d', $i), null, 'post', $document); + } + $first = $this->pages->editorPage('post'); + $second = $this->pages->editorPage('post', 2); + self::assertSame(43, $first['total']); + self::assertCount(20, $first['items']); + self::assertCount(20, $second['items']); + self::assertSame([], array_intersect(array_column($first['items'], 'id'), array_column($second['items'], 'id'))); + self::assertCount(3, $this->pages->editorPage('post', 999)['items']); + self::assertSame(3, $this->pages->editorPage('post', 999)['page']); + self::assertCount(43, $this->pages->editorPage('post', -1, 50)['items']); + self::assertSame(2, $this->pages->editorPage('page')['total']); + self::assertSame(1, $this->pages->editorPage('post', query: 'Eintrag 42')['total']); + self::assertSame(0, $this->pages->editorPage('post', query: '%')['total'], 'LIKE wildcards are literal search text.'); + self::assertSame(0, $this->pages->editorPage('post', query: '_')['total']); + $saved = $this->pages->findForEditor($ids[0]); + $this->pages->publish($ids[0], $saved['draft_revision_id'], $saved['version']); + self::assertSame(1, $this->pages->editorPage('post', status: 'published')['total']); + self::assertSame(42, $this->pages->editorPage('post', status: 'draft')['total']); + $this->pages->saveDraft($ids[0], DemoContent::pages()['home'], $saved['version'] + 1); + self::assertSame(1, $this->pages->editorPage('post', status: 'changed')['total']); + $this->db->update('page', ['deleted_at' => gmdate('Y-m-d H:i:s')], ['id' => $ids[42]]); + self::assertSame(42, $this->pages->editorPage('post')['total']); + self::assertSame(1, $this->pages->editorPage('post', status: 'trash')['total']); + self::assertCount(20, $this->pages->choices(type: 'post')); + self::assertSame([], $this->pages->choices(id: $ids[42])); + self::assertCount(2, $this->pages->choices(parentsOnly: true)); + self::assertSame('page-2', $this->pages->choices(id: 'page-2', parentsOnly: true)[0]['id']); + self::assertSame([], $this->pages->choices(id: $ids[1], parentsOnly: true)); + } + public function testDocumentRoundTripAndDraftPrivacy(): void { $document = DemoContent::pages()['home']; @@ -690,11 +727,8 @@ final class CmsTest extends TestCase } } - public function testASliderAdvancesByItselfOnlyWhenItWasAskedTo(): void + public function testSliderControlsLoadOnlyForPagesWithSliders(): void { - // Autorotation braucht ein Skript - das einzige auf einer öffentlichen Seite außer den - // Tabs. Es kommt nur auf Seiten, die einen solchen Slider tatsächlich enthalten, und die - // Richtlinie bleibt sonst bei script-src 'none'. $document = DemoContent::starter('Mit Slider'); $document['schemaVersion'] = 17; $document['blocks'][] = ['id' => 'slider', 'type' => 'slider', 'props' => [ @@ -703,22 +737,26 @@ final class CmsTest extends TestCase $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); $ohne = $this->request('/'); - self::assertStringNotContainsString('slider.js', $ohne->getContent()); - self::assertStringContainsString("script-src 'none'", (string) $ohne->headers->get('Content-Security-Policy')); + self::assertStringContainsString('slider.js?v=', $ohne->getContent()); + self::assertStringNotContainsString('data-autoplay=', $ohne->getContent()); + self::assertStringContainsString("script-src 'self'", (string) $ohne->headers->get('Content-Security-Policy')); $document['blocks'][array_key_last($document['blocks'])]['props']['autoplay'] = 7; $revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']); $this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']); $mit = $this->request('/'); - self::assertStringContainsString('