Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81276ddd1a | ||
|
|
d300eae241 | ||
|
|
a3029966cb | ||
|
|
a108e7d143 | ||
|
|
55dab4bc1e | ||
|
|
c1b152b376 | ||
|
|
8f475d1374 | ||
|
|
ab86312e87 | ||
|
|
16da844934 | ||
|
|
a3c89bb69d | ||
|
|
25fd388f80 | ||
|
|
e18c881db8 | ||
|
|
6d21c452bd | ||
|
|
b8991ddd5c | ||
|
|
0920e3cb83 | ||
|
|
d78a58f714 | ||
|
|
41d87f62a4 | ||
|
|
11b60fc5d8 |
No files matched your search
@@ -22,8 +22,22 @@ Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht op
|
||||
|
||||
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||
|
||||
## Eigene 404-Seite (ab 1.8.0)
|
||||
|
||||
Unter **Einstellungen → 404-Seite** eine vorhandene Seite per Livesuche auswählen und **404-Zuordnung speichern**. Oder **404-Vorlage anlegen** anklicken, den angelegten Entwurf im Editor gestalten und veröffentlichen. Die Ausgabe übernimmt den Standard-Header, Standard-Footer, Schriften und Farben der Website. **404-Vorschau** öffnet die tatsächlich sichtbare Fehlerseite.
|
||||
|
||||
Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website-Design. Entwürfe bleiben privat; Umbenennen erhält die Zuordnung, nach dem Löschen greift wieder der Standard. Das Entfernen der Auswahl setzt die Zuordnung zurück und erhält den Seiteninhalt. Die Zuordnung benötigt Veröffentlichungsrechte. Unbekannte URLs bleiben unverändert und antworten mit HTTP 404; die Fehlerseite wird nicht in Sitemap/llms.txt aufgenommen. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:404`, Berichte und Screenshots unter `reports/not-found/`. Datenvertrag und Rückweg: ADR 082.
|
||||
|
||||
## Anmelden und bearbeiten
|
||||
|
||||
**Bildausrichtung (ab 1.8.4):** Bild auswählen → Design → Layout → **Eigene Ausrichtung quer: Mitte**. Zentriert den Bildblock und auch kleinere Bilder innerhalb des Rahmens. Funktioniert mit Prozentbreite, maximaler Breite und Bildlinks; Anfang/Ende und Gerätewerte werden ebenfalls berücksichtigt. In nebeneinander angeordneten Flex-Gruppen bleibt die Querachse vertikal. Prüfen: `npm run test:image-alignment` auf einer isolierten Kant-IT-Kopie; Screenshots unter `reports/image-alignment/`.
|
||||
|
||||
**Akkordeon-Inhalte (ab 1.8.3):** Einträge bleiben im Editor geöffnet. Im Eintrag auf **Inhalt hinzufügen** klicken und links Text, Bild oder ein anderes Element auswählen; alternativ direkt in die sichtbare Inhaltsfläche ziehen. **Eintrag hinzufügen** steht unter dem Akkordeon und in dessen Inhaltsoptionen. Die Eintragsübersicht rechts und die Struktur links zeigen die Titel. **Beim Laden geöffnet** bestimmt ausschließlich den Zustand der veröffentlichten Seite. Prüfen: `npm run test:accordion`, Screenshots unter `reports/accordion/`.
|
||||
|
||||
**Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`.
|
||||
|
||||
**Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position.
|
||||
|
||||
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
|
||||
|
||||
```powershell
|
||||
@@ -34,6 +48,10 @@ Das Passwort wird verdeckt zweimal abgefragt (14–72 Bytes). Bestehende Benutze
|
||||
|
||||
Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzufügen/auswählen, in der Mitte die Vorschau, rechts Eigenschaften. Die Auswahl ist zwischen Strukturbaum und Vorschau synchron. Sektion, Zeile, Spalte, Überschrift, Text, Bild, Button und Galerie sind vorhanden. Bilder können aus der Mediathek oder über vorhandene lokale/HTTPS-Adressen eingebunden werden.
|
||||
|
||||
**Bild prozentual verkleinern (ab 1.7.2):** Bild auswählen → Inhalt → **Bildbreite (%)**. Regler oder Zahlenfeld setzen 1–100 % der verfügbaren Breite in Sektion/Spalte. Desktop, Tablet und Mobil können eigene Werte erhalten; kleinere Geräte übernehmen zunächst den Wert vom größeren Gerät. „Breite zurücksetzen“ entfernt den eigenen Wert und stellt die Vererbung beziehungsweise automatische Breite wieder her. Originalbreite/-höhe beschreiben weiterhin die Datei. Der Regler bearbeitet denselben Wert wie Design → Größe → Breite; eine dort gesetzte maximale Breite bleibt wirksam. Prüfung mit kopierten Kant-IT-Daten: `npm run test:image-size`, Bericht und Screenshots unter `reports/image-size/`.
|
||||
|
||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
|
||||
@@ -153,6 +171,8 @@ Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfol
|
||||
|
||||
**Seiten** und **Beiträge** sind getrennte Menüpunkte. Suche, Status einschließlich Papierkorb und 20/50 Einträge begrenzen jede Ergebnisliste bereits in der Datenbank. Vor/Zurück erhält die Filter; übergeordnete Seiten lassen sich unabhängig von der aktuellen Ergebnisseite suchen.
|
||||
|
||||
Ab **1.7.1** aktualisieren sich Seiten, Beiträge und Mediathek beim Tippen per AJAX, ohne Enter oder Neuladen. Status, Anzahl und Blättern reagieren ebenfalls direkt. Der Cursor bleibt im Suchfeld; Zurück/Vor im Browser stellt die Filter wieder her. Die Mediathek sucht in Titel und Alternativtext über alle Ergebnisseiten. Menüs, Seitenauswahl und Editor-Suchfelder reagieren ebenfalls beim Tippen. Bei Ladefehlern bleiben die bisherigen Treffer sichtbar und „Erneut suchen“ wiederholt die Anfrage. Ohne JavaScript bleibt das Suchformular mit „Anwenden“ benutzbar.
|
||||
|
||||
Unter **Menüs** zuerst den Menüplatz wählen, links Inhalte suchen und zum Menü hinzufügen. Rechts lassen sich Beschriftung, Ziel, Reihenfolge und Unterpunkte bearbeiten. Pfeilbuttons sind per Tastatur erreichbar; Rückgängig stellt entfernte oder umgeordnete Einträge wieder her. Erst **Menü speichern** ändert die Website. Validierungs-, Verbindungs- und Versionsfehler erhalten die lokalen Eingaben. Ein paralleler Speicherstand muss bewusst neu geladen werden.
|
||||
|
||||
**Bereiche** verwenden denselben Editor; Speichern wirkt sofort auf allen verknüpften Seiten. Unter **Formulare** ist die Meldung nach dem Absenden ein mehrzeiliges Feld über die verfügbare Formularbreite.
|
||||
@@ -164,3 +184,5 @@ Beim ersten Start von 1.7.0 wird eine vorhandene `.env.local` einmalig mit `.env
|
||||
Weitere Symfony-Overlays (`.env.prod`, `.env.prod.local`, `.env.local.php`) werden nicht mehr automatisch geladen. Falls ein Hosting sie bewusst verwendet hat, deren gewünschte Werte vor dem Wechsel in `.env` oder echte Servervariablen übernehmen. Der Installer, Setup und der Admin-Pfad-Befehl erzeugen keine `.env.local` mehr. Rückweg: alten Code und die beiden gesicherten Konfigurationsdateien gemeinsam wiederherstellen.
|
||||
|
||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||
|
||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||
@@ -40,6 +40,9 @@ services:
|
||||
App\EventSubscriber\AdminHeadersSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\EventSubscriber\PublicNotFoundSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\Statistics:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
|
||||
@@ -58,3 +58,5 @@ Geprüft: PHP-Vollsuite 461/461, JavaScript 30/30, alle 43 Twig-Dateien und Prod
|
||||
Kant-IT-Startseite, Produktionsmodus, Lighthouse 13.4.1: jeweils drei Läufe mobil/desktop, Leistungsmedian jeweils 99; Barrierefreiheit, Best Practices, SEO und Agentic Browsing jeweils 100. Lokale Labormessung, keine Felddaten/CWV-Abnahme für Kundenseiten. Messungen: `reports/admin-quality/lighthouse/`; Browserbericht und Bilder im übergeordneten Ordner. Der zusätzliche Updater-Test verändert die Datenbank vor einem Migrationsfehler, prüft echte Wiederherstellung und einen erhaltenen Wartungszustand, falls die Sicherung fehlt.
|
||||
|
||||
Veröffentlichungsnachweis und bekannte Grenzen stehen im aktuellen Abschnitt von STATUS.md.
|
||||
|
||||
**Veröffentlicht:** `495b268`, Tag `v1.7.0`, Release mit ZIP/Prüfsumme/Installer. Quellabgleich und anonyme Downloads geprüft; frischer Release-Installer antwortet mit HTTP 200 und erzeugt ausschließlich `.env`.
|
||||
@@ -2,6 +2,82 @@
|
||||
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 086 – Bildausrichtung berücksichtigt Rahmen, Bild und Layoutachse (14.09.2026)
|
||||
|
||||
`align-self` allein hat in normalen Inhaltsbereichen keine Wirkung. Ein prozentual schmaler Bildrahmen blieb links; bei voller Rahmenbreite konnte zusätzlich das kleinere Bild im Rahmen links stehen. Der Browserfall mit einem 50-Prozent-Bild in einer gepolsterten Spalte reproduzierte die Abweichung trotz ausgewählter Mitte.
|
||||
|
||||
Der gemeinsame StyleCompiler ergänzt bei Bildern mit eigener Ausrichtung horizontale Außenabstände am Rahmen und am enthaltenen Bild sowie `justify-self` für Raster. Verlinkte und vergrößerbare Bilder werden über dasselbe innere Bild erreicht. In horizontalen Flex-Gruppen bleiben die Außenabstände am Rahmen erhalten: Dort wirkt die vorhandene Einstellung auf der vertikalen Querachse und soll die horizontale Verteilung nicht verändern. Sektionen, Akkordeon-Einträge und Slider haben innere Inhaltswrapper, deren Blockfluss berücksichtigt wird.
|
||||
|
||||
Bild- und Elterneinstellungen werden je Gerät geerbt. Änderungen der Flexrichtung werden deshalb auch bei unverändertem Bildwert wirksam. Auto, Strecken und Grundlinie entfernen die zusätzliche horizontale Bildausrichtung und stellen die jeweiligen selbst gesetzten Außenabstände wieder her. Originalmaße, Bildquellen, Bildbreite, Beschriftungen und Links werden nicht umgeschrieben. Beliebiges benutzerdefiniertes CSS kann diese Layoutannahmen weiterhin überschreiben.
|
||||
|
||||
**Migration und Rückweg:** Keine Änderung am Dokument- oder Datenbankschema. Bestehende gespeicherte Ausrichtungswerte werden beim Rendern korrigiert, ohne erneutes Speichern. Vorschau und öffentliche Ausgabe verwenden denselben Compiler; kein zusätzliches öffentliches JavaScript. Ein Rückweg auf 1.8.3 erhält alle Daten, zeigt jedoch dessen bisherige Ausrichtung.
|
||||
|
||||
## ADR 085 – Akkordeon-Inhalte sind im Editor direkt zugänglich (14.09.2026)
|
||||
|
||||
Die bisherige Vorschau verhinderte Standard-Klickaktionen einschließlich des Öffnens von `<details>`. Einträge mit `props.open: false` blieben deshalb geschlossen; für leere Akkordeon-Einträge fehlten zusätzlich die Einfügehilfen. Ein Browserfall reproduzierte den geschlossenen Zustand nach Anklicken des Titels.
|
||||
|
||||
`preview-containers.js` öffnet Akkordeon-Einträge ausschließlich im Vorschau-DOM. Leere Einträge zeigen eine Einfügefläche, gefüllte Einträge eine Schaltfläche zum Anhängen weiterer Inhalte. Eine eigene Aktion am Akkordeon legt Einträge an, auch wenn es vollständig leer ist. Die rechte Inhaltsansicht listet Einträge mit ihren Titeln und bietet dieselben Aktionen. Neu eingefügte Akkordeons wählen ihren ersten Eintrag und den Inhaltsbereich; Struktur und Struktursuche berücksichtigen Titel.
|
||||
|
||||
Die Modellauswahl eines Akkordeons erlaubt direkt nur neue Einträge. Texte und andere Inhalte erfordern die Auswahl des Eintrags, damit sie nicht unbemerkt außerhalb des Akkordeons landen. Der gesonderte Befehl für neue Sektionen auf Seitenebene bleibt erhalten. Explizite Einfügepunkte und Drag-and-drop verwenden weiterhin die bestehenden Eltern-/Kindregeln; „Inhalt hinzufügen“ hängt innerhalb des angesprochenen Eintrags an.
|
||||
|
||||
**Migration und Rückweg:** Kein Schemawechsel und keine Datenmigration. Öffnungszustand, Kinder, IDs und Reihenfolge bleiben normale Blockwerte. Vorschauhilfen werden nie gespeichert; die öffentliche Ausgabe und deren native Tastatur-/Mausbedienung bleiben unverändert. Älterer Code kann die Daten weiterhin lesen, zeigt jedoch wieder die bisherige eingeschränkte Vorschau. Alle Inhaltsänderungen laufen über den vorhandenen Undo-/Speicher-/Veröffentlichungsweg.
|
||||
|
||||
## ADR 084 – Visuelle Palettenauswahl im Editor (14.09.2026)
|
||||
|
||||
Alle Farbfelder des Designbereichs verwenden `color-control.js`: kompakte Vorschau mit Farbnamen und aufgelöstem Hexwert, aufklappbare Farbkacheln, nativer Farbwähler und validierte Hexeingabe. `@key` wird nur für die Darstellung aufgelöst; die gespeicherte Palettenreferenz bleibt erhalten. Gerätespezifische Vererbung und Hover-Werte größerer Geräte sind sichtbar. Ohne zuordenbaren Farbwert zeigt das Feld ein neutrales Schachbrett und „Übernehmen“; es erfindet keine schwarze Farbe. Achtstellige Hexwerte behalten ihren Alphakanal auch beim Ändern des Farbtons. Dreistellige Hexeingaben werden vor dem Speichern in sechsstellige Werte umgewandelt.
|
||||
|
||||
Palettenwahl und bestätigte Texteingaben sind einzelne Undo-Schritte. Eine Bewegung im nativen Wähler aktualisiert die Vorschau mit einem gemeinsamen History-Schlüssel und erhält den DOM-Knoten bis zum Abschluss. Danach werden Gerätemarkierungen, Reset und Kontrasthinweis aktualisiert. Farbauswahl per Tastatur, Escape, Fokuswiederherstellung und lokale Fehlermeldung sind Teil desselben Bedienelements. Die Farbkacheln verwenden beschriftete Buttons mit Auswahlzustand; lange Paletten bleiben innerhalb des Panels scrollbar. Keine zusätzliche Bibliothek und kein öffentliches Skript. Das neue Modul steht in der versionierten Importmap.
|
||||
|
||||
**Migration:** Keine Änderung an Datenbank oder Dokumentformat. Bereits erlaubte sechs-/achtstellige Hexwerte und Palettenreferenzen werden unverändert gespeichert. Ein Rückweg auf 1.8.1 zeigt dieselben Werte im früheren Farbfeld. Verwaltungsformulare außerhalb des visuellen Editors behalten ihre bisherigen Felder.
|
||||
|
||||
## ADR 083 – Footer-Links werden redaktionell platziert (14.09.2026)
|
||||
|
||||
Auf ausdrücklichen Wunsch von Umut entfällt die automatisch angehängte Rechtszeile vollständig. Seiten, Beiträge, Archive, Fehlerseiten und Editorvorschauen geben ausschließlich die selbst platzierten Footer-/Inhaltslinks aus. Die frühere Erkennung des speziellen `legal`-Blocks konnte normale Text- oder Menülinks nicht berücksichtigen; dadurch erschien unter einem vollständig gestalteten Footer ein zusätzlicher heller Streifen mit doppelten Links. Die Template-Ausgabe, ihre Erkennung im Controller und ungenutztes CSS sind entfernt.
|
||||
|
||||
Normale Links, Menüs und bewusst eingefügte „Rechtliches“-Blöcke bleiben erhalten. Der optionale Block nutzt weiterhin die konfigurierten Seitenadressen und den Link zur Einwilligung. Einwilligungsdialog, Einstellungen und der Endpunkt `/einwilligung` behalten ihre Funktion. Keine Inhalte werden umgeschrieben, keine neue Schema- oder Datenbankmigration. Bei einem Rückweg auf ältere Versionen erscheint deren automatische Leiste wieder.
|
||||
|
||||
Diese Entscheidung ersetzt die automatische Verlinkung aus ADR 033 und die bedingte Zusatzleiste aus ADR 062; Position und Gestaltung der Links liegen beim Autor.
|
||||
|
||||
## ADR 082 – Zuweisbare 404-Seite im gemeinsamen Seitenrenderer (14.09.2026)
|
||||
|
||||
`website.settings.notFoundPageId` verweist auf eine normale Seite über deren stabile ID. Eine eigene Einstellung mit Livesuche weist sie zu oder entfernt die Zuordnung; sie benötigt Veröffentlichungsrechte und CSRF-Schutz. Der erwartete bisherige Wert schützt gegen ein veraltetes Formular. Die Änderung liest die aktuellen Einstellungen innerhalb einer Transaktion, unter MySQL/MariaDB mit Zeilensperre, und schreibt nur die geänderte Zuordnung in den erhaltenen JSON-Inhalt. Das normale Unternehmensformular erhält diesen Wert unabhängig von seinen eigenen Feldern. Startseite und 404-Seite dürfen nicht dieselbe Seite sein.
|
||||
|
||||
Ohne verwendbare veröffentlichte Zuordnung wird ein v10-Blockdokument im Speicher erzeugt. Öffentliche GETs legen keine Seiten an. „404-Vorlage anlegen“ erstellt eine bearbeitbare Kopie als Entwurf und weist sie zu; bis zur Veröffentlichung bleibt die Standardausgabe sichtbar. Vorhandene Adressen einschließlich Papierkorb werden erhalten; bei belegtem `/404` wird `/404-2` usw. gewählt. Wiederholtes Anlegen überschreibt keine Seite. Die erzeugte Vorlage erhält `noindex`. Erstellen und Zuweisen laufen in einer gemeinsamen Datenbanktransaktion.
|
||||
|
||||
Normale Seiten, zugewiesene Fehlerseiten und der Standard verwenden denselben Renderer mit Theme-CSS, Schriften, Standard-Header/-Footer, Menüs, verknüpften Sektionen und Einwilligungsanzeige. Auf der Fehlerausgabe haben die Standardbereiche Vorrang vor eventuellen Bereichsabweichungen der ausgewählten Seite. Nur veröffentlichte Revisionen sind öffentlich sichtbar; Umbenennen erhält die Zuordnung, Löschen/nicht veröffentlichte Seiten führen auf den Standard zurück. Die Originaladresse bleibt stehen, die Antwort trägt HTTP 404 und `noindex, follow` in HTML und Header, ohne Canonical oder strukturierte Daten einer normalen Seite. Auch die eigene Adresse der zugewiesenen Seite antwortet mit 404. Sitemap und llms.txt schließen die zugewiesene ID zusätzlich aus, unabhängig von deren normalen Metadaten.
|
||||
|
||||
Ein schmaler Exception-Subscriber übernimmt öffentliche HTML-GET/HEAD-Routingfehler außerhalb der Slug-Grammatik, etwa alte `.html`-Adressen oder tiefere Pfade. Fehler bestehender Controller, Verwaltung, Medien/Assets, Formulare, Installation, AJAX sowie maschinenlesbare Ressourcen bleiben ihren bisherigen Endpunkten überlassen. Normale Seiten- und Archivfehler rufen den Fehlerrenderer direkt auf. 301/410 werden vorher behandelt; 410 nutzt einen eigenen Standardtext und behält seinen Status.
|
||||
|
||||
**Migration und Rückweg:** Keine Datenbankmigration und kein neues Dokumentschema. Fehlender/null Settings-Inhalt erhält beim Lesen den leeren Standardwert. Die Einstellung wird erst beim Zuweisen gespeichert. Die Vorlage nutzt bestehende v10-Blöcke. Älterer Code ignoriert die neue Zuordnung und verwendet wieder seine bisherige 404-Ausgabe; die angelegte Seite und ihre Revisionen bleiben normaler Inhalt. Vor einem Rückweg die Website-Einstellungen sichern, da ältere Einstellungsformulare unbekannte JSON-Schlüssel entfernen können. Kein automatisches Veröffentlichen oder Überschreiben vorhandener Seiten.
|
||||
|
||||
## ADR 081 – Linkziel und sichtbare Medienauswahl (14.09.2026)
|
||||
|
||||
Schema v20 ergänzt optionales `props.newTab: boolean` an Button, Bild, Symbolkarte, Aufruf-Band und Symbol. Fehlend/false öffnet wie bisher im aktuellen Tab; true erzeugt im gemeinsamen Renderer `target="_blank" rel="noopener"`. Derselbe Wert ist als optionales Feld in Menüeinträgen verfügbar; die bestehende Menüversion schützt weiterhin vor veralteten Schreibzugriffen. Der Rich-Text-Filter erlaubt ausschließlich `_blank` als Ziel und setzt `noopener` selbst. Andere Targets und fremde Attribute werden entfernt. Textlinks bleiben Teil des bisherigen HTML-Vertrags. Bei Bildern entscheidet die gewählte Klickaktion; eine hinterlegte Adresse bleibt bei „Nichts tun“ gespeichert, erzeugt aber keinen Link mehr.
|
||||
|
||||
Bilder, Galerie-/Logobilder und Hintergründe zeigen kleine Vorschaubilder mit Lade-/Fehlerzustand. Für Hintergrund-IDs liefert eine authentifizierte einzelne Medienabfrage die tatsächlich vorhandene Bildvariante; auch kleine Originale und archivierte, weiterhin verwendete Dateien funktionieren. Die Bildauswahl verwendet denselben Vorschaucache. Späte Antworten ersetzen keine neuere Auswahl. Entfernen eines Inhaltsbildes löscht dessen Block aus dem Entwurf, ein Galerieeintrag bleibt eine einzelne Änderung; beide sind rückgängig zu machen. Die Mediendatei bleibt erhalten. Bildaustausch ersetzt Quelle und Bildmetadaten, erhält Link, Bildunterschrift, Block-ID und Gestaltung.
|
||||
|
||||
Ein Hintergrund wird mit `style.<device>.backgroundImage: "none"` ausdrücklich ausgeblendet. Das ist von Zurücksetzen zu unterscheiden: Zurücksetzen entfernt den eigenen Wert und stellt Vererbung her. Der Compiler übernimmt Bild, Verlauf und Überlagerung schrittweise Desktop→Tablet→Mobil; dadurch erscheint ein auf Tablet entferntes Bild nicht auf Mobil wieder. Geerbte Verläufe bleiben beim Entfernen des Bildes erhalten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Erzeugung. Die neuen Bedienelemente gehören nur zum Editor; kein zusätzliches öffentliches JavaScript.
|
||||
|
||||
**Migration:** Dokumente v1–19 bleiben lesbar und werden nicht beim Laden umgeschrieben. Das Editor-Modell hebt die Version erst beim Setzen eines neuen Block-Linkwerts oder eines ausdrücklichen Hintergrund-„none“ auf v20 an, einschließlich der bisherigen Schema-Upgrades. Die Validierung verlangt für diese Werte v20. Keine Datenbankmigration. Rückweg auf ältere Software: vor Bearbeitung mit v20 angelegte Revision/Export oder die Sicherung vor dem Update wiederherstellen; ältere Validatoren können v20 nicht speichern. Menüs benötigen ebenfalls die Sicherung, falls neue Tab-Einstellungen auf älterem Code erhalten bleiben sollen. Kein stilles Herabsetzen der Dokumentversion.
|
||||
|
||||
**Korrektur 1.7.4:** Native Inhalts-Auswahlfelder verarbeiten `input` und `change`, identische aufeinanderfolgende Werte werden nur einmal übernommen. Jede Auswahl ist ein eigener Undo-Schritt. Die Bild-Klickaktion ersetzt beim Wechsel nicht mehr den ganzen Feinschliff: Linkziel und Tab-Häkchen werden in einer bestehenden Gruppe ein-/ausgeblendet. Das erhält Dropdown, Fokus und eingegebene Adresse. Ein Test mit ausschließlich `change` konnte das fehlende URL-Feld vor der Korrektur reproduzieren; native Auswahl und Speicherung werden zusätzlich geprüft. Keine Änderung des Datenvertrags.
|
||||
|
||||
## ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026)
|
||||
|
||||
Der Inhaltsbereich eines Bildes erhält oben einen Prozentregler und ein Zahlenfeld für 1–100 % der verfügbaren Containerbreite. Beide schreiben ausschließlich `style.<device>.width` über die bestehende `setStyle`-Transaktion. Die generische Breite unter Design und die direkte Bildsteuerung sind dadurch immer derselbe Wert. Originalmaße, Quelle, Varianten, Alternativtext und Bildunterschrift bleiben unverändert. Vorhandene maximale Breiten werden erhalten und im Hinweis genannt; Werte in anderen Einheiten bleiben sichtbar, bis bewusst eine Prozentbreite gesetzt wird. Keine neue Style-Eigenschaft, keine neue Schema- oder Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen JavaScript.
|
||||
|
||||
Die Steuerung verwendet das aktive Vorschaugerät und zeigt die Herkunft des Werts. Zurücksetzen entfernt nur die Breite dieses Geräts und stellt die bestehende Desktop→Tablet→Mobil-Vererbung wieder her. Eingaben aktualisieren die Vorschau, ohne das Feld zu ersetzen. Eine Ziehbewegung ist ein Undo-Schritt; Zahlenwerte außerhalb des Bereichs werden mit verständlicher Meldung abgelehnt und nicht gespeichert. Tastatur, Undo/Redo, Speichern/Laden und prozentuale Ausgabe ohne JavaScript werden am realen TeamViewer-Bild aus einer isolierten Kopie der Pilotdaten geprüft.
|
||||
|
||||
## ADR 079 – Livesuche mit gemeinsamen HTML-Ergebnissen (14.09.2026)
|
||||
|
||||
Seiten, Beiträge und Mediathek verwenden dieselben authentifizierten GET-Routen für vollständige Ansichten und AJAX. Der Header `X-Requested-With: XMLHttpRequest` fordert JSON mit einem serverseitig gerenderten Ergebnisfragment, Gesamtzahl und normalisierten Filtern an. Twig bleibt die einzige Darstellung für Listen, Berechtigungen, CSRF-Felder, leere Ergebnisse und Pagination. Suchformular sowie Anlegen/Hochladen bleiben außerhalb des austauschbaren Bereichs; vorhandene Formulare funktionieren weiter ohne JavaScript. Keine Schema- oder Inhaltsmigration.
|
||||
|
||||
Das Verwaltungsmodul wartet beim Tippen 220 ms und ersetzt nur die Ergebnisliste. Eine neue Eingabe bricht die alte Anfrage sofort ab; eine zusätzliche Sequenzprüfung verwirft verspätete Antworten. IME-Eingaben werden erst nach der Komposition gesucht. Beginnt während einer ausstehenden Suche eine Bearbeitung in der Liste, wird die Suche angehalten, damit die Antwort keine Eingabe entfernt. Nach dem Austausch werden Seitenauswahlen initialisiert und die Rückkehrfilter aller Formulare aktualisiert. Fehler erhalten die bisherigen Ergebnisse und bieten Wiederholen an. Ergebniszahl und Ladezustand sind für Screenreader zugänglich; Tastaturfokus bleibt beim Tippen erhalten.
|
||||
|
||||
URLs enthalten den Suchzustand. Tippen ersetzt den aktuellen Historieneintrag, Filterwahl und Blättern legen einen neuen an; `popstate` lädt passende Ergebnisse und stellt die Felder wieder her. Ohne JavaScript arbeitet das GET-Formular mit Submit und regulären Links. Das Modul gehört ausschließlich zum Admin-Paket.
|
||||
|
||||
Die Mediathek filtert Titel und Alternativtext vor COUNT und LIMIT in der Datenbank, inklusive Archivfilter und wörtlicher Behandlung von `%`, `_` und `!`. SQLite erhält eine Unicode-fähige LOWER-Funktion über PDO/mb_strtolower, weil die eingebaute Funktion nur ASCII verarbeitet und etwa „über“ nicht zu „Über“ passte. MySQL verwendet weiterhin seine native Funktion. Die Korrektur gilt auch für Inhalts- und Menüsuche; bestehende Daten und Indizes werden nicht verändert.
|
||||
|
||||
## ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026)
|
||||
|
||||
Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. `motion.js` beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert.
|
||||
|
||||
+109
-1
@@ -1,8 +1,116 @@
|
||||
# Projektstatus
|
||||
|
||||
## 1.8.4 – Bildausrichtung sichtbar korrigiert (14.09.2026)
|
||||
|
||||
**Behoben:** „Eigene Ausrichtung quer: Mitte“ zentriert Bilder jetzt auch in normalen Sektionen und Spalten. Zuvor blieb das Bild trotz `align-self:center` links; der Browserfall mit 50-Prozent-Breite zeigte 181 Pixel Abweichung von der Mitte (`var/image-alignment-before.log`, `reports/image-alignment/before.png`). Der gemeinsame Compiler richtet Rahmen und enthaltenes Bild aus, einschließlich kleiner Logos, Prozent-/Maximalbreiten und Bildlinks. Anfang/Ende, Gerätevererbung und Rücksetzen sind berücksichtigt; horizontale Flex-Gruppen behalten ihre vertikale Querachse. Keine Datenmigration, keine Änderung an gespeicherten Bilddaten, kein zusätzliches öffentliches Skript (ADR 086).
|
||||
|
||||
**Browser geprüft:** `npm run test:image-alignment` mit drei Prüfgruppen auf isolierter Kant-IT-Kopie erfolgreich: echte Layout-Auswahl, Tastatur, Anfang/Mitte/Ende, Undo/Redo, gepolsterte Inhaltsbereiche, kleine Originalbilder, Prozent-/Maximalbreiten, Link/Vergrößerung, Grid, beide Flexachsen, responsive Elterneinstellungen und Rücksetzen auf Auto mit wiederhergestelltem Außenabstand. Speichern/Laden erhält Maße und Medienmetadaten. Öffentliche Ausgabe ohne JavaScript auf Desktop 1600, Tablet 900 und Mobil 390 geprüft. Screenshots unter `reports/image-alignment/` visuell geprüft; Originaldaten unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Bestehendes `npm run test:image-size` mit drei Prüfgruppen erfolgreich, einschließlich Größenregler, Gerätewerten, Undo/Redo, Speichern/Laden, Veröffentlichung und Desktop-/Mobil-Barrierefreiheit der Bedienelemente. 32 JavaScript-Tests und Produktionscontainer erfolgreich; PHP-Stilprüfung der drei betroffenen Dateien ohne Befund. Die PHP-Gesamtprüfung (481 Tests / 3858 Assertions) zeigte einen von CRLF/LF abhängigen Quelltextvergleich der Layout-Gruppen; alle anderen Fälle bestanden. Vergleich zeilenumbruchunabhängig korrigiert, anschließend fünf gezielte PHP-Tests / 29 Assertions erfolgreich. Gesamtsuite wird zur abschließenden Bestätigung mit `php -d max_execution_time=0 vendor/bin/phpunit` wiederholt; der erste Lauf mit lokaler Standardkonfiguration war am 80-Sekunden-Limit abgebrochen.
|
||||
|
||||
**Nächster Schritt:** PHP-Gesamtprüfung abschließen, Paket aus dem exakten Commit bauen, prüfen und veröffentlichen. Keine neue Lighthouse-Messung oder vollständige Editor-Gesamtprüfung für die gezielte CSS-Compiler-Korrektur; eigene CSS-Regeln können weiterhin Layoutvorgaben überschreiben.
|
||||
|
||||
## 1.8.3 – Inhalte in Akkordeons sichtbar bearbeiten (14.09.2026)
|
||||
|
||||
**Behoben:** Geschlossene Akkordeon-Einträge ließen sich in der Editorvorschau nicht öffnen, weil die Klickbehandlung die native Aktion verhinderte. Der Fehler wurde vor der Korrektur im Browser reproduziert (`var/accordion-before.log`, `reports/accordion/before.png`). Einträge sind im Editor jetzt geöffnet, leere Inhaltsbereiche sichtbar und als Ablageziel nutzbar. „Inhalt hinzufügen“ im Eintrag und rechts im Feinschliff hängt neue Elemente an. „Eintrag hinzufügen“ steht an der Gruppe und in deren Inhaltsansicht; auch ein vollständig leeres Akkordeon ist wieder befüllbar.
|
||||
|
||||
**Bedienung:** Rechte Eintragsübersicht, Struktur und Struktursuche zeigen Eintragstitel. Ein neues Akkordeon wählt den ersten Eintrag samt Inhaltsoptionen. Bei Auswahl der Gruppe werden Texte nicht mehr unbemerkt außerhalb des Akkordeons eingefügt. Die gespeicherte Einstellung „Beim Laden geöffnet“ gilt weiterhin für Besucher; Öffnen im Editor verändert weder Daten noch Speicherstatus. Keine Migration und kein geänderter öffentlicher Renderer (ADR 085).
|
||||
|
||||
**Geprüft:** `npm run test:accordion` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: sichtbarer Inhalt trotz geschlossenem Besucherzustand, Einfügen über Tastatur/Vorschau/Feinschliff, Textbearbeitung, Anhängen und Drag-and-drop von Buttons in Einträge, Undo/Redo, weitere/leere/verschachtelte Akkordeons, Suche nach Titeln, Speichern/Laden und Veröffentlichung. Veröffentlichte Einträge behalten ihre Öffnungswerte und lassen sich ohne JavaScript per Maus/Enter bedienen; Vorschauhilfen fehlen im öffentlichen HTML und in gespeicherten Daten. Desktop 1600/Mobil 390 ohne axe-Verstöße im Editor oder horizontalen Überlauf; Screenshots visuell geprüft (`reports/accordion/`). Originaldaten unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Medien, Einwilligung und Desktop-/Mobil-Barrierefreiheit. 32 JavaScript-Tests erfolgreich, darunter ergänzte Einfüge-/Undo-Prüfungen. 16 gezielte PHP-Tests / 33 Assertions zu V6-Blockvalidierung, Speichern und Ausgabe ohne JavaScript erfolgreich. Produktionscontainer gültig und Git-Diff ohne Whitespacefehler. Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Editoränderung.
|
||||
|
||||
**Veröffentlicht:** Commit `a302996` und Tag `v1.8.3` auf `origin`; [Release 1.8.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.3) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 212 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `7a3625609dc55f00354ee4b853c2816f20467cd04d0aa9acde7d117c69bd897a`.
|
||||
|
||||
**Nächster Schritt:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.2 – Visuelle Farbauswahl im Editor (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Kompakte Farbvorschau mit Palettenname und aufgelöstem Hexwert statt schwarzem Feld bei `@deep` und einer zusätzlichen Dropdown-Liste. Aufklappbare Website-Farben als beschriftete Farbkacheln mit Auswahlzustand, eigener Farbwähler und Hexeingabe mit Enter/Übernehmen. Transparente Farben auf Schachbrett; Alphakanal bleibt beim Ändern des Farbtons erhalten. Geerbte Geräte- und Hover-Farben erkennbar, einzelne Werte zurücksetzbar. Text-, Hintergrund-, Hover-, Verlaufs-, Rahmen- und Schattenfarben nutzen dasselbe Bedienelement. Keine Änderung am Datenvertrag oder öffentlichen Paket (ADR 084).
|
||||
|
||||
**Geprüft:** `npm run test:colors` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: Token-Vorschau, Auswahl/Kanvas, Tastatur/Tab/Escape/Fokus, Undo/Redo, Geräte-/Hover-Vererbung und Reset, stabiler nativer Farbwähler mit einem Undo-Schritt, reine change-Ereignisse, ungültige Eingaben, Kurzhex, achtstellige Hexwerte, Speichern/Laden und Veröffentlichung mit erhaltener Palettenreferenz. Desktop 1600/Mobil 390: geöffnete Farbauswahl und Editor ohne axe-Verstöße oder horizontalen Überlauf; Screenshots `reports/color-picker/` visuell geprüft. Originaldaten unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Gestaltung, Vorlagen, Speichern/Veröffentlichen, Konflikten, Mediathek, Einwilligung sowie Desktop-/Mobil-Barrierefreiheit. Der bisherige Test zur automatisch angehängten Einwilligungszeile wurde an die bereits veröffentlichte Änderung aus 1.8.1 angepasst. 32 JavaScript-Tests einschließlich versionierter Modulimporte bestanden. Geänderte Twig-Basis und Produktionscontainer gültig; Git-Diff ohne Whitespacefehler.
|
||||
|
||||
**Veröffentlicht:** Commit `55dab4b` und Tag `v1.8.2` auf `origin`; [Release 1.8.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.2) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 212 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `f6d2a6ff99f52f3a0939160e26403aca13bb9fb9d7042f38255dd958057ae8f5`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Änderung am Editor. Verwaltungsformulare außerhalb des Editors behalten ihre bisherigen Farbfelder. Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.1 – Keine automatische Rechtszeile unter dem Footer (14.09.2026)
|
||||
|
||||
**Behoben:** Das CMS hängt Datenschutz, Impressum und Einwilligungslink nicht mehr automatisch an den Footer an. Entfernt aus Seiten-/Archiv-/Fehlervorlagen und damit auch aus Seiten- und Bereichsvorschauen. Normale Textlinks, Menüs und ausdrücklich eingefügte „Rechtliches“-Bausteine bleiben erhalten; der Editorhinweis beschreibt die freie Platzierung. Keine Inhaltsänderung oder Migration (ADR 083).
|
||||
|
||||
**Geprüft:** PHP-Vollsuite 477 Tests / 3815 Assertions und 32 JavaScript-Tests erfolgreich. Vorhandene Tests an die redaktionelle Platzierung angepasst; freiwilliger Rechtsblock und Einwilligungsablauf weiterhin geprüft. 47 Twig-Dateien und Produktionscontainer gültig, PHP-Stilprüfung der drei betroffenen PHP-Dateien ohne Änderungen, Git-Diff ohne Whitespacefehler.
|
||||
|
||||
**Browser:** Isolierte Kant-IT-Kopie mit bestehenden Rechtsblöcken sowie einer Variante mit normalen Footer-Textlinks wie im Screenshot. Acht Prüfgruppen erfolgreich: Startseite, Beitragsarchiv und 404 auf Desktop 1600/Mobil 390 sowie Seiten- und Footer-Editor. Eigene Links erhalten, keine Zusatzleiste, kein horizontaler Überlauf. Screenshots unter `reports/footer-links/` visuell geprüft. Gegenprobe mit dem unveränderten Release 1.8.0 reproduziert die doppelte Leiste auf allen sechs öffentlichen Ansichten (`reports/footer-links-before/`). Originaldaten bleiben unverändert.
|
||||
|
||||
**Bekannte Prüfbefunde:** Die Variante mit gewöhnlichen, unformatierten Textlinks meldet Kontrastbefunde an diesen Links; bei sichtbarem Nach-oben-Knopf zusätzlich einen Landmark-Befund. Das Archiv meldet außerdem Überschriftenreihenfolge und doppelte Landmark-Benennung. Alle Befunde betreffen dieselben Elemente wie in der Gegenprobe mit 1.8.0. Keine fehlerfreie Gesamt-Barrierefreiheit oder neuen Lighthouse-Werte behauptet. Keine Wiederholung der gesamten Editor-Bedienprüfung für diese Template-Entfernung.
|
||||
|
||||
**Veröffentlicht:** Commit `8f475d1` und Tag `v1.8.1` auf `origin`; [Release 1.8.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.1) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `fc5d7386d1d9b099e1506f16ab7937526014a85a96840c510e7ca4e2703206c3`. Über die Systemverwaltung aktualisieren; Kundenhostings wurden hier nicht verändert.
|
||||
|
||||
## 1.8.0 – Eigene 404-Seite mit Website-Design (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Einstellungen → 404-Seite mit Livesuche, bestehende Seite zuweisen, Auswahl zurücksetzen, direktem Bearbeiten und öffentlicher 404-Vorschau. „404-Vorlage anlegen“ erstellt und verknüpft einen bearbeitbaren Entwurf, ohne vorhandene Seiten zu überschreiben. Bis zur Veröffentlichung erscheint eine automatische Standard-404. Normaler Seitenrenderer übernimmt Standard-Header/-Footer, Theme, Schriften, Menüs und verknüpfte Sektionen. Die ID-Verknüpfung übersteht Umbenennen; Löschen führt auf den Standard zurück. Nur veröffentlichte Revisionen werden öffentlich ausgeliefert.
|
||||
|
||||
**Antwortverhalten:** Echte 404-Antwort an der ursprünglichen URL, auch bei ungewöhnlichen/tiefen HTML-Adressen und beim direkten Aufruf der zugewiesenen Seite. Kein Canonical und keine strukturierten Daten einer normalen Seite; `noindex, follow` in HTML und Header. Ausschluss der zugewiesenen ID aus Sitemap und llms.txt. 301/410, Verwaltungs-/Medien-/Formularendpunkte und maschinenlesbare Ressourcen behalten ihre eigene Behandlung. Zuweisung mit Veröffentlichungsrechten, CSRF und Schutz gegen veraltete Formulare. Keine Datenbankmigration; optionaler Settings-Schlüssel und vorhandenes Dokumentschema v10 (ADR 082).
|
||||
|
||||
**Geprüft:** PHP-Vollsuite 477 Tests / 3817 Assertions erfolgreich, einschließlich neuer 404-Fälle zu Entwurf/Veröffentlichung, Umbenennen, Löschen/Fallback, bestehenden `/404`-Seiten, Schreibkonflikten, Berechtigungen, CSRF, HEAD, Bereichen, 301/410, Sitemap und llms.txt. 32 JavaScript-Tests erfolgreich. `npm run test:404` mit isolierter Kant-IT-Kopie: Vorlage anlegen, im Editor bearbeiten, veröffentlichen, tatsächliche Ausgabe, Livesuche/Tastaturwahl, Zuordnung nach Neuladen, Rücksetzen und veraltetes zweites Formular. Desktop 1600 und Mobil 390 ohne Überlauf/axe-Verstöße; öffentliche Startseite weiterhin 200 mit Canonical und ohne axe-Befund. Funktioniert auch ohne JavaScript; Header/Footer/Schrift mit dem Originalpiloten abgeglichen, Screenshots visuell geprüft (`reports/not-found/`). Originaldaten bleiben unverändert.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Laden, Veröffentlichung, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit. Alle 47 Twig-Dateien und der Produktionscontainer gültig; PHP-Stilprüfung über 133 Dateien ohne Befund.
|
||||
|
||||
**Veröffentlicht:** Commit `16da844` und Tag `v1.8.0` auf `origin`; [Release 1.8.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.0) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `4c90a0256ad827c40ae09db85fbe72a9315f41b181c26f9983d710b646bc64b4`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Keine neuen Lighthouse-Scores für eine absichtlich nicht indexierbare 404-Seite. Keine neuen Firefox-/Safari-/Screenreader- oder MySQL-Läufe; lokale Datenbankprüfungen verwenden SQLite. Nach Veröffentlichung über die Systemverwaltung aktualisieren; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
|
||||
|
||||
## 1.7.4 – URL-Feld bei Bild-Klickaktion zuverlässig einblenden (14.09.2026)
|
||||
|
||||
**Behoben:** „Zu einer Adresse führen“ blendet Linkziel und Tab-Häkchen unmittelbar unter dem Dropdown ein, auch bei Bildern ohne frühere Verlinkung. Inhalts-Auswahlfelder reagieren auf `input` und `change`, ohne doppelte Änderungen. Das Dropdown bleibt beim Umschalten im DOM; Linkfelder werden nur ein-/ausgeblendet, eingegebene Adressen bleiben erhalten. Jede Auswahl bleibt einzeln rückgängig zu machen. Keine neue Schema-/Datenbankmigration, kein geänderter öffentlicher Renderer oder öffentliches Paket. Cachekennung 1.7.4.
|
||||
|
||||
**Geprüft:** Der ergänzte Browserfall mit ausschließlich `change` scheiterte vor dem Fix am fehlenden URL-Feld (`var/image-link-before.log`). Danach `npm run test:link-media` mit sechs Prüfgruppen bestanden: erstmals verlinktes Bild, natives Dropdown mit Mausöffnung/Tastaturwahl, Tastaturwechsel, stabiler DOM-Knoten/Fokus, Nur-change-Ereignis, URL-Erhalt, Vergrößern/Nichts tun, Undo/Redo, Speichern/Laden und veröffentlichte Zieladresse. Bestehende Bild-, Hintergrund-, Textlink- und Menüprüfungen ebenfalls erfolgreich. Desktop-/Mobil-Editor und öffentliche Seite ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). 32 JavaScript-Tests bestanden. Schreibtests ausschließlich in isolierten Kant-IT-Kopien.
|
||||
|
||||
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Inhalts-/Design-Auswahl, Undo, Speichern, Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`).
|
||||
|
||||
**Veröffentlicht:** Commit `25fd388` und Tag `v1.7.4` auf `origin`; [Release 1.7.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.4) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3532 Archivdateien, 209 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `f41ff10ad403a5dccd48e8d2805079dc3ebdfa2966fd548c4c1b0abe6d26a617`.
|
||||
|
||||
**Grenzen / nächster Schritt:** Kein erneuter PHP- oder Lighthouse-Lauf für diese reine Editor-Korrektur. Kundenhostings werden nicht verändert; nach Einspielen des Updates den Editor neu öffnen, damit die versionierten Skripte geladen werden.
|
||||
|
||||
## 1.7.3 – Links in neuen Tabs und Bildvorschauen (14.09.2026)
|
||||
|
||||
**Umgesetzt:** „In neuem Tab öffnen“ bei Buttons, verlinkten Bildern, Symbolen, Symbolkarten, Aufruf-Bändern, Textlinks und Menüeinträgen. Serverseitig validierte Ausgabe mit `target="_blank" rel="noopener"`; normale Links bleiben im selben Tab. Textlinks lassen sich mit vorhandenem Ziel/Häkchen wieder bearbeiten. „Nichts tun“ am Bild deaktiviert auch eine früher hinterlegte Verlinkung. Inhalts-, Galerie-/Logo- und Hintergrundbilder zeigen Vorschauen mit Lade-/Fehlerzustand und Entfernen-Funktion. Entfernen ist rückgängig zu machen und erhält die Datei in der Mediathek. Bildaustausch erhält Link, Bildunterschrift und Gestaltung. Geerbte Hintergrundbilder sind sichtbar und je Gerät entfernbar; kleinere Geräte übernehmen die Entfernung, sofern dort kein eigener Wert besteht. Verläufe bleiben erhalten. Tastaturfokus bleibt beim Umschalten der Klickaktion und nach dem Entfernen eines Hintergrunds bedienbar.
|
||||
|
||||
**Datenvertrag:** Dokument-Schema v20 nur beim Verwenden neuer Block-Linkwerte oder ausdrücklicher Hintergrundentfernung. Vorhandene Dokumente bleiben lesbar, keine Datenbankmigration. Migration und Rückweg: ADR 081. Medienvorschauen verwenden eine authentifizierte Einzelabfrage und tatsächlich vorhandene kleine Varianten, auch bei archivierten verwendeten Bildern. Kein zusätzliches öffentliches JavaScript.
|
||||
|
||||
**Geprüft:** 338 PHP-Tests (`CmsTest|EditorSecurityTest`) mit 2873 Assertions; 32 JavaScript-Tests; 47 Twig-Vorlagen gültig; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:link-media` auf einer isolierten Kant-IT-Kopie: Vorschauen, fehlendes Bild, Entfernen/Undo bei Bild/Galerie/Hintergrund, Vererbung/Reset, Links erstellen/ändern/deaktivieren, Menü-Undo, Speichern/Laden und Veröffentlichung. Neuer Tab tatsächlich geöffnet, Ursprungstab erhalten und `window.opener` null. Veröffentlichter Hintergrund ohne JavaScript bei 1600/900/390 px geprüft. Öffentliche Seite sowie Desktop-/Mobil-Editor ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). Vollständiges `npm run test:editor` ebenfalls erfolgreich, einschließlich Mediathek, Entwurf/Veröffentlichung/Konflikten und Desktop-/Mobil-Barrierefreiheit. Originaldaten bleiben unverändert; Schreibtests ausschließlich in temporären Kopien.
|
||||
|
||||
**Veröffentlicht:** Commit `6d21c45` und Tag `v1.7.3` auf `origin`; [Release 1.7.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.3) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen. Aus dem exakten Commit gebaut: 3532 Archivdateien, 209 versionierte Quelldateien bytegenau verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `acf6a4ebea555fe7613935372e1a7922aca4e2c72646175a93d349359787e315`.
|
||||
|
||||
**Grenzen und nächster Schritt:** Keine erneute Lighthouse-Messung für diese Link-/Mediensteuerung; öffentliche Ausgabe und Barrierefreiheit direkt geprüft. Keine neuen Firefox-/Safari-/Screenreader-Prüfungen. Nach Veröffentlichung kann das Update über die Systemverwaltung eingespielt werden; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
|
||||
|
||||
## 1.7.2 – Bildbreite direkt in Prozent einstellen (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Beim ausgewählten Bild steht im Reiter Inhalt oben „Bildbreite (%)“ mit Regler, Zahlenfeld und Zurücksetzen. 1–100 % beziehen sich auf die verfügbare Breite der Sektion/Spalte. Desktop, Tablet und Mobil verwenden die vorhandene Vererbung; Herkunft und bestehende maximale Breiten sind sichtbar. Beide Bildsteuerungen bearbeiten denselben gespeicherten Breitenwert wie Design → Größe. Originalmaße, Medienvarianten, Alt-Text und Bildunterschrift bleiben erhalten. Ungültige Zahlen verändern den gespeicherten Wert nicht, eine Ziehbewegung bleibt ein Undo-Schritt. Der veraltete Hinweis auf eine erst künftig verfügbare Mediathek wurde berichtigt. Keine neue Schema-/Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen Paket.
|
||||
|
||||
**Geprüft:** `npm run test:image-size` auf einer isolierten Kant-IT-Kopie mit dem TeamViewer-Logo: Regler mit echter Mausbewegung und Tastatur, Zahlenfeld, Fokus, ungültiger Wert, Undo/Redo, responsive Vererbung/Reset, Speichern/Laden, unveränderte Bilddaten und Abgleich mit Design → Größe. Veröffentlichte Ausgabe ohne JavaScript bei 1600/900/390 px: 60/75/30 % und unverzerrtes Seitenverhältnis gemessen. Neue Steuerung auf Desktop/Mobil ohne Überlauf und axe-Verstöße, Screenshots visuell geprüft (`reports/image-size/`). Vollständige Editor-Browserprüfung erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). 30 JavaScript-Tests und 35 PHP-Tests zu Styles/Bildern mit 67 Assertions erfolgreich. Alle Schreibtests in eigenen Datenbankkopien.
|
||||
|
||||
**Veröffentlicht:** Commit `0920e3c` und Tag `v1.7.2` auf `origin`; [Release 1.7.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.2) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen. Aus dem exakten Commit gebaut: 3531 Archivdateien, 208 versionierte Quelldateien bytegenau verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `e9983669871815e46c8a675bf59eb3632f37f015c0026ccb072a93119def5ac8`.
|
||||
|
||||
**Grenzen und nächster Schritt:** Das Update kann über die Systemverwaltung eingespielt werden; Kundenhostings wurden nicht verändert. Da nur die Editorsteuerung vorhandener Style-Werte geändert wurde, keine erneuten öffentlichen Lighthouse-Messungen; die Prozentdarstellung wurde im gemeinsamen Renderer direkt überprüft.
|
||||
|
||||
## 1.7.1 – Livesuche in der Verwaltung (14.09.2026)
|
||||
|
||||
**Umgesetzt:** Seiten und Beiträge suchen beim Tippen per AJAX; Status, 20/50 Einträge und Pagination aktualisieren nur die Ergebnisliste. URL, Browser-Zurück/Vor, Eingabefokus und Rückkehrfilter beim Speichern bleiben zusammenhängend. Die Mediathek durchsucht Titel und Alternativtext über alle Seiten, kombiniert mit Archivfilter. Bestehende Suchen in Menüs, übergeordneten Seiten, Editor-Werkzeugkasten/Struktur/Eigenschaften und Bildauswahl reagieren bereits beim Tippen. Kein Enter nötig; ohne JavaScript bleibt „Anwenden“ erhalten. Alte Antworten werden verworfen, Fehler erhalten Treffer und Eingaben, Wiederholen ist direkt möglich. Eine während der Suche begonnene Bearbeitung hält den Austausch an. SQLite-Suche erkennt Groß-/Kleinschreibung auch bei Umlauten. Keine Datenmigration und kein zusätzliches öffentliches JavaScript.
|
||||
|
||||
**Geprüft:** PHP 8.4.22: `EditorSecurityTest|CmsTest` 332 Tests / 2832 Assertions; nach der abschließenden Korrektur zur Suchanfrage „0“ die fünf Livesuche-/Medienfälle erneut mit 111 Assertions. 30 JavaScript-Tests; 24 Admin-Twig-Vorlagen; Produktionscontainer; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:search`: sechs Browser-Prüfgruppen mit kopierten Kant-IT-Daten, darunter schnelle/überholte Anfragen, Fehler/Wiederholen, abgelaufene Sitzung, IME, Pagination/Historie, Seitenauswahl nach AJAX, Bearbeitung während laufender Suche, Medien und Betrieb ohne JavaScript. Desktop 1440 und Mobil 390: kein horizontaler Überlauf, axe ohne Verstöße; Screenshots visuell geprüft. `test:admin -- --admin-only` bestätigt zusätzlich Menüs, Formulare, Bereiche, Speichern/Laden sowie die Vorschau mit aktivierter Bewegung; Tablet/Mobil ohne axe-Befunde. Berichte: `reports/live-search/`, `reports/admin-quality/`; Testkonten und Änderungen nur in isolierten Datenbankkopien.
|
||||
|
||||
**Veröffentlicht:** Commit `41d87f6` und Tag `v1.7.1` auf `origin`; [Release 1.7.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.1) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` hochgeladen und anschließend ohne Anmeldung heruntergeladen/bytegenau geprüft. Paket aus dem exakten Commit: 3531 Dateien, davon 208 versionierte Quelldateien verglichen; Produktionsabhängigkeiten ohne Entwicklungspakete, `.env` aus Vorlage, keine `.env.local`. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite mit HTTP 200 geprüft. ZIP-SHA-256: `08846e3b8b700e66075160956a9e1963dc5d4f11a350dae175a0ff9511dd2ab1`.
|
||||
|
||||
**Grenzen und nächster Schritt:** Das Update ist veröffentlicht und kann über die Systemverwaltung eingespielt werden; Kundenhostings wurden nicht verändert. MySQL wurde in diesem Schritt nicht ausgeführt; die lokale Prüfung verwendet SQLite. Öffentliche Lighthouse-Messungen wurden für diese Verwaltungsänderung nicht wiederholt; Nachweise aus 1.7.0 stehen unten.
|
||||
|
||||
## 1.7.0 – Installation, Verwaltungsabläufe und Bewegung (14.09.2026)
|
||||
|
||||
**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.
|
||||
**Veröffentlicht:** Commit `495b268` und annotierter Tag `v1.7.0` sind auf `origin` hochgeladen. [Release 1.7.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.0) enthält ZIP, SHA-256-Datei und `install.php`. Gebaut aus dem exakten Commit: 3527 Archivdateien, 204 versionierte Quelldateien bytegenau verglichen, Produktionsabhängigkeiten ohne Entwicklungspakete. Archivintegrität, Prüfsumme im Installer, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env` vorhanden, keine `.env.local`) geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; API meldet 1.7.0 als neuestes stabiles Release. ZIP-SHA-256: `b708f7dffad74e1def31325533b646557963b0f3e702c46bd85ee109fc22a192`. Das Update ist bereitgestellt, nicht auf Kundenhostings eingespielt.
|
||||
|
||||
**Umgesetzt:** `.env` ist die einzige automatisch geladene Installationsdatei und aus Git entfernt. Setup, Webinstaller und Admin-Pfad-Befehl schreiben dort. Alte `.env.local` wird einmalig mit Sicherung beider Originale zusammengeführt; wirksame Datenbank und Secret bleiben erhalten. System zeigt die tatsächliche Datenbank samt Quelle. Der Git-Updater erhält Konfiguration vor dem neuen PHP-Prozess und beim Rückweg; ein entdeckter falscher Datenbank-Sicherungspfad wurde ebenfalls korrigiert. Ein fehlgeschlagenes Zurückschreiben lässt die Website im Wartungszustand. Vorgehen beim erstmaligen Wechsel von altem Git-Updater-Code und bei FTP: UPDATE.md. Keine Schema-/Datenbankmigration.
|
||||
|
||||
**Verwaltung:** Fehler 500 beim Öffnen von Bereichen behoben. Seiten/Beiträge mit eigener Navigation, datenbankseitiger Pagination (20/50), Suche, Status, begrenztem Papierkorb und Filtererhalt. Elternauswahl unabhängig von der Ergebnisseite. Menüeditor mit Suchauswahl per Tastatur, eigenen Links, klarer Reihenfolge, Haupt-/Unterpunkten und Rückgängig; Validierungs-/Verbindungs-/Versionsfehler erhalten Eingaben. Formularmeldung mehrzeilig über die verfügbare Breite; Felddefinitionen auf schmalen Bildschirmen als beschriftete Gruppen. Mobil zugänglicher Kontolink.
|
||||
|
||||
|
||||
+8
-1
@@ -9,7 +9,14 @@
|
||||
"test:browser": "node scripts/browser-check.mjs",
|
||||
"test:links": "node scripts/link-check.mjs",
|
||||
"audit": "node scripts/audit.mjs",
|
||||
"test:admin": "node scripts/admin-quality-check.mjs"
|
||||
"test:admin": "node scripts/admin-quality-check.mjs",
|
||||
"test:search": "node scripts/live-search-check.mjs",
|
||||
"test:image-size": "node scripts/image-size-check.mjs",
|
||||
"test:image-alignment": "node scripts/image-alignment-check.mjs",
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs",
|
||||
"test:colors": "node scripts/color-picker-check.mjs",
|
||||
"test:accordion": "node scripts/accordion-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
@@ -708,6 +708,8 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
|
||||
.selected-media-preview{margin:12px 0;padding:10px;border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.selected-media-preview img{display:block;width:100%;height:140px;object-fit:contain;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px}.selected-media-preview figcaption{font-size:12px;line-height:1.5;overflow-wrap:anywhere;margin-top:8px;color:var(--muted)}
|
||||
.live-search-ready .search-submit{display:none}.search-status{min-height:20px;margin:-12px 0 0}#list-results{scroll-margin-top:16px}#list-results>.section-head{margin-top:0}#list-results:focus-visible{outline:2px solid var(--accent);outline-offset:5px}#search-error p{margin-top:0}
|
||||
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
|
||||
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
||||
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/** A compact palette picker. Palette references stay references when saved. */
|
||||
let sequence = 0;
|
||||
const node = (tag, className, text) => {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
};
|
||||
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||||
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) {
|
||||
const wrap = node('div', 'color-control');
|
||||
const trigger = node('button', 'color-trigger');
|
||||
trigger.type = 'button'; trigger.id = id;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.setAttribute('aria-controls', `${id}-choices`);
|
||||
const sample = node('span', 'color-sample'); sample.setAttribute('aria-hidden', 'true');
|
||||
const ink = node('span', 'color-sample-ink'); sample.append(ink);
|
||||
const description = node('span', 'color-description');
|
||||
const name = node('span', 'color-name'), detail = node('span', 'color-detail');
|
||||
description.append(name, detail);
|
||||
const arrow = node('span', 'color-chevron', '⌄'); arrow.setAttribute('aria-hidden', 'true');
|
||||
trigger.append(sample, description, arrow);
|
||||
|
||||
const choices = node('div', 'color-choices'); choices.id = `${id}-choices`; choices.hidden = true;
|
||||
const heading = node('p', 'color-heading', 'Website-Farben');
|
||||
const palette = node('div', 'color-palette');
|
||||
palette.setAttribute('role', 'group'); palette.setAttribute('aria-label', `Website-Farben für ${label}`);
|
||||
const buttons = [];
|
||||
let current = value, editing = false, mergeKey = `color-${++sequence}`;
|
||||
const entryFor = raw => colors.find(color => `@${color.key}` === raw);
|
||||
const resolve = raw => hexPattern.test(raw) ? raw : entryFor(raw)?.value || '';
|
||||
const restoreFocus = () => document.getElementById(id)?.focus();
|
||||
const choose = raw => { current = raw; sync(); onChange(raw); restoreFocus(); };
|
||||
for (const color of colors) {
|
||||
const button = node('button', 'color-option'); button.type = 'button';
|
||||
button.dataset.color = `@${color.key}`;
|
||||
button.title = `${color.name} · ${color.value}`;
|
||||
button.setAttribute('aria-label', button.title);
|
||||
const swatch = node('span', 'color-option-swatch'); swatch.style.backgroundColor = color.value;
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
button.append(swatch, node('span', 'color-option-name', color.name));
|
||||
button.addEventListener('click', () => choose(`@${color.key}`));
|
||||
buttons.push(button); palette.append(button);
|
||||
}
|
||||
if (colors.length) choices.append(heading, palette);
|
||||
|
||||
const customLabel = node('label', 'color-heading', 'Eigene Farbe'); customLabel.htmlFor = `${id}-hex`;
|
||||
const custom = node('div', 'color-custom');
|
||||
const picker = node('input', 'color-native'); picker.type = 'color';
|
||||
picker.setAttribute('aria-label', `Eigene Farbe für ${label}`);
|
||||
const text = node('input', 'color-text'); text.type = 'text'; text.id = `${id}-hex`;
|
||||
text.autocomplete = 'off'; text.spellcheck = false; text.placeholder = '#RRGGBB';
|
||||
text.setAttribute('aria-label', `Farbwert für ${label}`);
|
||||
const accept = node('button', 'color-accept', '✓'); accept.type = 'button';
|
||||
accept.setAttribute('aria-label', `Farbwert für ${label} übernehmen`); accept.title = 'Farbwert übernehmen';
|
||||
custom.append(picker, text, accept);
|
||||
const error = node('p', 'field-error'); error.id = `${id}-error`; error.hidden = true;
|
||||
error.setAttribute('role', 'status'); text.setAttribute('aria-describedby', error.id);
|
||||
const clear = node('button', 'color-inherit', 'Geerbten Wert verwenden'); clear.type = 'button';
|
||||
clear.addEventListener('click', () => choose(''));
|
||||
choices.append(customLabel, custom, error, clear);
|
||||
wrap.append(trigger, choices);
|
||||
|
||||
function sync() {
|
||||
const shown = current || inheritedValue, entry = entryFor(shown), hex = resolve(shown);
|
||||
ink.style.backgroundColor = hex || 'transparent';
|
||||
name.textContent = entry?.name || (hex ? 'Eigene Farbe' : current ? 'Unbekannte Palettenfarbe' : 'Übernehmen');
|
||||
detail.textContent = [!current ? 'Geerbt' : entry ? 'Palette' : '', hex || (current || 'Kein eigener Farbwert')].filter(Boolean).join(' · ');
|
||||
trigger.setAttribute('aria-label', `${label}: ${name.textContent}, ${detail.textContent}. Farbe auswählen`);
|
||||
picker.value = hex ? hex.slice(0, 7) : '#ffffff';
|
||||
text.value = hex || current || '';
|
||||
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.color === shown));
|
||||
clear.disabled = current === '';
|
||||
}
|
||||
const finish = () => {
|
||||
if (!editing) return;
|
||||
editing = false;
|
||||
onComplete();
|
||||
};
|
||||
trigger.addEventListener('click', () => {
|
||||
if (!choices.hidden) {
|
||||
choices.hidden = true; trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
} else {
|
||||
choices.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
// A native picker emits many input events during one gesture. Keep its DOM and one undo entry.
|
||||
picker.addEventListener('input', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
editing = true; sync(); onChange(current, mergeKey);
|
||||
});
|
||||
picker.addEventListener('change', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
sync(); onChange(current, editing ? mergeKey : undefined);
|
||||
if (editing) finish();
|
||||
restoreFocus();
|
||||
});
|
||||
const applyText = () => {
|
||||
let raw = text.value.trim();
|
||||
if (/^#[0-9a-f]{3}$/i.test(raw)) raw = '#' + [...raw.slice(1)].map(char => char + char).join('');
|
||||
if (raw && !hexPattern.test(raw) && !entryFor(raw)) {
|
||||
text.setAttribute('aria-invalid', 'true'); error.hidden = false;
|
||||
error.textContent = 'Hexfarbe wie #1a2b3c oder eine vorhandene Palettenfarbe eingeben.';
|
||||
text.focus(); return;
|
||||
}
|
||||
choose(raw);
|
||||
};
|
||||
text.addEventListener('input', () => { text.removeAttribute('aria-invalid'); error.hidden = true; });
|
||||
text.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); applyText(); }
|
||||
});
|
||||
accept.addEventListener('click', applyText);
|
||||
wrap.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || choices.hidden) return;
|
||||
event.preventDefault(); event.stopPropagation(); choices.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
});
|
||||
// Commit after leaving the native picker even on browsers that omit its change event.
|
||||
wrap.addEventListener('focusout', () => setTimeout(() => {
|
||||
if (wrap.isConnected && !wrap.contains(document.activeElement)) finish();
|
||||
}, 0));
|
||||
sync();
|
||||
return wrap;
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
* appear here without touching this file.
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
export const GROUPS = {
|
||||
text: 'Typografie',
|
||||
@@ -204,31 +205,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const colorControl = (id, value, apply) => {
|
||||
const wrap = element('div', 'color-control');
|
||||
const picker = document.createElement('input');
|
||||
picker.type = 'color';
|
||||
picker.id = id;
|
||||
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
|
||||
const text = document.createElement('input');
|
||||
text.type = 'text';
|
||||
text.className = 'color-text';
|
||||
text.value = value ?? '';
|
||||
text.placeholder = '#1a2b3c';
|
||||
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
|
||||
const palette = document.createElement('select');
|
||||
palette.className = 'color-palette';
|
||||
palette.setAttribute('aria-label', 'Farbe aus der Palette');
|
||||
palette.append(new Option('Palette …', ''));
|
||||
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
|
||||
palette.value = (value ?? '').startsWith('@') ? value : '';
|
||||
|
||||
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
|
||||
text.addEventListener('change', () => apply(text.value.trim()));
|
||||
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
|
||||
wrap.append(picker, text, palette);
|
||||
return wrap;
|
||||
};
|
||||
const colorControl = (id, value, apply, label, inheritedValue = '') => createColorControl({
|
||||
id, value, label, inheritedValue, colors: schema.colors, onChange: apply,
|
||||
onComplete: () => {
|
||||
const focusedId = document.activeElement?.id;
|
||||
onChange();
|
||||
if (focusedId) document.getElementById(focusedId)?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||
const wrap = element('div', 'length-control');
|
||||
@@ -444,15 +428,18 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
const mediaControl = (key, value, apply) => {
|
||||
const wrap = element('div', 'media-control');
|
||||
const status = element('p', 'media-chosen', value ? `Bild gewählt (${String(value).slice(0, 8)}…)` : 'Kein Bild gewählt');
|
||||
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||
const hasImage = Boolean(value && value !== 'none');
|
||||
const preview = createMediaPreview({ mediaId: hasImage ? value : '' });
|
||||
const choose = element('button', 'secondary', hasImage ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||
choose.type = 'button';
|
||||
choose.id = `style-${key}`; choose.setAttribute('aria-label', choose.textContent);
|
||||
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
|
||||
wrap.append(status, choose);
|
||||
if (value) {
|
||||
wrap.append(preview.element, choose);
|
||||
if (hasImage) {
|
||||
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
||||
remove.type = 'button';
|
||||
remove.addEventListener('click', () => apply(''));
|
||||
remove.title = 'Hintergrundbild für dieses Gerät entfernen. Die Datei bleibt in der Mediathek.';
|
||||
remove.addEventListener('click', () => { apply('none'); document.getElementById(choose.id)?.focus(); });
|
||||
wrap.append(remove);
|
||||
}
|
||||
return wrap;
|
||||
@@ -536,12 +523,12 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply, property.label, current.value || '');
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||||
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
|
||||
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
@@ -549,9 +536,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
if (schema.hoverable.includes(key)) {
|
||||
const hoverId = `${id}-hover`;
|
||||
const hoverValue = hoverStyle(block, deviceName, key);
|
||||
let hoverInherited = current.value || '';
|
||||
for (const name of Object.keys(devices)) {
|
||||
if (name === deviceName) break;
|
||||
hoverInherited = hoverStyle(block, name, key) ?? hoverInherited;
|
||||
}
|
||||
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||||
const hoverControl = property.type === 'color'
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited)
|
||||
: property.type === 'number'
|
||||
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
|
||||
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
|
||||
@@ -618,6 +610,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
};
|
||||
|
||||
function describe(property, value) {
|
||||
if (property.type === 'media') return value === 'none' ? 'Kein Bild' : 'Bild aus der Mediathek';
|
||||
if (property.type === 'choice') return property.values[value] ?? value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -148,6 +148,8 @@ export function insertionTarget(document, selected, type) {
|
||||
}
|
||||
const probe = { type };
|
||||
let item = locate(document.blocks, selected);
|
||||
// Content belongs to a chosen entry. Do not silently place it outside the accordion.
|
||||
if (item?.block.type === 'accordion' && type !== 'accordionitem') return null;
|
||||
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
|
||||
while (item) {
|
||||
const parentId = item.parent?.id ?? null;
|
||||
@@ -252,7 +254,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 19;
|
||||
export const SCHEMA_VERSION = 20;
|
||||
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
|
||||
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
@@ -310,7 +312,7 @@ export function mediaProps(media) {
|
||||
export function insertMedia(document, selected, items) {
|
||||
const target = locate(document.blocks, selected)?.block;
|
||||
upgradeDocument(document, 3);
|
||||
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
|
||||
if (target?.type === 'image') { target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' }; return target.id; }
|
||||
let parent = target;
|
||||
if (target?.type !== 'gallery') {
|
||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||
@@ -496,6 +498,7 @@ export function setStyle(document, id, device, key, value, { side = null, hover
|
||||
if (!block) return;
|
||||
const empty = value === '' || value === null || value === undefined;
|
||||
if (!empty) upgradeDocument(document, 10);
|
||||
if (key === 'backgroundImage' && value === 'none') upgradeDocument(document, 20);
|
||||
|
||||
block.style ??= {};
|
||||
block.style[device] ??= {};
|
||||
|
||||
@@ -110,6 +110,9 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}
|
||||
.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}
|
||||
.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}
|
||||
.accordion-entry-list{display:grid;gap:7px;margin:12px 0}
|
||||
.accordion-entry-choice{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:#f7f9f3;color:var(--ink);font-size:12px;line-height:1.5;text-align:left;overflow-wrap:anywhere}
|
||||
.accordion-entry-choice:hover{border-color:var(--accent);background:#edf3e7}
|
||||
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}
|
||||
.design-reset{margin-top:24px;white-space:normal;width:100%}
|
||||
.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}
|
||||
@@ -131,15 +134,39 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
|
||||
.design-row{margin-top:14px}
|
||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.design-row-head label{margin:0;font-size:11px}
|
||||
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
|
||||
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
|
||||
.color-control .color-text{font-size:12px;min-height:38px}
|
||||
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
|
||||
.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
|
||||
.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-trigger:hover{background:#f5f7f3}
|
||||
.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
|
||||
.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px}
|
||||
.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit}
|
||||
.color-description{display:grid;gap:3px;flex:1;min-width:0}
|
||||
.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
|
||||
.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere}
|
||||
.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c}
|
||||
.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)}
|
||||
.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8}
|
||||
.color-choices[hidden]{display:none}
|
||||
.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258}
|
||||
.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin}
|
||||
.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer}
|
||||
.color-option:hover{background:#eef3ea;border-color:#718575}
|
||||
.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8}
|
||||
.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426}
|
||||
.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.color-custom{display:flex;align-items:center;gap:6px}
|
||||
.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff}
|
||||
.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px}
|
||||
.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0}
|
||||
.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer}
|
||||
.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none}
|
||||
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
|
||||
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
||||
.length-control.is-inherited input{color:var(--muted)}
|
||||
@@ -193,6 +220,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
|
||||
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
|
||||
.rt-link input{font-size:11px;min-height:30px}
|
||||
.rt-link{flex-wrap:wrap;align-items:center}.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
|
||||
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
|
||||
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
|
||||
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
||||
@@ -311,3 +339,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
@media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}}
|
||||
|
||||
.canvas-toolbar{flex-wrap:wrap;gap:8px}.canvas-toolbar #preview-motion{white-space:nowrap;min-height:36px}
|
||||
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
|
||||
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
|
||||
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
|
||||
+150
-20
@@ -1,5 +1,5 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||
@@ -101,7 +101,7 @@ function controls() {
|
||||
function renderTree() {
|
||||
const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null;
|
||||
const query = $('#tree-search').value.trim().toLocaleLowerCase('de');
|
||||
const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||||
const matches = block => `${blockLabel(block)} ${block.props?.text || ''} ${block.props?.alt || ''} ${block.props?.title || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||||
const build = blocks => {
|
||||
const list = document.createElement('ul'); list.className = 'structure-list';
|
||||
for (const block of blocks) {
|
||||
@@ -119,9 +119,10 @@ function renderTree() {
|
||||
button.className = 'tree-item'; button.type = 'button'; button.draggable = true;
|
||||
button.dataset.blockId = block.id;
|
||||
button.setAttribute('aria-pressed', String(block.id === selected));
|
||||
button.setAttribute('aria-label', `${blockLabel(block)}: ${block.props?.text || block.props?.alt || blockLabel(block)}`);
|
||||
const displayText = block.props?.text || block.props?.alt || block.props?.title || blockLabel(block);
|
||||
button.setAttribute('aria-label', `${blockLabel(block)}: ${displayText}`);
|
||||
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = blockIcon(block);
|
||||
const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || blockLabel(block);
|
||||
const text = document.createElement('span'); text.textContent = displayText;
|
||||
button.append(icon, text); item.append(button);
|
||||
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
|
||||
list.append(item);
|
||||
@@ -177,7 +178,9 @@ function renderProperties() {
|
||||
}
|
||||
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
|
||||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||||
let imagePreview;
|
||||
const field = (label, key, value, options = {}) => {
|
||||
const container = options.container || panel;
|
||||
const id = `property-${key}`;
|
||||
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
|
||||
let input;
|
||||
@@ -189,8 +192,13 @@ function renderProperties() {
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
|
||||
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||||
if (options.maxLength) input.maxLength = options.maxLength;
|
||||
input.addEventListener('input', () => {
|
||||
let previousChoice = input.value;
|
||||
const applyValue = () => {
|
||||
const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value;
|
||||
// Native selects may emit input, change, or both. Apply each selection once and keep
|
||||
// the control in place while its popup or keyboard navigation is active.
|
||||
if (options.choices && input.value === previousChoice) return;
|
||||
previousChoice = input.value;
|
||||
history.change(doc => {
|
||||
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||||
if (!current) return;
|
||||
@@ -203,14 +211,20 @@ function renderProperties() {
|
||||
else current[key] = next;
|
||||
if (key === 'caption') upgradeDocument(doc, 3);
|
||||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, `${selected || 'page'}:${key}`);
|
||||
if (key === 'decorative') renderProperties();
|
||||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||
options.onInput?.(next);
|
||||
if (key === 'decorative') { renderProperties(); document.getElementById(id)?.focus({ preventScroll: true }); }
|
||||
renderTree(); controls(); schedulePreview();
|
||||
});
|
||||
};
|
||||
input.addEventListener('input', applyValue);
|
||||
if (options.choices) input.addEventListener('change', applyValue);
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) panel.append(labelEl); else panel.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); panel.append(help); }
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
@@ -310,12 +324,31 @@ function renderProperties() {
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
const hint = document.createElement('p'); hint.className = 'field-help';
|
||||
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
|
||||
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
|
||||
for (const [index, entry] of (block.children ?? []).entries()) {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = 'accordion-entry-choice';
|
||||
button.textContent = `${index + 1}. ${entry.props?.title || 'Ohne Titel'}`;
|
||||
button.setAttribute('aria-label', `Eintrag ${index + 1}: ${entry.props?.title || 'Ohne Titel'} bearbeiten`);
|
||||
button.addEventListener('click', () => { propertyPanel = 'content'; choose(entry.id); });
|
||||
entries.append(button);
|
||||
}
|
||||
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
|
||||
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
|
||||
panel.append(hint, entries, add);
|
||||
}
|
||||
if (block.type === 'accordionitem') {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
|
||||
const add = document.createElement('button'); add.type = 'button'; add.className = 'secondary full-width';
|
||||
add.textContent = '+ Inhalt hinzufügen';
|
||||
add.addEventListener('click', () => beginContainerInsert(block.id));
|
||||
panel.append(add);
|
||||
}
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' });
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
@@ -333,15 +366,25 @@ function renderProperties() {
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'image') {
|
||||
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
|
||||
if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
renderImageWidth(block, panel);
|
||||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||
panel.append(imagePreview.element);
|
||||
const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options';
|
||||
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
|
||||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
showLinkOptions(clickAction); panel.append(linkOptions);
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen';
|
||||
remove.title = 'Bild aus diesem Inhalt entfernen. Die Datei bleibt in der Mediathek.';
|
||||
remove.addEventListener('click', () => { actions.delete(block.id); $('#undo').focus(); }); panel.append(remove);
|
||||
field('Bildadresse', 'src', block.props.src, { help: 'Lokale Datei oder HTTPS-Adresse. Über die Mediathek wird die passende Bildvariante automatisch gewählt.' });
|
||||
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
|
||||
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
|
||||
field('Originalbreite (px)', 'width', block.props.width, { number: true });
|
||||
field('Originalhöhe (px)', 'height', block.props.height, { number: true });
|
||||
field('Originalhöhe (px)', 'height', block.props.height, { number: true, help: 'Abmessungen der Bilddatei. Die Anzeigegröße stellst du oben unter Bildbreite ein.' });
|
||||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||
}
|
||||
if (block.type === 'gallery') {
|
||||
@@ -350,6 +393,22 @@ function renderProperties() {
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
}
|
||||
if (['gallery', 'logos'].includes(block.type)) {
|
||||
const images = document.createElement('div'); images.className = 'selected-media-list';
|
||||
for (const child of block.children || []) {
|
||||
const item = document.createElement('div');
|
||||
const title = child.props.alt || child.props.caption || 'Bild';
|
||||
item.append(createMediaPreview({ src: child.props.sources?.[0]?.src || child.props.src, label: title }).element);
|
||||
const edit = document.createElement('button'); edit.type = 'button'; edit.className = 'text-button'; edit.textContent = 'Bearbeiten'; edit.setAttribute('aria-label', `${title} bearbeiten`);
|
||||
edit.addEventListener('click', () => choose(child.id));
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Entfernen'; remove.setAttribute('aria-label', `${title} entfernen`);
|
||||
remove.addEventListener('click', () => {
|
||||
history.change(doc => { const found = locate(doc.blocks, child.id); if (found) found.siblings.splice(found.index, 1); }); changed(); $('#undo').focus();
|
||||
});
|
||||
item.append(edit, remove); images.append(item);
|
||||
}
|
||||
panel.append(images);
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
|
||||
@@ -397,6 +456,48 @@ function renderAdvanced(block, panel) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderImageWidth(block, panel) {
|
||||
const group = document.createElement('div'); group.className = 'image-width-control';
|
||||
const label = document.createElement('label'); label.htmlFor = 'image-width-percent'; label.textContent = 'Bildbreite (%)';
|
||||
const row = document.createElement('div'); row.className = 'image-width-inputs';
|
||||
const slider = document.createElement('input'); slider.type = 'range'; slider.setAttribute('aria-label', 'Bildbreite Schieberegler');
|
||||
const exact = document.createElement('input'); exact.type = 'number'; exact.id = label.htmlFor;
|
||||
for (const input of [slider, exact]) { input.min = '1'; input.max = '100'; input.step = '0.1'; input.setAttribute('aria-describedby', 'image-width-help image-width-state'); }
|
||||
const state = document.createElement('p'); state.id = 'image-width-state'; state.className = 'field-help';
|
||||
const help = document.createElement('p'); help.id = 'image-width-help'; help.className = 'field-help';
|
||||
help.textContent = 'Anteil an der verfügbaren Breite der Sektion oder Spalte. Die Originalmaße der Datei bleiben erhalten.';
|
||||
const error = document.createElement('p'); error.className = 'field-help image-width-error'; error.setAttribute('role', 'alert'); error.hidden = true;
|
||||
error.textContent = 'Bitte 1 bis 100 eingeben. Die bisherige Breite bleibt bis dahin erhalten.';
|
||||
const reset = document.createElement('button'); reset.type = 'button'; reset.id = 'image-width-reset'; reset.className = 'text-button'; reset.textContent = 'Breite zurücksetzen';
|
||||
const sync = (keepInput = false) => {
|
||||
const current = locate(history.document.blocks, block.id)?.block;
|
||||
const { value, source } = effectiveStyle(current, device, 'width');
|
||||
const percent = typeof value === 'string' && /^\d+(?:\.\d+)?%$/.test(value) ? parseFloat(value) : null;
|
||||
if (!keepInput) exact.value = percent ?? '';
|
||||
exact.placeholder = value && percent === null ? value : '100';
|
||||
slider.value = percent ?? 100;
|
||||
slider.setAttribute('aria-valuetext', percent !== null ? `${percent} Prozent` : `Aktuell ${value || 'automatisch'}. Prozentwert wählen.`);
|
||||
state.textContent = `${devices[device]} · ${source === device ? 'Eigener Wert' : source ? `Von ${devices[source]} übernommen` : 'Automatische Breite'}${value ? ` (${value})` : ''}`;
|
||||
const maximum = effectiveStyle(current, device, 'maxWidth').value;
|
||||
if (maximum && maximum !== 'none') state.textContent += `. Maximale Breite: ${maximum} (Design → Größe).`;
|
||||
};
|
||||
const apply = (value, keepInput = false) => {
|
||||
error.hidden = true; exact.removeAttribute('aria-invalid');
|
||||
history.change(doc => setStyle(doc, block.id, device, 'width', value), `image-width:${block.id}:${device}`);
|
||||
controls(); schedulePreview(); sync(keepInput);
|
||||
};
|
||||
slider.addEventListener('input', () => apply(`${slider.value}%`));
|
||||
exact.addEventListener('input', () => {
|
||||
if (exact.validity.badInput || (exact.value !== '' && !exact.checkValidity())) { error.hidden = false; exact.setAttribute('aria-invalid', 'true'); return; }
|
||||
apply(exact.value === '' ? '' : `${Number(exact.value)}%`, true);
|
||||
});
|
||||
// A whole drag or keyboard edit is one undo step, independent of preview requests.
|
||||
slider.addEventListener('change', () => { history.mergeKey = null; });
|
||||
exact.addEventListener('blur', () => { history.mergeKey = null; if (error.hidden) sync(); });
|
||||
reset.addEventListener('click', () => { history.mergeKey = null; apply(''); history.mergeKey = null; });
|
||||
row.append(slider, exact); group.append(label, row, state, help, error, reset); panel.append(group); sync();
|
||||
}
|
||||
|
||||
function renderDesign(block, panel) {
|
||||
if (designSchema && designPanel) {
|
||||
designPanel(panel);
|
||||
@@ -641,14 +742,16 @@ async function updatePreview() {
|
||||
const id = insert.dataset.editorTarget;
|
||||
if (!locate(history.document.blocks, id)) return;
|
||||
choose(id, false);
|
||||
if (insert.hasAttribute('data-editor-row')) {
|
||||
if (insert.hasAttribute('data-editor-add-entry')) {
|
||||
addAccordionEntry(id);
|
||||
} else if (insert.hasAttribute('data-editor-row')) {
|
||||
setPendingInsert(null);
|
||||
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
|
||||
propertyPanel = 'content'; changed();
|
||||
} else if (locate(history.document.blocks, id).block.type === 'row') {
|
||||
selected = history.change(content => insertBlockAt(content, 'column', id, 0));
|
||||
changed();
|
||||
} else setPendingInsert({ parentId: id, index: 0 });
|
||||
} else beginContainerInsert(id);
|
||||
return;
|
||||
}
|
||||
if (inlineEdit) return;
|
||||
@@ -776,9 +879,28 @@ function addElement(type) {
|
||||
selected = history.change(doc => at
|
||||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||||
: insertBlock(doc, type, selected));
|
||||
if (type === 'accordion') selected = locate(history.document.blocks, selected)?.block.children?.[0]?.id ?? selected;
|
||||
if (['accordion', 'accordionitem'].includes(type)) propertyPanel = 'content';
|
||||
changed();
|
||||
}
|
||||
|
||||
function beginContainerInsert(id) {
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (!block || busy) return;
|
||||
propertyPanel = 'content';
|
||||
choose(id, false);
|
||||
setPendingInsert({ parentId: id, index: block.children?.length ?? 0 });
|
||||
}
|
||||
|
||||
function addAccordionEntry(id) {
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (busy || block?.type !== 'accordion') return;
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => insertBlockAt(doc, 'accordionitem', id, block.children?.length ?? 0));
|
||||
collapsed.delete(id); propertyPanel = 'content'; changed();
|
||||
$('#property-title')?.focus();
|
||||
}
|
||||
|
||||
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
|
||||
function setPendingInsert(target) {
|
||||
pendingInsert = target;
|
||||
@@ -864,7 +986,11 @@ function updateLibrary() {
|
||||
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
|
||||
: insertionTarget(history.document, selected, type);
|
||||
button.disabled = !target;
|
||||
if (!target) { button.title = whyNot(type); continue; }
|
||||
if (!target) {
|
||||
button.title = locate(history.document.blocks, selected)?.block.type === 'accordion'
|
||||
? 'Wähle zuerst einen Akkordeon-Eintrag, um darin Inhalte einzufügen.' : whyNot(type);
|
||||
continue;
|
||||
}
|
||||
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
|
||||
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
|
||||
}
|
||||
@@ -1011,7 +1137,11 @@ overlay = createCanvasOverlay({
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
: insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||||
if (id) { selected = id; changed(); }
|
||||
if (id) {
|
||||
selected = payload.type === 'accordion' ? locate(history.document.blocks, id)?.block.children?.[0]?.id ?? id : id;
|
||||
if (['accordion', 'accordionitem'].includes(payload.type)) propertyPanel = 'content';
|
||||
changed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { setupPicker } from './page-picker.js';
|
||||
|
||||
const form = document.querySelector('[data-live-search]');
|
||||
if (form) {
|
||||
const results = document.querySelector('#list-results');
|
||||
const status = document.querySelector('#search-status');
|
||||
const error = document.querySelector('#search-error');
|
||||
const search = form.querySelector('input[type="search"]');
|
||||
const fields = [...form.querySelectorAll('input[name],select[name]')];
|
||||
let timer, controller, sequence = 0, composing = false;
|
||||
|
||||
const cancel = () => { clearTimeout(timer); controller?.abort(); sequence++; results.removeAttribute('aria-busy'); };
|
||||
const updateReset = () => {
|
||||
form.querySelector('[data-search-reset]').hidden = !fields.some(field => field.value !== (field.dataset.searchDefault || ''));
|
||||
};
|
||||
const urlFor = parameters => {
|
||||
const url = new URL(form.action);
|
||||
for (const [name, value] of Object.entries(parameters)) {
|
||||
const fallback = fields.find(field => field.name === name)?.dataset.searchDefault || (name === 'seite' ? '1' : '');
|
||||
if (String(value) !== fallback) url.searchParams.set(name, String(value));
|
||||
}
|
||||
return url;
|
||||
};
|
||||
const currentUrl = () => urlFor({ ...Object.fromEntries(new FormData(form)), q: search.value.trim(), seite: 1 });
|
||||
const restoreFilters = url => {
|
||||
for (const field of fields) field.value = url.searchParams.get(field.name) ?? field.dataset.searchDefault ?? '';
|
||||
updateReset();
|
||||
};
|
||||
|
||||
async function load(url, navigation = 'replace', focusResults = false) {
|
||||
cancel(); const request = sequence;
|
||||
controller = new AbortController(); const signal = controller.signal;
|
||||
results.setAttribute('aria-busy', 'true'); error.hidden = true;
|
||||
status.textContent = 'Ergebnisse werden gesucht …';
|
||||
updateReset();
|
||||
try {
|
||||
const response = await fetch(url, { signal, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' } });
|
||||
if (response.redirected || response.status === 401 || response.status === 403) throw new Error('Deine Sitzung ist abgelaufen. Bitte erneut anmelden und die Suche wiederholen.');
|
||||
if (!response.ok) throw new Error('Die Suche konnte nicht geladen werden. Die bisherigen Ergebnisse bleiben sichtbar.');
|
||||
const data = await response.json();
|
||||
if (signal.aborted || request !== sequence) return;
|
||||
if (typeof data.html !== 'string' || !data.parameters || !Number.isInteger(data.total)) throw new Error('Die Suchantwort konnte nicht verarbeitet werden.');
|
||||
const parsed = new DOMParser().parseFromString(data.html, 'text/html');
|
||||
results.replaceChildren(...parsed.body.childNodes);
|
||||
results.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
// Keep create/upload forms outside the replaced list on the same filtered screen.
|
||||
const context = { _list_query: data.parameters.q, _list_status: data.parameters.status, _list_limit: data.parameters.limit, _list_page: data.parameters.seite, q: data.parameters.q, archiv: data.parameters.archiv, seite: data.parameters.seite };
|
||||
for (const [name, value] of Object.entries(context)) if (value !== undefined) {
|
||||
document.querySelectorAll(`form:not([data-live-search]) input[type="hidden"][name="${name}"]`).forEach(input => { input.value = value; });
|
||||
}
|
||||
const canonical = urlFor(data.parameters);
|
||||
if (navigation !== 'none' && canonical.href !== location.href) history[navigation === 'push' ? 'pushState' : 'replaceState'](null, '', canonical);
|
||||
status.textContent = `${data.total} Treffer${data.parameters.q ? ` für „${data.parameters.q}“` : ''}.`;
|
||||
if (focusResults) { results.focus({ preventScroll: true }); results.scrollIntoView({ block: 'start' }); }
|
||||
} catch (failure) {
|
||||
if (signal.aborted || request !== sequence) return;
|
||||
error.querySelector('[role="alert"]').textContent = failure instanceof TypeError ? 'Verbindung unterbrochen. Die bisherigen Ergebnisse bleiben sichtbar.' : failure.message;
|
||||
error.hidden = false; status.textContent = 'Suche fehlgeschlagen.';
|
||||
} finally {
|
||||
if (request === sequence) results.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
cancel(); updateReset();
|
||||
if (composing) return;
|
||||
error.hidden = true; status.textContent = 'Ergebnisse werden gesucht …'; results.setAttribute('aria-busy', 'true');
|
||||
timer = setTimeout(() => load(currentUrl()), search.value ? 220 : 0);
|
||||
};
|
||||
search.addEventListener('compositionstart', () => { composing = true; cancel(); });
|
||||
search.addEventListener('compositionend', () => { composing = false; queue(); });
|
||||
search.addEventListener('input', queue);
|
||||
form.addEventListener('change', event => { if (event.target.matches('select')) load(currentUrl(), 'push'); });
|
||||
form.addEventListener('submit', event => { event.preventDefault(); if (!composing) load(currentUrl()); });
|
||||
form.querySelector('[data-search-reset]').addEventListener('click', event => {
|
||||
event.preventDefault(); const url = new URL(form.action); restoreFilters(url); load(url, 'push'); search.focus();
|
||||
});
|
||||
results.addEventListener('click', event => {
|
||||
const link = event.target.closest('.pager a');
|
||||
if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault(); load(new URL(link.href), 'push', true);
|
||||
});
|
||||
// A delayed response must not overwrite a form the user has just started editing.
|
||||
results.addEventListener('input', () => {
|
||||
if (results.getAttribute('aria-busy') === 'true') { cancel(); status.textContent = 'Suche angehalten, während du einen Eintrag bearbeitest.'; }
|
||||
});
|
||||
window.addEventListener('popstate', () => { const url = new URL(location.href); restoreFilters(url); load(url, 'none'); });
|
||||
window.addEventListener('pagehide', cancel);
|
||||
document.querySelector('#search-retry').addEventListener('click', () => load(currentUrl()));
|
||||
form.classList.add('live-search-ready');
|
||||
status.textContent = 'Die Suche aktualisiert sich beim Tippen.';
|
||||
search.setAttribute('aria-controls', results.id);
|
||||
updateReset();
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
|
||||
|
||||
const previews = new Map();
|
||||
async function previewItem(id) {
|
||||
if (!previews.has(id)) previews.set(id, fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media/' + id, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('Bild nicht erreichbar');
|
||||
return (await response.json()).item;
|
||||
}).catch(error => { previews.delete(id); throw error; }));
|
||||
return previews.get(id);
|
||||
}
|
||||
|
||||
/** A preview of the current selection, including small originals without a 480 px variant. */
|
||||
export function createMediaPreview(initial = {}) {
|
||||
const wrap = element('figure', '', 'selected-media-preview');
|
||||
const image = element('img'); image.alt = ''; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; image.hidden = true;
|
||||
const caption = element('figcaption'); caption.setAttribute('aria-live', 'polite');
|
||||
wrap.append(image, caption); let sequence = 0;
|
||||
const update = async ({ src = '', mediaId = '', label = '' } = {}) => {
|
||||
const request = ++sequence; image.hidden = true; image.removeAttribute('src');
|
||||
caption.textContent = src || mediaId ? 'Bildvorschau wird geladen …' : 'Kein Bild ausgewählt';
|
||||
if (!src && !mediaId) return;
|
||||
try {
|
||||
if (!src && /^[a-f0-9]{32}$/.test(mediaId)) {
|
||||
const item = await previewItem(mediaId);
|
||||
if (request !== sequence) return;
|
||||
src = item.variants[0]?.src || ''; label ||= item.title;
|
||||
}
|
||||
const url = new URL(src, location.origin);
|
||||
if (!(src.startsWith('/') && !src.startsWith('//') && url.origin === location.origin) && url.protocol !== 'https:') throw new Error();
|
||||
if (!src) throw new Error();
|
||||
const description = label || decodeURIComponent(url.pathname.split('/').pop()) || 'Ausgewähltes Bild';
|
||||
image.onload = () => { if (request === sequence) { image.hidden = false; caption.textContent = description; } };
|
||||
image.onerror = () => { if (request === sequence) { image.hidden = true; caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.'; } };
|
||||
image.src = url.href;
|
||||
} catch {
|
||||
if (request === sequence) caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.';
|
||||
}
|
||||
};
|
||||
update(initial);
|
||||
return { element: wrap, update };
|
||||
}
|
||||
|
||||
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
||||
@@ -24,6 +64,7 @@ export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
|
||||
for (const item of data.items || (data.item ? [data.item] : [])) previews.set(item.id, Promise.resolve(item));
|
||||
return data;
|
||||
}
|
||||
function render() {
|
||||
|
||||
@@ -9,7 +9,7 @@ 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; });
|
||||
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.type === 'checkbox' ? (input.checked ? '1' : '0') : input.value; });
|
||||
data.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
|
||||
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
|
||||
return data;
|
||||
@@ -49,7 +49,10 @@ function createRow(data) {
|
||||
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); }
|
||||
if (type !== 'hidden') {
|
||||
const caption = el('label', type === 'checkbox' ? 'check-label' : '', label); caption.htmlFor = input.id;
|
||||
if (type === 'checkbox') { caption.prepend(input); body.append(caption); } else body.append(caption, input);
|
||||
}
|
||||
else row.append(input);
|
||||
return input;
|
||||
};
|
||||
@@ -68,6 +71,7 @@ function createRow(data) {
|
||||
body.append(picker); setupPicker(picker);
|
||||
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
||||
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
|
||||
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
|
||||
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
|
||||
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
|
||||
@@ -101,7 +105,8 @@ form.querySelector('#menu-add-page').addEventListener('click', () => {
|
||||
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 = '';
|
||||
const newTab = form.querySelector('#link-new-tab');
|
||||
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), newTab: newTab.checked, level: 1 }); name.value = url.value = ''; newTab.checked = false;
|
||||
});
|
||||
form.addEventListener('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
|
||||
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
|
||||
|
||||
@@ -5,22 +5,28 @@ export function decorateContainers(preview, content) {
|
||||
const visit = (blocks, parent = null) => {
|
||||
blocks.forEach((block, index) => {
|
||||
const node = preview.getElementById(block.id);
|
||||
if (node && ['section', 'row', 'column'].includes(block.type)) {
|
||||
// Editing must expose the body without changing the visitor's saved open/closed state.
|
||||
if (node && block.type === 'accordionitem') node.open = true;
|
||||
if (node && ['section', 'row', 'column', 'accordion', 'accordionitem'].includes(block.type)) {
|
||||
const label = block.type === 'column' && parent?.type === 'row'
|
||||
? `Spalte ${index + 1} von ${blocks.length}` : blockLabel(block);
|
||||
? `Spalte ${index + 1} von ${blocks.length}` : block.type === 'accordionitem'
|
||||
? `Eintrag „${block.props?.title || 'Ohne Titel'}“` : blockLabel(block);
|
||||
node.classList.add('editor-container');
|
||||
node.dataset.editorLabel = label;
|
||||
if (!block.children?.length) {
|
||||
node.classList.add('editor-empty-container');
|
||||
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner') : node;
|
||||
if (!block.children?.length || ['accordion', 'accordionitem'].includes(block.type)) {
|
||||
const compact = Boolean(block.children?.length);
|
||||
if (!compact) node.classList.add('editor-empty-container');
|
||||
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner')
|
||||
: block.type === 'accordionitem' ? node.querySelector(':scope > .accordion-body') : node;
|
||||
if (!host) return;
|
||||
const empty = preview.createElement('div');
|
||||
empty.className = 'editor-empty';
|
||||
empty.className = compact ? 'editor-empty editor-add-content' : 'editor-empty';
|
||||
const name = preview.createElement('span');
|
||||
name.className = 'editor-empty-name'; name.textContent = label;
|
||||
const hint = preview.createElement('span');
|
||||
hint.className = 'editor-empty-hint';
|
||||
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen' : 'Element hierher ziehen oder hinzufügen';
|
||||
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen'
|
||||
: block.type === 'accordion' ? 'Eintrag hinzufügen und darin Inhalte gestalten' : 'Element hierher ziehen oder hinzufügen';
|
||||
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
|
||||
const add = (text, attribute, value, accessibleName) => {
|
||||
const button = preview.createElement('button');
|
||||
@@ -31,10 +37,15 @@ export function decorateContainers(preview, content) {
|
||||
actions.append(button);
|
||||
};
|
||||
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
|
||||
if (block.type === 'accordion') {
|
||||
actions.replaceChildren();
|
||||
add('+ Eintrag hinzufügen', 'data-editor-add-entry', '', 'Akkordeon: Eintrag hinzufügen');
|
||||
}
|
||||
if (block.type === 'section') {
|
||||
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
|
||||
}
|
||||
empty.append(name, hint, actions);
|
||||
if (!compact) empty.append(name, hint);
|
||||
empty.append(actions);
|
||||
host.append(empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,6 @@
|
||||
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
|
||||
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
|
||||
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
|
||||
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
|
||||
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
|
||||
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
@@ -83,7 +83,10 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
linkInput.type = 'text';
|
||||
linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…';
|
||||
linkInput.setAttribute('aria-label', 'Linkziel');
|
||||
let savedRange = null;
|
||||
const newTabLabel = document.createElement('label'); newTabLabel.className = 'check-label';
|
||||
const newTab = document.createElement('input'); newTab.type = 'checkbox';
|
||||
newTabLabel.append(newTab, document.createTextNode(' In neuem Tab öffnen'));
|
||||
let savedRange = null, currentLink = null;
|
||||
const confirmLink = () => {
|
||||
const target = linkInput.value.trim();
|
||||
linkRow.hidden = true;
|
||||
@@ -93,8 +96,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(savedRange);
|
||||
}
|
||||
if (target) document.execCommand('createLink', false, target);
|
||||
else document.execCommand('unlink');
|
||||
if (target) {
|
||||
if (currentLink && surface.contains(currentLink)) currentLink.setAttribute('href', target);
|
||||
else document.execCommand('createLink', false, target);
|
||||
const range = window.getSelection().rangeCount ? window.getSelection().getRangeAt(0) : null;
|
||||
for (const anchor of surface.querySelectorAll('a')) {
|
||||
if (anchor !== currentLink && !(anchor.getAttribute('href') === target && range?.intersectsNode(anchor))) continue;
|
||||
if (newTab.checked) { anchor.setAttribute('target', '_blank'); anchor.setAttribute('rel', 'noopener'); }
|
||||
else { anchor.removeAttribute('target'); anchor.removeAttribute('rel'); }
|
||||
}
|
||||
} else document.execCommand('unlink');
|
||||
push(surface.innerHTML);
|
||||
linkInput.value = '';
|
||||
};
|
||||
@@ -102,14 +113,20 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
||||
});
|
||||
linkRow.append(linkInput, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
||||
savedRange = selection.rangeCount > 0 && surface.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0).cloneRange() : null;
|
||||
const node = savedRange?.startContainer;
|
||||
currentLink = (node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement)?.closest('a');
|
||||
if (currentLink && (!surface.contains(currentLink) || !currentLink.contains(savedRange.endContainer))) currentLink = null;
|
||||
linkInput.value = currentLink?.getAttribute('href') || '';
|
||||
newTab.checked = currentLink?.getAttribute('target') === '_blank';
|
||||
linkRow.hidden = false;
|
||||
linkInput.focus();
|
||||
}));
|
||||
linkRow.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); linkRow.hidden = true; surface.focus(); } });
|
||||
|
||||
const toggle = button('HTML', 'Zwischen Formatierung und HTML wechseln', () => {
|
||||
const toSource = source.hidden;
|
||||
|
||||
@@ -162,12 +162,6 @@ body.consent-open{padding-bottom:260px}
|
||||
.site-footer .block-column{border:0;border-radius:0;padding:0}
|
||||
.site-header .block-text,
|
||||
.site-footer .block-text{margin:0}
|
||||
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
|
||||
.legal-bar a{color:var(--accent);text-decoration:none}
|
||||
.legal-bar a:hover{text-decoration:underline}
|
||||
|
||||
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
|
||||
figure and left the picture square - which looked like the setting did nothing. */
|
||||
.block-image img, .block-gallery img, .block-logos img { border-radius: inherit }
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/accordion'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
data.document.blocks.unshift({ id: 'qa-accordion', type: 'accordion', children: [
|
||||
{ id: 'qa-entry', type: 'accordionitem', props: { title: 'Was ist enthalten?', open: false }, children: [] },
|
||||
{ id: 'qa-open', type: 'accordionitem', props: { title: 'Schon geöffnet', open: true }, children: [{ id: 'qa-existing', type: 'text', props: { text: '<p>Vorhandener Inhalt bleibt.</p>' } }] },
|
||||
] }, { id: 'qa-empty-accordion', type: 'accordion', children: [] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const frame = page.frameLocator('#preview');
|
||||
const ready = async () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
const select = async id => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator('[data-panel="content"]').click(); };
|
||||
await ready();
|
||||
await frame.locator('#qa-entry > summary').click();
|
||||
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
|
||||
assert.equal(await page.locator('#save').isDisabled(), true, 'Revealing the editor body must not change the document');
|
||||
await frame.locator('#qa-entry [data-editor-insert]').focus();
|
||||
await frame.locator('#qa-entry [data-editor-insert]').press('Enter');
|
||||
assert.match(await page.locator('#insert-hint').innerText(), /Was ist enthalten/);
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await ready(); await frame.locator('#qa-entry .block-text').waitFor();
|
||||
const textId = await frame.locator('#qa-entry .block-text').getAttribute('id');
|
||||
await page.getByLabel('Text', { exact: true }).fill('Text direkt im Akkordeon.'); await ready();
|
||||
assert.equal(await frame.locator('#qa-entry .block-text').innerText(), 'Text direkt im Akkordeon.');
|
||||
await page.locator('#undo').click(); await page.locator('#undo').click(); await ready();
|
||||
assert.equal(await frame.locator(`#${textId}`).count(), 0);
|
||||
await page.locator('#redo').click(); await page.locator('#redo').click(); await ready();
|
||||
assert.equal(await frame.locator(`#qa-entry #${textId}`).count(), 1);
|
||||
checks.push('closed entries reveal editable bodies and empty insert areas without changing public state; text insertion, editing and undo/redo');
|
||||
|
||||
await select('qa-accordion');
|
||||
assert.equal(await page.locator('#library [data-add="text"]').isDisabled(), true, 'Group selection must not put text outside the accordion');
|
||||
await page.getByRole('button', { name: 'Eintrag 1: Was ist enthalten? bearbeiten', exact: true }).click();
|
||||
await page.getByRole('button', { name: '+ Inhalt hinzufügen', exact: true }).click();
|
||||
await page.locator('#library [data-add="button"]').click(); await ready();
|
||||
const firstButtonId = await frame.locator('#qa-entry a.button').getAttribute('id');
|
||||
assert.equal(await frame.locator('#qa-entry .accordion-body > [id]').last().getAttribute('id'), firstButtonId, 'Add content appends after existing content');
|
||||
await select('qa-open');
|
||||
await page.locator('#library [data-add="button"]').dragTo(frame.locator('#qa-open .accordion-body'), { targetPosition: { x: 40, y: 20 } });
|
||||
await ready(); await frame.locator('#qa-open a.button').waitFor();
|
||||
const draggedId = await frame.locator('#qa-open a.button').getAttribute('id');
|
||||
await page.locator('#undo').click(); await ready(); assert.equal(await frame.locator(`#${draggedId}`).count(), 0);
|
||||
await page.locator('#redo').click(); await ready(); assert.equal(await frame.locator(`#qa-open #${draggedId}`).count(), 1);
|
||||
checks.push('entry selection in inspector, append content and drag/drop into an open body with undo/redo');
|
||||
|
||||
await frame.locator('#qa-accordion > .editor-add-content [data-editor-add-entry]').click();
|
||||
await page.getByLabel('Titel', { exact: true }).fill('Ein neuer Eintrag'); await ready();
|
||||
const newEntryId = await frame.locator('#qa-accordion > details').last().getAttribute('id');
|
||||
assert.equal(await frame.locator('#qa-accordion > details').count(), 3);
|
||||
assert.equal(await frame.locator(`#${newEntryId}`).evaluate(el => el.open), true);
|
||||
await select('qa-empty-accordion');
|
||||
await page.getByRole('button', { name: '+ Eintrag hinzufügen', exact: true }).click(); await ready();
|
||||
assert.equal(await frame.locator('#qa-empty-accordion > details').count(), 1);
|
||||
await page.getByLabel('Titel', { exact: true }).fill('Erster Eintrag'); await ready();
|
||||
await page.locator('#tree-search').fill('Ein neuer Eintrag');
|
||||
assert.equal(await page.locator(`#structure [data-block-id="${newEntryId}"]`).count(), 1);
|
||||
await page.locator('#tree-search').fill('');
|
||||
await select('qa-entry'); await page.locator('[data-panel="design"]').click();
|
||||
await page.locator('#library [data-add="accordion"]').click(); await ready();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Akkordeon-Eintrag');
|
||||
assert.equal(await page.getByLabel('Titel', { exact: true }).inputValue(), 'Eine Frage');
|
||||
await page.locator('#undo').click(); await ready();
|
||||
checks.push('add entries in canvas and inspector, recover empty accordion, search by title, new accordion starts with its editable entry selected');
|
||||
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await ready(); await select('qa-entry');
|
||||
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
|
||||
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
const saved = await (await context.request.get(api)).json();
|
||||
const accordion = saved.document.blocks.find(b => b.id === 'qa-accordion');
|
||||
assert.equal(accordion.children[0].props.open, false); assert.equal(accordion.children[1].props.open, true);
|
||||
assert.deepEqual(accordion.children[0].children.map(b => b.id), [textId, firstButtonId]);
|
||||
assert.ok(!JSON.stringify(saved.document).includes('editor-empty'));
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false });
|
||||
const visitor = await plain.newPage(); await visitor.goto(origin);
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
|
||||
assert.equal(await visitor.locator('#qa-open').evaluate(el => el.open), true);
|
||||
assert.equal(await visitor.locator('[data-editor-insert],[data-editor-add-entry],.editor-empty').count(), 0);
|
||||
await visitor.locator('#qa-entry > summary').click();
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), true);
|
||||
assert.equal(await visitor.locator(`#qa-entry #${textId}`).isVisible(), true);
|
||||
await visitor.locator('#qa-entry > summary').press('Enter');
|
||||
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
|
||||
await plain.close();
|
||||
checks.push('save/reload/publication keep children and original open flags; published accordion toggles without JavaScript and contains no editing aids');
|
||||
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1050 }); await select('qa-entry'); await ready();
|
||||
await page.locator('[data-viewport="mobile"]').click(); await ready();
|
||||
await frame.locator('#qa-entry').scrollIntoViewIfNeeded();
|
||||
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze();
|
||||
assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
await page.screenshot({ path: `${report}/editor-${width}.png`, fullPage: true });
|
||||
}
|
||||
checks.push('desktop/mobile editor passes axe and layout checks with editable accordion preview');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Accordion checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -102,14 +102,15 @@ try {
|
||||
assert.equal(await rowLinks().count(), 20);
|
||||
const firstIds = await rowLinks().evaluateAll(links => links.map(link => link.href));
|
||||
await page.getByRole('navigation', { name: 'Ergebnisseiten oben', exact: true }).getByRole('link', { name: 'Weiter' }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 9);
|
||||
assert.equal(await rowLinks().count(), 9);
|
||||
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
|
||||
assert.equal((await rowLinks().evaluateAll(links => links.map(link => link.href))).some(id => firstIds.includes(id)), false);
|
||||
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50');
|
||||
await page.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 29);
|
||||
assert.equal(await rowLinks().count(), 29);
|
||||
await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff');
|
||||
await page.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 0);
|
||||
assert.equal(await rowLinks().count(), 0);
|
||||
await page.goto(origin + '/admin/posts?seite=999'); assert.equal(await rowLinks().count(), 9);
|
||||
await page.goto(origin + '/admin'); assert.equal(await rowLinks().count(), 4);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/color-picker'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
const schema = await (await context.request.get(origin + '/admin/api/design')).json();
|
||||
const deep = schema.colors.find(c => c.key === 'deep');
|
||||
const accent = schema.colors.find(c => c.key === 'accent');
|
||||
assert.ok(deep && accent);
|
||||
data.document.blocks.unshift({ id: 'qa-color', type: 'section', style: { desktop: {
|
||||
backgroundColor: '@deep', color: '#ffffff', gradientFrom: '#11223344', gradientTo: '#55667788',
|
||||
hover: { backgroundColor: '@accent' }, padding: { top: '40px', bottom: '40px' },
|
||||
} }, children: [{ id: 'qa-color-text', type: 'text', props: { text: '<p>Farben direkt im Blick</p>' } }] });
|
||||
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const select = async () => {
|
||||
await page.locator('#structure [data-block-id="qa-color"]').click();
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Hintergrund$/ }) });
|
||||
if (!(await group.getAttribute('open'))) {
|
||||
if (!(await group.evaluate(el => el.open))) await group.locator('summary').click();
|
||||
}
|
||||
};
|
||||
await select();
|
||||
const trigger = (key = 'backgroundColor') => page.locator(`#style-${key}`);
|
||||
const choices = (key = 'backgroundColor') => page.locator(`#style-${key}-choices`);
|
||||
const sample = async (hex, key = 'backgroundColor') => {
|
||||
await page.waitForFunction(({ hex, key }) => document.querySelector(`#style-${key} .color-detail`)?.textContent.includes(hex), { hex, key });
|
||||
assert.ok(await trigger(key).locator('.color-sample-ink').evaluate(el => el.style.backgroundColor !== '' && el.style.backgroundColor !== 'transparent'));
|
||||
};
|
||||
const choose = async (token, key = 'backgroundColor') => { await trigger(key).click(); await choices(key).locator(`[data-color="${token}"]`).click(); };
|
||||
const frameColor = async hex => {
|
||||
const rgb = `rgb(${[1,3,5].map(i => parseInt(hex.slice(i,i+2),16)).join(', ')})`;
|
||||
await page.waitForFunction(rgb => {
|
||||
const doc = document.querySelector('#preview').contentDocument, block = doc?.getElementById('qa-color');
|
||||
return block && doc.defaultView.getComputedStyle(block).backgroundColor === rgb;
|
||||
}, rgb);
|
||||
};
|
||||
await sample(deep.value); assert.match(await trigger().innerText(), new RegExp(deep.name));
|
||||
await trigger().focus(); await page.keyboard.press('Enter');
|
||||
assert.equal(await trigger().getAttribute('aria-expanded'), 'true');
|
||||
await page.keyboard.press('Tab'); assert.equal(await choices().locator('[data-color]').first().evaluate(el => el === document.activeElement), true);
|
||||
await page.keyboard.press('Escape'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true);
|
||||
await choose('@accent'); await sample(accent.value); await frameColor(accent.value);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await page.locator('#redo').click(); await sample(accent.value);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
checks.push('palette name and real token color, keyboard open/tab/escape, selection, canvas, undo and redo');
|
||||
|
||||
await page.locator('[data-viewport="tablet"]').click(); await sample(deep.value);
|
||||
assert.match(await trigger().innerText(), /Geerbt/);
|
||||
await sample(accent.value, 'backgroundColor-hover');
|
||||
await choose('@accent'); await page.locator('[data-viewport="mobile"]').click(); await sample(accent.value);
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await page.getByRole('button', { name: 'Hintergrundfarbe zurücksetzen', exact: true }).click(); await sample(deep.value);
|
||||
await page.locator('[data-viewport="desktop"]').click();
|
||||
checks.push('desktop/tablet/mobile inheritance including hover colors and reset');
|
||||
|
||||
await trigger().click();
|
||||
await choices().locator('.color-native').evaluate(el => {
|
||||
window.colorPickerNode = el;
|
||||
el.focus();
|
||||
for (const value of ['#112233', '#223344', '#334455']) { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
});
|
||||
assert.equal(await choices().locator('.color-native').evaluate(el => el === window.colorPickerNode), true);
|
||||
await frameColor('#334455');
|
||||
await choices().locator('.color-native').dispatchEvent('change');
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger().click();
|
||||
await choices().locator('.color-native').evaluate(el => { el.value = '#445566'; el.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await sample('#445566'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true);
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger().click();
|
||||
const hex = page.locator('#style-backgroundColor-hex');
|
||||
await hex.fill('ungueltig'); await hex.press('Enter'); assert.equal(await hex.getAttribute('aria-invalid'), 'true');
|
||||
await sample(deep.value);
|
||||
await hex.fill('#abc'); await hex.press('Enter'); await sample('#aabbcc'); await frameColor('#aabbcc');
|
||||
await page.locator('#undo').click(); await sample(deep.value);
|
||||
await trigger('gradientFrom').click();
|
||||
await choices('gradientFrom').locator('.color-native').evaluate(el => { el.value = '#224466'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await sample('#22446644', 'gradientFrom');
|
||||
await page.locator('#undo').click(); await sample('#11223344', 'gradientFrom');
|
||||
checks.push('native live color changes retain DOM and form one undo step; invalid input refused; short hex expanded; gradient alpha preserved');
|
||||
|
||||
await choose('@accent');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await select(); await sample(accent.value);
|
||||
const saved = await (await context.request.get(api)).json();
|
||||
assert.equal(saved.document.blocks[0].style.desktop.backgroundColor, '@accent');
|
||||
assert.equal(saved.document.blocks[0].style.desktop.gradientFrom, '#11223344');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const visitor = await context.newPage(); await visitor.goto(origin);
|
||||
assert.equal(await visitor.locator('#qa-color').evaluate(el => getComputedStyle(el).backgroundColor), `rgb(${[1,3,5].map(i => parseInt(accent.value.slice(i,i+2),16)).join(', ')})`);
|
||||
await visitor.close();
|
||||
checks.push('save, reload and publication preserve palette references and alpha values');
|
||||
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1050 });
|
||||
if (await trigger().getAttribute('aria-expanded') === 'false') await trigger().click();
|
||||
await choices().scrollIntoViewIfNeeded();
|
||||
await trigger().evaluate(el => {
|
||||
const panel = el.closest('.right-panel');
|
||||
panel.scrollTop += el.getBoundingClientRect().top - panel.getBoundingClientRect().top - 16;
|
||||
});
|
||||
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze();
|
||||
assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
assert.equal(await choices().evaluate(el => el.scrollWidth <= el.clientWidth), true);
|
||||
await page.locator('.right-panel').screenshot({ path: `${report}/palette-${width}.png` });
|
||||
}
|
||||
checks.push('open palette and editor pass axe and fit desktop/mobile without horizontal overflow');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Color picker checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -487,7 +487,8 @@ try {
|
||||
await frameValue('#headline', 'fontSize', '72px');
|
||||
|
||||
// Colours: a palette reference resolves to the theme variable, a hex value is taken as it is.
|
||||
await page.locator('#style-color').locator('xpath=following-sibling::select').selectOption('@link');
|
||||
await page.locator('#style-color').click();
|
||||
await page.locator('#style-color-choices [data-color="@link"]').click();
|
||||
await frameValue('#headline', 'color', 'rgb(164, 81, 15)');
|
||||
const contrast = page.locator('.contrast-note');
|
||||
await contrast.waitFor();
|
||||
@@ -572,9 +573,10 @@ try {
|
||||
// The background group is collapsed by default, and the readable field next to the swatch is
|
||||
// what a person types into.
|
||||
await openGroup('Hintergrund');
|
||||
const hoverColor = page.locator('#style-backgroundColor-hover').locator('xpath=following-sibling::input[contains(@class,"color-text")]');
|
||||
await page.locator('#style-backgroundColor-hover').click();
|
||||
const hoverColor = page.locator('#style-backgroundColor-hover-hex');
|
||||
await hoverColor.fill('#112233');
|
||||
await hoverColor.blur();
|
||||
await hoverColor.press('Enter');
|
||||
// The preview carries its stylesheet as template text, applied by the editor as a constructed
|
||||
// stylesheet; the hover rule has to arrive through that path, not as a style element.
|
||||
await page.waitForFunction(() => {
|
||||
@@ -857,7 +859,7 @@ try {
|
||||
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
|
||||
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
|
||||
assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.');
|
||||
assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.');
|
||||
assert.equal(await visitorPage.locator('.legal-bar').count(), 0, 'Footer links are placed by the author (since 1.8.1).');
|
||||
await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false });
|
||||
// And the way back: the consent page can refuse again, which blocks the embed once more.
|
||||
await visitorPage.goto(origin + '/einwilligung');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { mediaProps, upgradeDocument } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/image-alignment'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
|
||||
// Geometry of the visible image as well as its figure: centering an empty, full-width
|
||||
// figure alone must not pass while the logo inside it stays at the left edge.
|
||||
const geometry = figure => {
|
||||
const img = figure.querySelector('img');
|
||||
const parent = figure.parentElement, style = getComputedStyle(parent);
|
||||
const p = parent.getBoundingClientRect(), f = figure.getBoundingClientRect(), i = img.getBoundingClientRect();
|
||||
const left = p.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft);
|
||||
const width = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
const top = p.top + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop);
|
||||
const height = parent.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
||||
return { left: f.left - left, right: left + width - f.right, width: f.width, available: width,
|
||||
center: i.left + i.width / 2 - left - width / 2, innerCenter: i.left + i.width / 2 - f.left - f.width / 2,
|
||||
verticalCenter: f.top + f.height / 2 - top - height / 2, imageWidth: i.width, imageHeight: i.height,
|
||||
align: getComputedStyle(figure).alignSelf };
|
||||
};
|
||||
const closeTo = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 2, `${message}: ${actual} vs ${expected}`);
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
upgradeDocument(data.document, 20);
|
||||
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||
const chosen = media.find(item => /teamviewer/i.test(item.title)); assert.ok(chosen);
|
||||
const original = mediaProps(chosen); original.alt ||= chosen.title;
|
||||
const small = { ...original, width: 180, height: Math.round(180 * original.height / original.width) };
|
||||
const image = (id, style, props = original) => ({ id, type: 'image', props: structuredClone(props), style });
|
||||
const centered = { desktop: { width: '40%', alignSelf: 'center' } };
|
||||
const container = (id, style, child) => ({ id, type: 'column', style, children: [child] });
|
||||
const section = { id: 'qa-alignment-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px', padding: { left: '20px', right: '20px' } } }, children: [
|
||||
container('qa-flow', { desktop: { padding: { left: '18px', right: '18px' } } }, image('qa-center', { desktop: { width: '50%' } })),
|
||||
image('qa-natural', { desktop: { alignSelf: 'center' } }, small),
|
||||
image('qa-linked', { desktop: { width: '60%', alignSelf: 'center', margin: { left: '11px', right: '7px' } }, tablet: { alignSelf: 'flex-end' }, mobile: { alignSelf: 'auto' } }, { ...small, click: 'link', href: '/kontakt', newTab: true }),
|
||||
container('qa-grid', { desktop: { display: 'grid', gridColumns: '1', height: '160px' } }, image('qa-grid-image', centered)),
|
||||
container('qa-flex-column', { desktop: { display: 'flex', flexDirection: 'column', height: '160px' } }, image('qa-column-image', centered)),
|
||||
container('qa-flex-row', { desktop: { display: 'flex', height: '160px' } }, image('qa-row-image', centered)),
|
||||
container('qa-responsive-parent', { desktop: { display: 'flex', height: '160px' }, tablet: { flexDirection: 'column' }, mobile: { display: 'block' } }, image('qa-responsive-image', centered)),
|
||||
image('qa-zoom', { desktop: { maxWidth: '300px', alignSelf: 'center' } }, { ...small, click: 'vergroessern' }),
|
||||
] };
|
||||
data.document.blocks.unshift(section);
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200, await created.text());
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const select = async () => {
|
||||
await page.locator('#structure [data-block-id="qa-center"]').click(); await page.locator('[data-panel="design"]').click();
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Layout$/ }) });
|
||||
if (await group.getAttribute('open') === null) await group.locator('summary').click();
|
||||
};
|
||||
const preview = () => page.frameLocator('#preview');
|
||||
const aligned = async value => {
|
||||
await page.waitForFunction(value => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, figure = doc?.getElementById('qa-center');
|
||||
return figure && doc.defaultView.getComputedStyle(figure).alignSelf === value;
|
||||
}, value);
|
||||
return preview().locator('#qa-center').evaluate(geometry);
|
||||
};
|
||||
await select(); closeTo((await aligned('auto')).left, 0, 'Initial left edge');
|
||||
await page.locator('#style-alignSelf').selectOption('center');
|
||||
closeTo((await aligned('center')).center, 0, 'Center in normal column');
|
||||
await page.locator('#undo').click(); closeTo((await aligned('auto')).left, 0, 'Undo centering');
|
||||
await page.locator('#redo').click(); closeTo((await aligned('center')).center, 0, 'Redo centering');
|
||||
await page.locator('#style-alignSelf').focus(); await page.locator('#style-alignSelf').press('ArrowDown');
|
||||
closeTo((await aligned('flex-end')).right, 0, 'Keyboard end alignment');
|
||||
await page.locator('#style-alignSelf').selectOption('flex-start'); closeTo((await aligned('flex-start')).left, 0, 'Start alignment');
|
||||
await page.locator('#style-alignSelf').selectOption('center'); await aligned('center');
|
||||
checks.push('actual Design control: center, start, end, keyboard, undo and redo in a normal padded column');
|
||||
|
||||
const checkLayout = async (root, device) => {
|
||||
for (const id of ['qa-center', 'qa-natural', 'qa-grid-image', 'qa-column-image', 'qa-zoom']) {
|
||||
closeTo((await root.locator(`#${id}`).evaluate(geometry)).center, 0, `${device} ${id} centered`);
|
||||
}
|
||||
const normal = await root.locator('#qa-center').evaluate(geometry);
|
||||
closeTo(normal.width / normal.available * 100, 50, 'Percentage width preserved');
|
||||
const natural = await root.locator('#qa-natural').evaluate(geometry);
|
||||
closeTo(natural.imageWidth, 180, 'Natural logo is not stretched');
|
||||
const row = await root.locator('#qa-row-image').evaluate(geometry);
|
||||
closeTo(row.left, 0, 'Flex row keeps its main-axis position'); closeTo(row.verticalCenter, 0, 'Flex row retains vertical centering');
|
||||
const changing = await root.locator('#qa-responsive-image').evaluate(geometry);
|
||||
closeTo(device === 'desktop' ? changing.left : changing.center, 0, 'Responsive parent layout');
|
||||
const linked = await root.locator('#qa-linked').evaluate(geometry);
|
||||
if (device === 'desktop') closeTo(linked.center, 0, 'Linked image centered');
|
||||
if (device === 'tablet') { closeTo(linked.right, 0, 'Tablet figure at end'); const img = await root.locator('#qa-linked img').boundingBox(); const fig = await root.locator('#qa-linked').boundingBox(); closeTo(img.x + img.width, fig.x + fig.width, 'Small linked image at end'); }
|
||||
if (device === 'mobile') { closeTo(linked.left, 11, 'Auto restores authored margin'); closeTo(linked.innerCenter, -(linked.width - linked.imageWidth) / 2, 'Auto clears inherited internal alignment'); }
|
||||
assert.equal(await root.locator('#qa-linked a').getAttribute('target'), '_blank');
|
||||
closeTo(normal.imageWidth / normal.imageHeight, original.width / original.height, 'Aspect ratio preserved');
|
||||
};
|
||||
for (const device of ['desktop', 'tablet', 'mobile']) {
|
||||
await page.locator(`[data-viewport="${device}"]`).click(); await aligned('center');
|
||||
await checkLayout(preview(), device);
|
||||
}
|
||||
await page.locator('[data-viewport="desktop"]').click(); await aligned('center');
|
||||
await preview().locator('#qa-flow').screenshot({ path: `${report}/centered-column.png` });
|
||||
await page.locator('#style-alignSelf').scrollIntoViewIfNeeded(); await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true });
|
||||
checks.push('preview: natural, percentage and maximum widths; links/zoom; grid and flex axes; responsive parent and auto reset');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await page.reload(); await select(); closeTo((await aligned('center')).center, 0, 'Reload preserves alignment');
|
||||
const stored = await (await context.request.get(api)).json();
|
||||
const block = stored.document.blocks[0].children[0].children[0];
|
||||
assert.deepEqual(block.props, original); assert.deepEqual(block.style, { desktop: { width: '50%', alignSelf: 'center' } });
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
for (const [width, device] of [[1600, 'desktop'], [900, 'tablet'], [390, 'mobile']]) {
|
||||
await publicPage.setViewportSize({ width, height: 1000 }); await publicPage.goto(origin);
|
||||
await checkLayout(publicPage, device);
|
||||
await publicPage.locator('#qa-alignment-section').screenshot({ path: `${report}/published-${device}.png` });
|
||||
}
|
||||
await plain.close(); checks.push('save/reload, unchanged media metadata, published desktop/tablet/mobile with JavaScript disabled');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Image alignment checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,124 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/image-size'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||
const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800);
|
||||
assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title;
|
||||
data.document.blocks.unshift({ id: 'qa-image-size-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px' } }, children: [{ id: 'qa-image-size', type: 'image', props: original }] });
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const select = async () => { await page.locator('#structure [data-block-id="qa-image-size"]').click(); await page.locator('[data-panel="content"]').click(); };
|
||||
await select();
|
||||
const exact = () => page.getByLabel('Bildbreite (%)', { exact: true });
|
||||
const slider = () => page.getByRole('slider', { name: 'Bildbreite Schieberegler' });
|
||||
const width = async percent => page.waitForFunction(percent => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument;
|
||||
const figure = doc?.getElementById('qa-image-size'); if (!figure) return false;
|
||||
const parent = figure.parentElement; const style = doc.defaultView.getComputedStyle(parent);
|
||||
const available = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
return Math.abs(figure.getBoundingClientRect().width / available * 100 - percent) < .1;
|
||||
}, percent);
|
||||
const saved = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); };
|
||||
const changeDevice = async device => page.locator(`[data-viewport="${device}"]`).click();
|
||||
assert.equal(await exact().inputValue(), '');
|
||||
await exact().fill('50'); await width(50);
|
||||
assert.equal(await exact().evaluate(input => input === document.activeElement), true);
|
||||
await exact().blur();
|
||||
await slider().focus(); await slider().press('ArrowRight'); await width(50.1);
|
||||
await page.locator('#undo').click(); await width(50); await page.locator('#redo').click(); await width(50.1);
|
||||
const box = await slider().boundingBox();
|
||||
await page.mouse.move(box.x + box.width * .2, box.y + box.height / 2); await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * .6, box.y + box.height / 2, { steps: 8 }); await page.mouse.up();
|
||||
const dragged = Number(await exact().inputValue()); assert.ok(dragged > 50.1); await width(dragged);
|
||||
await page.locator('#undo').click(); await width(50.1);
|
||||
await page.locator('#redo').click(); await width(dragged);
|
||||
await exact().fill('0'); assert.equal(await exact().getAttribute('aria-invalid'), 'true'); await width(dragged);
|
||||
await exact().fill('50'); await width(50); await exact().blur();
|
||||
checks.push('number, keyboard and drag update preview; one undo per gesture; invalid values do not change the document');
|
||||
|
||||
await changeDevice('tablet'); assert.equal(await exact().inputValue(), '50');
|
||||
assert.match(await page.locator('#image-width-state').innerText(), /Von Desktop übernommen/);
|
||||
await exact().fill('75'); await width(75); await exact().blur();
|
||||
await changeDevice('mobile'); assert.equal(await exact().inputValue(), '75');
|
||||
await exact().fill('30'); await width(30); await exact().blur();
|
||||
await page.locator('#image-width-reset').click(); await width(75);
|
||||
assert.match(await page.locator('#image-width-state').innerText(), /Von Tablet übernommen/);
|
||||
await page.locator('#undo').click(); await width(30);
|
||||
await changeDevice('desktop'); assert.equal(await exact().inputValue(), '50'); await width(50);
|
||||
await saved(); await page.reload(); await select(); assert.equal(await exact().inputValue(), '50'); await width(50);
|
||||
const stored = await (await context.request.get(api)).json();
|
||||
const image = stored.document.blocks[0].children[0];
|
||||
assert.deepEqual(image.style, { desktop: { width: '50%' }, tablet: { width: '75%' }, mobile: { width: '30%' } });
|
||||
assert.deepEqual(image.props, original, 'original dimensions, media variants, caption and alt text are unchanged');
|
||||
checks.push('device inheritance, reset, undo, save/reload and unchanged image metadata');
|
||||
|
||||
await page.locator('[data-panel="design"]').click();
|
||||
const size = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Größe$/ }) });
|
||||
if (!(await size.getAttribute('open'))) await size.locator('summary').click();
|
||||
assert.equal(await page.locator('#style-width').inputValue(), '50');
|
||||
await page.locator('#style-width').fill('60'); await page.locator('#style-width').blur();
|
||||
await page.locator('[data-panel="content"]').click(); assert.equal(await exact().inputValue(), '60'); await width(60);
|
||||
await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true });
|
||||
for (const viewport of [1600, 390]) {
|
||||
await page.setViewportSize({ width: viewport, height: 1050 });
|
||||
await exact().scrollIntoViewIfNeeded();
|
||||
const results = await new AxeBuilder({ page }).include('.image-width-control').analyze();
|
||||
assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.locator('.image-width-control').evaluate(el => el.scrollWidth <= el.clientWidth), true);
|
||||
await page.locator('.image-width-control').screenshot({ path: `${report}/control-${viewport}.png` });
|
||||
}
|
||||
await page.setViewportSize({ width: 1600, height: 1050 }); await saved();
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
|
||||
for (const [viewport, percent] of [[1600, 60], [900, 75], [390, 30]]) {
|
||||
await publicPage.setViewportSize({ width: viewport, height: 900 }); await publicPage.goto(origin);
|
||||
const geometry = await publicPage.locator('#qa-image-size').evaluate(async figure => {
|
||||
const img = figure.querySelector('img'); await img.decode();
|
||||
const parent = figure.parentElement, style = getComputedStyle(parent);
|
||||
return { figure: figure.getBoundingClientRect().width, available: parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), width: img.getBoundingClientRect().width, height: img.getBoundingClientRect().height };
|
||||
});
|
||||
assert.ok(Math.abs(geometry.figure / geometry.available * 100 - percent) < .1);
|
||||
assert.ok(Math.abs(geometry.width / geometry.height - original.width / original.height) < .02, 'image aspect ratio is preserved');
|
||||
await publicPage.locator('#qa-image-size-section').screenshot({ path: `${report}/published-${viewport}.png` });
|
||||
}
|
||||
await plain.close(); checks.push('shared Design width; desktop/mobile accessible controls; published responsive percentages and aspect ratio without JavaScript');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Image size checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,212 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { mediaProps } from '../public/assets/editor-model.js';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/link-media'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
|
||||
const data = await (await context.request.get(api)).json();
|
||||
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||
const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800);
|
||||
assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title;
|
||||
data.document.blocks.unshift({ id: 'qa-link-section', type: 'section', children: [
|
||||
{ id: 'qa-link-image', type: 'image', props: { ...original, href: '/impressum', click: 'link' } },
|
||||
{ id: 'qa-empty-image', type: 'image', props: { ...original, click: 'keine' } },
|
||||
{ id: 'qa-link-button', type: 'button', props: { text: 'QA Neuer Tab', href: '/impressum' } },
|
||||
{ id: 'qa-link-text', type: 'text', props: { text: '<p>Textlink prüfen</p>' } },
|
||||
{ id: 'qa-link-gallery', type: 'gallery', children: [{ id: 'qa-gallery-image', type: 'image', props: original }] },
|
||||
] });
|
||||
data.document.blocks.unshift({ id: 'qa-background', type: 'section', style: { desktop: { backgroundImage: chosen.id, minHeight: '200px' } }, children: [] });
|
||||
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
|
||||
assert.equal(created.status(), 200);
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
|
||||
const frame = page.frameLocator('#preview');
|
||||
const select = async (id, panel = 'content') => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`[data-panel="${panel}"]`).click(); };
|
||||
const thumbnail = () => page.locator('#properties > .selected-media-preview img');
|
||||
const background = async hasImage => page.waitForFunction(hasImage => {
|
||||
const doc = document.querySelector('#preview')?.contentDocument, block = doc?.getElementById('qa-background');
|
||||
return block && doc.defaultView.getComputedStyle(block).backgroundImage.includes('url(') === hasImage;
|
||||
}, hasImage);
|
||||
const save = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); };
|
||||
await select('qa-empty-image');
|
||||
const clickAction = () => page.getByLabel('Beim Klick', { exact: true });
|
||||
await page.locator('#property-href').waitFor({ state: 'hidden' });
|
||||
const nativeSelect = await clickAction().elementHandle();
|
||||
await clickAction().click(); await page.keyboard.press('End'); await page.keyboard.press('Enter');
|
||||
await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
assert.equal(await nativeSelect.evaluate(el => el.isConnected), true, 'Keep the native dropdown mounted while changing the action');
|
||||
await nativeSelect.dispose();
|
||||
await page.getByLabel('Linkziel', { exact: true }).fill('/kontakt');
|
||||
await clickAction().focus(); await page.keyboard.press('Home');
|
||||
await page.locator('#property-href').waitFor({ state: 'hidden' });
|
||||
await page.keyboard.press('End'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
|
||||
await clickAction().selectOption('keine');
|
||||
await clickAction().evaluate(el => { el.value = 'link'; el.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
|
||||
await page.locator('#undo').click();
|
||||
assert.equal(await clickAction().inputValue(), 'keine'); await page.locator('#property-href').waitFor({ state: 'hidden' });
|
||||
await page.locator('#redo').click();
|
||||
assert.equal(await clickAction().inputValue(), 'link'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
await clickAction().selectOption('vergroessern');
|
||||
await page.locator('#property-href').waitFor({ state: 'hidden' }); await page.locator('#property-newTab').waitFor({ state: 'hidden' });
|
||||
await frame.locator('#qa-empty-image a.image-zoom').waitFor();
|
||||
await clickAction().selectOption('link'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
await page.locator('.image-link-options').screenshot({ path: `${report}/image-link-fields.png` });
|
||||
checks.push('fresh image URL appears after native dropdown and keyboard selection; URL retained; change-only event supported');
|
||||
await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' });
|
||||
assert.ok(await thumbnail().evaluate(img => img.naturalWidth > 0));
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await frame.locator('#qa-link-image a[target="_blank"][rel="noopener"]').waitFor();
|
||||
await page.getByLabel('Beim Klick', { exact: true }).selectOption('keine');
|
||||
await frame.locator('#qa-link-image a').waitFor({ state: 'detached' });
|
||||
await page.getByLabel('Beim Klick', { exact: true }).focus();
|
||||
await page.getByLabel('Beim Klick', { exact: true }).selectOption('link');
|
||||
assert.equal(await page.getByLabel('Beim Klick', { exact: true }).evaluate(el => el === document.activeElement), true);
|
||||
await page.getByLabel('Linkziel', { exact: true }).waitFor();
|
||||
await page.getByLabel('Bildadresse', { exact: true }).fill('/nicht-vorhanden.png');
|
||||
await page.getByLabel('Bildadresse', { exact: true }).blur();
|
||||
await page.locator('#properties > .selected-media-preview').getByText(/nicht verfügbar/).waitFor();
|
||||
await page.locator('#undo').click(); await thumbnail().waitFor({ state: 'visible' });
|
||||
await page.getByRole('button', { name: 'Bild entfernen', exact: true }).click();
|
||||
await frame.locator('#qa-link-image').waitFor({ state: 'detached' });
|
||||
assert.equal((await context.request.get(origin + '/admin/api/media/' + chosen.id)).status(), 200);
|
||||
await page.locator('#undo').click(); await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' });
|
||||
await page.locator('.right-panel').screenshot({ path: `${report}/image-inspector.png` });
|
||||
await select('qa-link-gallery'); await page.locator('.selected-media-list img').waitFor({ state: 'visible' });
|
||||
await page.locator('.selected-media-list').getByRole('button', { name: /entfernen$/ }).click();
|
||||
await frame.locator('#qa-gallery-image').waitFor({ state: 'detached' });
|
||||
await page.locator('#undo').click(); await frame.locator('#qa-gallery-image').waitFor();
|
||||
checks.push('image and gallery thumbnails, missing-image feedback, remove/undo without deleting media; image link/no-action toggle');
|
||||
|
||||
await select('qa-background', 'design');
|
||||
const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Hintergrund$/ }) });
|
||||
if (!(await group.getAttribute('open'))) await group.locator('summary').click();
|
||||
await group.locator('.selected-media-preview img').waitFor({ state: 'visible' });
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await group.locator('.selected-media-preview img').waitFor({ state: 'visible' }); await background(true);
|
||||
await group.getByRole('button', { name: 'Bild entfernen', exact: true }).click(); await background(false);
|
||||
assert.equal(await page.locator('#style-backgroundImage').evaluate(el => el === document.activeElement), true);
|
||||
await page.locator('[data-viewport="mobile"]').click(); await background(false);
|
||||
await page.locator('[data-viewport="desktop"]').click(); await background(true);
|
||||
await page.locator('[data-viewport="tablet"]').click();
|
||||
await page.getByRole('button', { name: 'Hintergrundbild zurücksetzen', exact: true }).click(); await background(true);
|
||||
await page.locator('#undo').click(); await background(false);
|
||||
await page.locator('[data-viewport="desktop"]').click();
|
||||
checks.push('inherited background thumbnail, remove on tablet/mobile, preserve desktop, reset and undo');
|
||||
|
||||
await select('qa-link-button'); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await frame.locator('#qa-link-button[target="_blank"]').waitFor();
|
||||
await select('qa-link-text');
|
||||
const surface = page.locator('.rt-surface');
|
||||
await surface.evaluate(el => { const range = document.createRange(); range.selectNodeContents(el.querySelector('p')); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.focus(); });
|
||||
await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click();
|
||||
await page.getByLabel('Linkziel', { exact: true }).fill('/impressum');
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await frame.locator('#qa-link-text a[target="_blank"][rel="noopener"]').waitFor();
|
||||
const editExisting = async () => {
|
||||
await surface.locator('a').evaluate(el => { const range = document.createRange(); range.selectNodeContents(el); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.closest('[contenteditable]').focus(); });
|
||||
await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click();
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/impressum');
|
||||
};
|
||||
await editExisting(); assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await page.getByLabel('In neuem Tab öffnen', { exact: true }).uncheck(); await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await frame.locator('#qa-link-text a:not([target])').waitFor();
|
||||
await editExisting(); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
|
||||
await page.locator('.rt-link').screenshot({ path: `${report}/text-link.png` });
|
||||
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze(); assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
|
||||
await save(); await page.reload(); await select('qa-link-image');
|
||||
assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await thumbnail().waitFor({ state: 'visible' });
|
||||
await select('qa-empty-image');
|
||||
assert.equal(await clickAction().inputValue(), 'link');
|
||||
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
checks.push('button and rich-text target creation/edit/uncheck, safe attributes, save/reload and publication');
|
||||
|
||||
await page.goto(origin + '/admin/menus');
|
||||
await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click();
|
||||
await page.locator('#link-label').fill('QA Menülink'); await page.locator('#link-url').fill('/impressum');
|
||||
await page.locator('#link-new-tab').check(); await page.locator('#menu-add-url').click();
|
||||
const entry = () => page.locator('#menu-items > li').last();
|
||||
await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await entry().getByLabel('In neuem Tab öffnen', { exact: true }).uncheck();
|
||||
await page.locator('#menu-undo').click();
|
||||
await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await page.getByRole('button', { name: 'Menü speichern', exact: true }).click();
|
||||
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
|
||||
await page.reload(); await entry().locator('summary').click();
|
||||
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
|
||||
await entry().screenshot({ path: `${report}/menu-link.png` });
|
||||
checks.push('menu creation, checkbox undo, save and reload');
|
||||
|
||||
const publicPage = await context.newPage(); await publicPage.goto(origin);
|
||||
for (const selector of ['#qa-link-image a', '#qa-link-button', '#qa-link-text a']) {
|
||||
assert.equal(await publicPage.locator(selector).getAttribute('target'), '_blank');
|
||||
assert.equal(await publicPage.locator(selector).getAttribute('rel'), 'noopener');
|
||||
}
|
||||
assert.equal(await publicPage.getByRole('link', { name: 'QA Menülink', exact: true }).getAttribute('target'), '_blank');
|
||||
assert.equal(await publicPage.locator('#qa-empty-image a').getAttribute('href'), '/kontakt');
|
||||
const popupPromise = publicPage.waitForEvent('popup'); await publicPage.locator('#qa-link-button').click();
|
||||
const popup = await popupPromise; await popup.waitForLoadState();
|
||||
assert.equal(new URL(popup.url()).pathname, '/impressum'); assert.equal(await popup.evaluate(() => window.opener), null); await popup.close();
|
||||
assert.equal(new URL(publicPage.url()).pathname, '/');
|
||||
const publicAudit = await new AxeBuilder({ page: publicPage }).analyze();
|
||||
assert.deepEqual(publicAudit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
await publicPage.close();
|
||||
const noScriptContext = await browser.newContext({ javaScriptEnabled: false });
|
||||
const noScript = await noScriptContext.newPage();
|
||||
for (const [width, hasImage] of [[1600, true], [900, false], [390, false]]) {
|
||||
await noScript.setViewportSize({ width, height: 1000 }); await noScript.goto(origin);
|
||||
assert.equal(await noScript.locator('#qa-background').evaluate(el => getComputedStyle(el).backgroundImage.includes('url(')), hasImage);
|
||||
assert.equal(await noScript.locator('#qa-link-image a').getAttribute('target'), '_blank');
|
||||
}
|
||||
await noScriptContext.close();
|
||||
await page.goto(`${origin}/admin/pages/${fixture.homeId}`); await select('qa-link-image');
|
||||
for (const viewport of [1600, 390]) {
|
||||
await page.setViewportSize({ width: viewport, height: 1050 }); await thumbnail().scrollIntoViewIfNeeded();
|
||||
const result = await new AxeBuilder({ page }).exclude('#preview').analyze();
|
||||
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await page.locator('.selected-media-preview').evaluate(el => el.scrollWidth <= el.clientWidth), true);
|
||||
await page.locator('#properties').screenshot({ path: `${report}/image-properties-${viewport}.png` });
|
||||
}
|
||||
checks.push('public links really open a separate tab without opener; public page and desktop/mobile image controls pass axe; background removal persists without JavaScript at 1600/900/390 px');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Link and media checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,162 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: environment });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...environment, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/live-search'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const search = () => page.locator('[data-live-search] input[type="search"]');
|
||||
const rows = () => page.locator('.rows article h3 a');
|
||||
const settled = () => page.waitForFunction(() => /^\d+ Treffer/.test(document.querySelector('#search-status')?.textContent) && !document.querySelector('#list-results').hasAttribute('aria-busy'));
|
||||
const type = async value => { await search().fill(value); await settled(); };
|
||||
const count = async expected => { await settled(); assert.equal(await rows().count(), expected); };
|
||||
const mark = async () => page.evaluate(() => { window.searchDocumentMarker = 'same-document'; });
|
||||
const sameDocument = async () => assert.equal(await page.evaluate(() => window.searchDocumentMarker), 'same-document');
|
||||
const next = () => page.locator('.pager').first().getByRole('link', { name: 'Weiter' });
|
||||
|
||||
await page.goto(origin + '/admin/posts'); await mark();
|
||||
assert.equal(await rows().count(), 20);
|
||||
await next().click(); await count(9); await sameDocument();
|
||||
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
|
||||
await page.goBack(); await count(20);
|
||||
await page.goForward(); await count(9);
|
||||
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50'); await count(29);
|
||||
assert.equal(new URL(page.url()).searchParams.has('seite'), false);
|
||||
await type('nichtvorhandener-qa-begriff'); await count(0); await sameDocument();
|
||||
assert.equal(await search().evaluate(input => input === document.activeElement), true);
|
||||
assert.equal(await page.locator('.create-form input[name="_list_query"]').inputValue(), 'nichtvorhandener-qa-begriff');
|
||||
await type(''); await count(29);
|
||||
await page.locator('[data-search-reset]').click(); await count(20);
|
||||
assert.equal(await page.getByLabel('Pro Seite', { exact: true }).inputValue(), '20');
|
||||
await page.getByLabel('Status', { exact: true }).selectOption('trash'); await count(0);
|
||||
await page.goBack(); await count(20);
|
||||
assert.equal(await page.getByLabel('Status', { exact: true }).inputValue(), 'all');
|
||||
checks.push('posts: typing, focus, filter, page size, pagination, reset, back/forward and form context without navigation');
|
||||
|
||||
// Let an old server response arrive after the latest query has already completed.
|
||||
let delayed;
|
||||
const oldRequest = new Promise(resolve => { delayed = resolve; });
|
||||
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'old-delayed-search', async route => {
|
||||
const response = await route.fetch(); delayed();
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
});
|
||||
await search().fill('old-delayed-search'); await oldRequest;
|
||||
await type(''); await count(20); await page.waitForTimeout(1000); await count(20);
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
let requests = 0;
|
||||
const countRequests = request => { if (request.headers()['x-requested-with'] === 'XMLHttpRequest') requests++; };
|
||||
page.on('request', countRequests);
|
||||
await search().pressSequentially('fast-query', { delay: 25 }); await count(0);
|
||||
assert.equal(requests, 1, 'rapid typing is debounced');
|
||||
page.off('request', countRequests);
|
||||
await type(''); await count(20);
|
||||
await search().dispatchEvent('compositionstart'); await search().fill('ime-query');
|
||||
await page.waitForTimeout(400); assert.equal(await rows().count(), 20);
|
||||
await search().dispatchEvent('compositionend'); await count(0);
|
||||
await type(''); await count(20);
|
||||
checks.push('stale responses ignored; rapid input debounced; IME composition waits for completed text');
|
||||
|
||||
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'error-query', route => route.fulfill({ status: 500, body: 'Unavailable' }));
|
||||
await search().fill('error-query'); await page.locator('#search-error').waitFor({ state: 'visible' });
|
||||
assert.equal(await rows().count(), 20); assert.equal(await search().inputValue(), 'error-query');
|
||||
await page.unrouteAll({ behavior: 'wait' }); await page.getByRole('button', { name: 'Erneut suchen' }).click(); await count(0);
|
||||
assert.equal(await page.locator('#search-error').isVisible(), false);
|
||||
await type(''); await count(20);
|
||||
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'expired-session', route => route.fulfill({ status: 401, body: '' }));
|
||||
await search().fill('expired-session'); await page.getByRole('alert').filter({ hasText: 'erneut anmelden' }).waitFor();
|
||||
assert.equal(await rows().count(), 20); await page.unrouteAll({ behavior: 'wait' });
|
||||
await type(''); await count(20);
|
||||
checks.push('server error and expired session preserve results and input; retry succeeds');
|
||||
|
||||
await page.goto(origin + '/admin'); await mark();
|
||||
await type('Impressum'); await count(1); await sameDocument();
|
||||
assert.match(await rows().first().innerText(), /Impressum/);
|
||||
await page.screenshot({ path: `${report}/pages-search.png`, fullPage: true });
|
||||
await page.reload(); assert.equal(await search().inputValue(), 'Impressum'); assert.equal(await rows().count(), 1);
|
||||
await type(''); await count(4);
|
||||
const entry = page.locator('.rows article').filter({ hasText: 'Impressum' }).first();
|
||||
await entry.locator('summary').first().click();
|
||||
await entry.getByRole('combobox', { name: 'Übergeordnete Seite (optional)' }).fill('Datenschutz');
|
||||
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().waitFor();
|
||||
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().click();
|
||||
assert.ok(await entry.locator('.picker-value').inputValue());
|
||||
let pending;
|
||||
const pendingRequest = new Promise(resolve => { pending = resolve; });
|
||||
await page.route(url => url.pathname === '/admin' && url.searchParams.get('q') === 'late-edit-search', async route => {
|
||||
const response = await route.fetch(); pending();
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
});
|
||||
await search().fill('late-edit-search'); await pendingRequest;
|
||||
await entry.getByLabel('Titel für Suchergebnisse').fill('Ungespeicherte Bearbeitung');
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(await entry.getByLabel('Titel für Suchergebnisse').inputValue(), 'Ungespeicherte Bearbeitung');
|
||||
assert.match(await page.locator('#search-status').innerText(), /angehalten/);
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
checks.push('pages: live title search, reloadable URLs, reinitialized parent picker after AJAX');
|
||||
|
||||
await page.goto(origin + '/admin/media'); await mark();
|
||||
const library = await (await context.request.get(origin + '/admin/api/media')).json();
|
||||
const target = library.items.filter(item => !item.archived).at(-1); assert.ok(target, 'pilot has media');
|
||||
await type(target.title);
|
||||
assert.ok((await page.locator('.library-body strong').allTextContents()).includes(target.title));
|
||||
assert.equal(await page.locator('form[action$="/media/upload"] input[name="q"]').inputValue(), target.title);
|
||||
await sameDocument();
|
||||
await page.screenshot({ path: `${report}/media-search.png`, fullPage: true });
|
||||
await page.getByLabel('Status', { exact: true }).selectOption('1'); await settled();
|
||||
assert.equal(new URL(page.url()).searchParams.get('archiv'), '1');
|
||||
await type('nichtvorhandenes-bild'); assert.equal(await page.locator('.library-tile').count(), 0);
|
||||
await type(''); assert.ok(await page.locator('.library-tile').count());
|
||||
const older = page.locator('.pager a[rel="next"]');
|
||||
if (await older.count()) { await older.click(); await settled(); assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); await sameDocument(); }
|
||||
checks.push('media: global title search, archive filter, empty/reset states, pagination, upload context');
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const route of ['/admin', '/admin/posts', '/admin/media']) {
|
||||
await page.goto(origin + route); await type('nichtvorhandener-begriff');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`);
|
||||
const audit = await new AxeBuilder({ page }).analyze();
|
||||
assert.deepEqual(audit.violations.map(v => ({ id: v.id, targets: v.nodes.map(n => n.target) })), [], `${route} ${width}`);
|
||||
if (width === 390) await page.screenshot({ path: `${report}/${route.split('/').pop()}-mobile.png`, fullPage: true });
|
||||
}
|
||||
}
|
||||
const plain = await browser.newContext({ javaScriptEnabled: false, storageState: await context.storageState() });
|
||||
const fallback = await plain.newPage(); await fallback.goto(origin + '/admin');
|
||||
await fallback.getByLabel('Seiten durchsuchen').fill('Impressum');
|
||||
await fallback.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
||||
assert.equal(await fallback.locator('.rows article h3 a').count(), 1);
|
||||
await plain.close(); checks.push('desktop/mobile axe and overflow checks; HTML fallback without JavaScript');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Live search browser checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -0,0 +1,137 @@
|
||||
import { chromium } from 'playwright';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
|
||||
const php = process.env.PHP_BINARY || 'php';
|
||||
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
|
||||
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
|
||||
const report = 'reports/not-found'; await mkdir(report, { recursive: true });
|
||||
let browser, page;
|
||||
const checks = [], errors = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
|
||||
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(origin + '/admin/login');
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||
const visitor = await context.newPage();
|
||||
visitor.on('pageerror', error => errors.push(error.message));
|
||||
await visitor.goto(origin);
|
||||
const shell = await visitor.evaluate(() => ({
|
||||
header: document.querySelector('.site-header').textContent.replace(/\s+/g, ' ').trim(),
|
||||
footer: document.querySelector('.site-footer').textContent.replace(/\s+/g, ' ').trim(),
|
||||
font: getComputedStyle(document.body).fontFamily,
|
||||
}));
|
||||
const checkError = async (target, heading = 'Seite nicht gefunden', targetPage = visitor) => {
|
||||
const response = await targetPage.goto(origin + target);
|
||||
assert.equal(response.status(), 404);
|
||||
assert.ok(response.headers()['x-robots-tag'].includes('noindex'));
|
||||
assert.ok(response.headers()['content-security-policy'].includes("'sha256-"));
|
||||
assert.equal(await targetPage.locator('h1').innerText(), heading);
|
||||
assert.equal((await targetPage.locator('.site-header').textContent()).replace(/\s+/g, ' ').trim(), shell.header);
|
||||
assert.equal((await targetPage.locator('.site-footer').textContent()).replace(/\s+/g, ' ').trim(), shell.footer);
|
||||
assert.equal(await targetPage.evaluate(() => getComputedStyle(document.body).fontFamily), shell.font);
|
||||
assert.equal(await targetPage.locator('link[rel="canonical"]').count(), 0);
|
||||
assert.equal(await targetPage.locator('meta[name="robots"]').getAttribute('content'), 'noindex, follow');
|
||||
};
|
||||
const audit = async targetPage => {
|
||||
const result = await new AxeBuilder({ page: targetPage }).analyze();
|
||||
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||
assert.equal(await targetPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
};
|
||||
await checkError('/eine-unbekannte-seite');
|
||||
await audit(visitor); await visitor.screenshot({ path: `${report}/default-desktop.png`, fullPage: true });
|
||||
await checkError('/Alte/Seite.html'); await checkError('/eins/zwei/drei/vier/fuenf');
|
||||
await visitor.setViewportSize({ width: 390, height: 900 });
|
||||
await checkError('/__404-vorschau__'); await audit(visitor);
|
||||
await visitor.screenshot({ path: `${report}/default-mobile.png`, fullPage: true });
|
||||
checks.push('default 404, unusual/deep URLs, correct status/noindex, standard header/footer/fonts, desktop/mobile axe and layout');
|
||||
|
||||
await page.goto(origin + '/admin/settings#not-found');
|
||||
await page.getByRole('button', { name: '404-Vorlage anlegen', exact: true }).click();
|
||||
await page.waitForURL(/\/admin\/pages\/[a-f0-9]{32}$/);
|
||||
const id = new URL(page.url()).pathname.split('/').pop();
|
||||
const selectHeading = async () => {
|
||||
await page.locator('#structure [data-block-id="error-heading"]').click();
|
||||
await page.locator('[data-panel="content"]').click();
|
||||
};
|
||||
await selectHeading(); await page.getByLabel('Text', { exact: true }).fill('Hier bist du falsch abgebogen');
|
||||
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad');
|
||||
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
|
||||
await checkError('/404', 'Hier bist du falsch abgebogen');
|
||||
await audit(visitor); await visitor.screenshot({ path: `${report}/custom-mobile.png`, fullPage: true });
|
||||
await visitor.setViewportSize({ width: 1600, height: 1050 });
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen'); await audit(visitor);
|
||||
await visitor.screenshot({ path: `${report}/custom-desktop.png`, fullPage: true });
|
||||
const sitemap = await (await context.request.get(origin + '/sitemap.xml')).text();
|
||||
assert.ok(!sitemap.includes(origin + '/404'));
|
||||
checks.push('create an assigned draft, edit in normal editor, keep draft private, publish immediately, assigned own URL remains 404 and out of sitemap');
|
||||
|
||||
await page.goto(origin + '/admin/settings#not-found');
|
||||
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
|
||||
const clear = () => page.getByRole('button', { name: '404-Seite suchen: Auswahl entfernen', exact: true });
|
||||
const saveAssignment = async () => {
|
||||
await page.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin/settings#not-found');
|
||||
};
|
||||
await clear().click(); await saveAssignment();
|
||||
await page.getByText('Aktiv: Standard-404 im Website-Design.', { exact: true }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad');
|
||||
const assignExisting = async () => {
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).fill('Seite nicht gefunden');
|
||||
await page.locator('#not-found-picker-results [role="option"]').first().waitFor();
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).press('ArrowDown');
|
||||
await page.getByLabel('404-Seite suchen', { exact: true }).press('Enter');
|
||||
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
|
||||
await saveAssignment();
|
||||
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
|
||||
};
|
||||
await assignExisting(); await page.reload();
|
||||
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
|
||||
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
|
||||
const stale = await context.newPage(); await stale.goto(origin + '/admin/settings#not-found');
|
||||
await clear().click(); await saveAssignment();
|
||||
await stale.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
|
||||
await stale.getByText('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.', { exact: true }).waitFor();
|
||||
await checkError('/ein-fehlender-pfad'); await stale.close();
|
||||
await assignExisting();
|
||||
for (const width of [1600, 390]) {
|
||||
await page.setViewportSize({ width, height: 1100 }); await page.locator('#not-found').scrollIntoViewIfNeeded();
|
||||
await audit(page); await page.locator('#not-found').screenshot({ path: `${report}/settings-${width}.png` });
|
||||
}
|
||||
checks.push('live search without submitting, keyboard assignment, reload persistence, reset to default and protection against stale settings forms');
|
||||
|
||||
const withoutScripts = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 900 } });
|
||||
const plain = await withoutScripts.newPage();
|
||||
await checkError('/Alte/Seite.html', 'Hier bist du falsch abgebogen', plain);
|
||||
await plain.getByRole('link', { name: 'Zur Startseite', exact: true }).click();
|
||||
await plain.waitForURL(origin + '/'); await withoutScripts.close();
|
||||
const normal = await visitor.goto(origin); assert.equal(normal.status(), 200);
|
||||
assert.equal(await visitor.locator('link[rel="canonical"]').getAttribute('href'), origin + '/');
|
||||
await audit(visitor);
|
||||
checks.push('public 404 and working home link without JavaScript; normal home remains 200 with canonical and passes axe');
|
||||
assert.deepEqual(errors, []);
|
||||
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||
console.log('Custom 404 checks passed:', checks.length, 'groups.');
|
||||
} catch (error) {
|
||||
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||
throw error;
|
||||
} finally { await browser?.close(); server.kill(); }
|
||||
@@ -42,7 +42,7 @@ final class DocumentValidator
|
||||
public function validate(array $document, bool $requireHeading = true): void
|
||||
{
|
||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], true), 'Unsupported document schema.');
|
||||
$this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], true), 'Unsupported document schema.');
|
||||
$this->text($document['title'] ?? null, 160);
|
||||
$this->text($document['description'] ?? null, 320);
|
||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||
@@ -124,6 +124,15 @@ final class DocumentValidator
|
||||
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
|
||||
$this->text($props['text'] ?? null, $limit);
|
||||
}
|
||||
if (!$plugin && array_key_exists('newTab', $props)) {
|
||||
$this->require($schemaVersion >= 20 && in_array($type, ['button', 'image', 'iconcard', 'cta', 'icon'], true), 'Neue Link-Tabs brauchen Schema v20 und einen verlinkbaren Block.');
|
||||
$this->require(is_bool($props['newTab']), 'In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
foreach (($block['style'] ?? []) as $values) {
|
||||
if ('none' === ($values['backgroundImage'] ?? null)) {
|
||||
$this->require($schemaVersion >= 20, 'Ein entferntes Hintergrundbild braucht Schema v20.');
|
||||
}
|
||||
}
|
||||
if ('heading' === $type) {
|
||||
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
||||
$headings += 1 === $props['level'] ? 1 : 0;
|
||||
|
||||
@@ -39,17 +39,22 @@ final class MediaLibrary
|
||||
*
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, pages: int, perPage: int}
|
||||
*/
|
||||
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE): array
|
||||
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE, string $query = ''): array
|
||||
{
|
||||
$where = $withArchived ? '' : ' WHERE archived = 0';
|
||||
$total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'.$where);
|
||||
$qb = $this->db->createQueryBuilder()->from('media');
|
||||
if (!$withArchived) {
|
||||
$qb->andWhere('archived = 0');
|
||||
}
|
||||
if ('' !== trim($query)) {
|
||||
$qb->andWhere('(LOWER(title) LIKE :query ESCAPE \'!\' OR LOWER(alt) LIKE :query ESCAPE \'!\')')
|
||||
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
|
||||
}
|
||||
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
|
||||
$perPage = max(1, min(96, $perPage));
|
||||
$pages = max(1, (int) ceil($total / $perPage));
|
||||
$page = max(1, min($pages, $page));
|
||||
$items = $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM media'.$where.' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?',
|
||||
[$perPage, ($page - 1) * $perPage],
|
||||
);
|
||||
$items = $qb->select('*')->orderBy('created_at', 'DESC')->addOrderBy('id', 'DESC')
|
||||
->setMaxResults($perPage)->setFirstResult(($page - 1) * $perPage)->executeQuery()->fetchAllAssociative();
|
||||
|
||||
return [
|
||||
'items' => array_map($this->present(...), $items),
|
||||
|
||||
@@ -132,7 +132,7 @@ final class MenuRepository
|
||||
if ('' === trim($label)) {
|
||||
continue;
|
||||
}
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children];
|
||||
$links[] = ['label' => $label, 'href' => $href, 'current' => $current, 'children' => $children, ...(($item['newTab'] ?? false) ? ['newTab' => true] : [])];
|
||||
}
|
||||
|
||||
return $links;
|
||||
@@ -153,6 +153,9 @@ final class MenuRepository
|
||||
if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) {
|
||||
throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.');
|
||||
}
|
||||
if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) {
|
||||
throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.');
|
||||
}
|
||||
if ('page' === ($item['type'] ?? null)) {
|
||||
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) {
|
||||
throw new InvalidMenu('Für einen Seiteneintrag fehlt die Seite.');
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/** A normal block page supplies the content; an error response always keeps the site shell. */
|
||||
final class NotFoundPage
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private PageRepository $pages, private Connection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function selected(): ?array
|
||||
{
|
||||
$id = $this->settings->load()['notFoundPageId'];
|
||||
|
||||
return '' === $id ? null : ($this->pages->choices(id: $id)[0] ?? null);
|
||||
}
|
||||
|
||||
public function page(int $status = 404): array
|
||||
{
|
||||
$id = $this->settings->load()['notFoundPageId'];
|
||||
$published = 404 === $status && '' !== $id ? $this->pages->findPublishedById($id) : null;
|
||||
|
||||
return [
|
||||
'id' => 'error-'.$status, 'path' => '', 'document' => self::document($status),
|
||||
...($published ?? []),
|
||||
'locale' => 'de', 'type' => 'page', 'robots' => 'noindex', 'seo_title' => '',
|
||||
'header_area' => null, 'footer_area' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function document(int $status = 404): array
|
||||
{
|
||||
$title = 410 === $status ? 'Diese Seite ist nicht mehr verfügbar.' : 'Seite nicht gefunden';
|
||||
|
||||
return [
|
||||
'schemaVersion' => 10, 'title' => $title, 'description' => 'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.',
|
||||
'blocks' => [[
|
||||
'id' => 'error-section', 'type' => 'section',
|
||||
'style' => ['desktop' => ['textAlign' => 'center', 'minHeight' => '45vh', 'padding' => ['top' => '80px', 'bottom' => '80px']]],
|
||||
'children' => [
|
||||
['id' => 'error-code', 'type' => 'text', 'props' => ['text' => (string) $status]],
|
||||
['id' => 'error-heading', 'type' => 'heading', 'props' => ['text' => $title, 'level' => 1]],
|
||||
['id' => 'error-message', 'type' => 'text', 'props' => ['text' => 'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.']],
|
||||
['id' => 'error-home', 'type' => 'button', 'props' => ['text' => 'Zur Startseite', 'href' => '/']],
|
||||
],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
public function create(string $expectedId): string
|
||||
{
|
||||
return $this->db->transactional(function () use ($expectedId): string {
|
||||
if (null !== $this->selected()) {
|
||||
throw new InvalidPage('Es ist bereits eine 404-Seite zugewiesen. Du kannst sie direkt bearbeiten.');
|
||||
}
|
||||
$slug = '404';
|
||||
for ($suffix = 2; false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', 'de', $slug]); ++$suffix) {
|
||||
$slug = '404-'.$suffix;
|
||||
}
|
||||
$id = $this->pages->createPage($slug, null, 'page', self::document());
|
||||
$this->db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $id]);
|
||||
$this->settings->assignNotFoundPage($id, $expectedId);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ final class RichText
|
||||
'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
|
||||
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
|
||||
'ul' => [], 'ol' => [], 'li' => [],
|
||||
'a' => ['href'],
|
||||
'a' => ['href', 'target'],
|
||||
];
|
||||
/** Tags whose content is dropped along with them; unwrapping them would leak code as text. */
|
||||
private const DROP_ENTIRELY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
|
||||
@@ -121,7 +121,10 @@ final class RichText
|
||||
continue;
|
||||
}
|
||||
$child->setAttribute('href', $href);
|
||||
if (str_starts_with($href, 'https://')) {
|
||||
if ('_blank' !== $child->getAttribute('target')) {
|
||||
$child->removeAttribute('target');
|
||||
}
|
||||
if ('_blank' === $child->getAttribute('target') || str_starts_with($href, 'https://')) {
|
||||
$child->setAttribute('rel', 'noopener');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Content;
|
||||
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||
|
||||
@@ -53,6 +54,7 @@ final class SiteSettings
|
||||
'robotsExtra' => '',
|
||||
// Which stored page answers at the site root. Everything else derives from this.
|
||||
'homePath' => 'home',
|
||||
'notFoundPageId' => '',
|
||||
// The consent question. Off by default, because a site that loads nothing from third
|
||||
// parties has nothing to ask about, and a banner without a reason is only in the way.
|
||||
'consentBanner' => false,
|
||||
@@ -96,6 +98,8 @@ final class SiteSettings
|
||||
public function save(array $input): void
|
||||
{
|
||||
$values = self::DEFAULTS;
|
||||
// Assigned separately, so saving company data cannot clear the error page.
|
||||
$values['notFoundPageId'] = $this->load()['notFoundPageId'];
|
||||
foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) {
|
||||
$values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max);
|
||||
}
|
||||
@@ -194,9 +198,13 @@ final class SiteSettings
|
||||
throw new InvalidPage('Die Startseite braucht eine gültige Adresse.');
|
||||
}
|
||||
// A root that points nowhere would answer every visit with 404, so it must exist.
|
||||
if (false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home])) {
|
||||
$homeId = $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home]);
|
||||
if (false === $homeId) {
|
||||
throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home));
|
||||
}
|
||||
if ($homeId === $values['notFoundPageId']) {
|
||||
throw new InvalidPage('Die 404-Seite kann nicht gleichzeitig die Startseite sein.');
|
||||
}
|
||||
$values['homePath'] = $home;
|
||||
|
||||
$values['consentBanner'] = (bool) ($input['consentBanner'] ?? false);
|
||||
@@ -237,6 +245,41 @@ final class SiteSettings
|
||||
]);
|
||||
}
|
||||
|
||||
/** Change only the 404 assignment, preserving settings saved in other forms. */
|
||||
public function assignNotFoundPage(string $id, string $expectedId): void
|
||||
{
|
||||
$this->db->transactional(function () use ($id, $expectedId): void {
|
||||
$lock = $this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\SQLitePlatform ? '' : ' FOR UPDATE';
|
||||
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?'.$lock, ['default']);
|
||||
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
|
||||
$stored = is_array($stored) ? $stored : [];
|
||||
$current = $stored['notFoundPageId'] ?? '';
|
||||
if ($current !== $expectedId) {
|
||||
throw new StaleWrite('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.');
|
||||
}
|
||||
if ('' !== $id) {
|
||||
$page = $this->db->fetchAssociative('SELECT path, type FROM page WHERE id = ? AND website_id = ? AND deleted_at IS NULL', [$id, 'default']);
|
||||
if (false === $page || 'page' !== $page['type']) {
|
||||
throw new InvalidPage('Bitte wähle eine vorhandene Seite als 404-Seite.');
|
||||
}
|
||||
if ($page['path'] === ($stored['homePath'] ?? self::DEFAULTS['homePath'])) {
|
||||
throw new InvalidPage('Die Startseite kann nicht gleichzeitig die 404-Seite sein.');
|
||||
}
|
||||
}
|
||||
if ($current === $id) {
|
||||
return;
|
||||
}
|
||||
$stored['notFoundPageId'] = $id;
|
||||
$changed = $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
|
||||
json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default',
|
||||
]);
|
||||
if (1 !== $changed) {
|
||||
throw new StaleWrite('Die Einstellungen wurden inzwischen geändert. Lade die Seite neu.');
|
||||
}
|
||||
});
|
||||
$this->loaded = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two mandatory pages are missing, by their settings key.
|
||||
*
|
||||
|
||||
@@ -84,7 +84,7 @@ final class StyleCompiler
|
||||
sprintf('"%s" is outside its range.', $key),
|
||||
),
|
||||
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
|
||||
'media' => $this->require(1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
'media' => $this->require('none' === $text || 1 === preg_match('/^[a-f0-9]{32}$/D', $text), sprintf('"%s" is not a media ID.', $key)),
|
||||
default => $this->require(false, 'Unknown style type.'),
|
||||
};
|
||||
}
|
||||
@@ -97,12 +97,15 @@ final class StyleCompiler
|
||||
public function compileDocument(array $document): string
|
||||
{
|
||||
$css = '';
|
||||
$walk = function (array $blocks) use (&$walk, &$css): void {
|
||||
$walk = function (array $blocks, array $parent = []) use (&$walk, &$css): void {
|
||||
foreach ($blocks as $block) {
|
||||
if (is_array($block['style'] ?? null) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', (string) ($block['id'] ?? ''))) {
|
||||
$css .= $this->compileBlock((string) $block['id'], $block['style']);
|
||||
if ('image' === ($block['type'] ?? null)) {
|
||||
$css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent);
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
$walk($block['children'] ?? [], $block);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
@@ -110,6 +113,50 @@ final class StyleCompiler
|
||||
return $css;
|
||||
}
|
||||
|
||||
/**
|
||||
* In a normal section/column, align-self cannot position a figure. Align its box
|
||||
* and the possibly smaller image inside it, including linked images. A horizontal
|
||||
* flex group must keep align-self's vertical axis without consuming its main-axis
|
||||
* space with automatic margins. Resolve both styles at each breakpoint so a parent
|
||||
* layout change or "auto" on a phone cannot leave desktop alignment behind.
|
||||
*/
|
||||
private function imageAlignment(string $id, array $style, array $parent): string
|
||||
{
|
||||
$css = $previous = '';
|
||||
$values = $group = [];
|
||||
// These block types render children in an unstyled inner wrapper.
|
||||
$wrapped = in_array($parent['type'] ?? '', ['section', 'accordionitem', 'slider'], true);
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = array_replace_recursive($values, $style[$device] ?? []);
|
||||
$group = array_replace_recursive($group, $parent['style'][$device] ?? []);
|
||||
if (!isset($values['alignSelf'])) {
|
||||
continue;
|
||||
}
|
||||
$alignment = match ($values['alignSelf']) {
|
||||
'center' => ['auto', 'auto', 'center'],
|
||||
'flex-start' => ['0', 'auto', 'start'],
|
||||
'flex-end' => ['auto', '0', 'end'],
|
||||
default => null,
|
||||
};
|
||||
$horizontalFlex = !$wrapped
|
||||
&& in_array($group['display'] ?? '', ['flex', 'inline-flex'], true)
|
||||
&& in_array($group['flexDirection'] ?? 'row', ['row', 'row-reverse'], true);
|
||||
[$left, $right, $justify] = $horizontalFlex || null === $alignment
|
||||
? [$values['margin']['left'] ?? '0', $values['margin']['right'] ?? '0', 'auto']
|
||||
: $alignment;
|
||||
$rules = sprintf('#%s{margin-left:%s;margin-right:%s;justify-self:%s}#%s img{margin-left:%s;margin-right:%s}',
|
||||
$id, $left, $right, $justify, $id, $alignment[0] ?? '0', $alignment[1] ?? '0');
|
||||
if ($rules !== $previous) {
|
||||
$css .= isset(self::BREAKPOINTS[$device])
|
||||
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rules)
|
||||
: $rules;
|
||||
$previous = $rules;
|
||||
}
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
private function compileBlock(string $id, array $style): string
|
||||
{
|
||||
$css = '';
|
||||
@@ -233,7 +280,7 @@ final class StyleCompiler
|
||||
$this->css(['type' => 'color'], (string) $values['gradientTo']),
|
||||
);
|
||||
}
|
||||
if (isset($values['backgroundImage']) && null !== $this->media) {
|
||||
if (isset($values['backgroundImage']) && 'none' !== $values['backgroundImage'] && null !== $this->media) {
|
||||
$item = $this->media->find((string) $values['backgroundImage']);
|
||||
if (null !== $item && [] !== $item['variants']) {
|
||||
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
||||
@@ -244,7 +291,7 @@ final class StyleCompiler
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $layers ? '' : 'background-image:'.implode(',', $layers);
|
||||
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
|
||||
}
|
||||
|
||||
private function shadow(array $values): string
|
||||
@@ -348,21 +395,19 @@ final class StyleCompiler
|
||||
*/
|
||||
private function scaleBackground(array $style): array
|
||||
{
|
||||
$image = $style['desktop']['backgroundImage'] ?? null;
|
||||
if (null === $image) {
|
||||
return $style;
|
||||
}
|
||||
$inherited = $style['desktop'] ?? [];
|
||||
foreach (['tablet', 'mobile'] as $device) {
|
||||
if (!isset($style[$device]['backgroundImage'])) {
|
||||
$style[$device]['backgroundImage'] = $image;
|
||||
if (isset($inherited['backgroundImage']) || isset($style[$device]['backgroundImage'])) {
|
||||
$style[$device]['backgroundImage'] ??= $inherited['backgroundImage'];
|
||||
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
|
||||
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
|
||||
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
|
||||
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||
$style[$device][$begleiter] = $style['desktop'][$begleiter];
|
||||
if (isset($inherited[$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||
$style[$device][$begleiter] = $inherited[$begleiter];
|
||||
}
|
||||
}
|
||||
}
|
||||
$inherited = array_replace($inherited, $style[$device] ?? []);
|
||||
}
|
||||
|
||||
return $style;
|
||||
|
||||
@@ -54,14 +54,23 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
unset($page);
|
||||
|
||||
return $this->render('admin/pages.html.twig', [
|
||||
$view = [
|
||||
'pages' => 'trash' === $status ? [] : $pages,
|
||||
'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [],
|
||||
'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(),
|
||||
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
|
||||
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
|
||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||
]);
|
||||
];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/content-results.html.twig', $view),
|
||||
'total' => $result['total'],
|
||||
'parameters' => ['q' => $query, 'status' => $status, 'limit' => $result['limit'], 'seite' => $result['page']],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->render('admin/pages.html.twig', $view);
|
||||
}
|
||||
|
||||
#[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])]
|
||||
|
||||
@@ -24,11 +24,18 @@ final class MediaController extends AbstractController
|
||||
// The screen renders the images itself, page by page. The dialog stays what it is: the way
|
||||
// to pick an image while editing, not the way to see what the library holds.
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||
$view = ['library' => $library, 'archived' => $archived, 'query' => $query];
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return $this->json([
|
||||
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||
'total' => $library['total'],
|
||||
'parameters' => ['q' => $query, 'archiv' => $archived ? '1' : '0', 'seite' => $library['page']],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->render('admin/media.html.twig', [
|
||||
'library' => $this->media->page($request->query->getInt('seite', 1), $archived),
|
||||
'archived' => $archived,
|
||||
]);
|
||||
return $this->render('admin/media.html.twig', $view);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
||||
@@ -90,7 +97,8 @@ final class MediaController extends AbstractController
|
||||
$query = array_filter([
|
||||
'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : null,
|
||||
'archiv' => $request->request->getBoolean('archiv') ? '1' : null,
|
||||
]);
|
||||
'q' => mb_substr(trim($request->request->getString('q')), 0, 160),
|
||||
], static fn ($value): bool => null !== $value && '' !== $value);
|
||||
|
||||
return $this->redirectToRoute('admin_media', $query);
|
||||
}
|
||||
@@ -103,6 +111,15 @@ final class MediaController extends AbstractController
|
||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])]
|
||||
public function read(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$item = $this->media->find($id);
|
||||
|
||||
return null === $item ? $this->json(['error' => 'Bild nicht gefunden.'], 404) : $this->json(['item' => $item]);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/api/media/upload', methods: ['POST'])]
|
||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function write(Request $request, ?string $id = null): Response
|
||||
|
||||
@@ -76,6 +76,7 @@ final class MenuController extends AbstractController
|
||||
$rows[] = [
|
||||
'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
|
||||
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
|
||||
'newTab' => $item['newTab'] ?? false,
|
||||
];
|
||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||
}
|
||||
@@ -101,6 +102,7 @@ final class MenuController extends AbstractController
|
||||
'pageId' => (string) ($row['pageId'] ?? ''),
|
||||
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
|
||||
'url' => trim((string) ($row['url'] ?? '')),
|
||||
'newTab' => '1' === (string) ($row['newTab'] ?? ''),
|
||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||
'position' => (int) ($row['position'] ?? 0),
|
||||
];
|
||||
@@ -110,6 +112,9 @@ final class MenuController extends AbstractController
|
||||
$items = [];
|
||||
foreach ($flat as $row) {
|
||||
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
|
||||
if ($row['newTab']) {
|
||||
$item['newTab'] = true;
|
||||
}
|
||||
if ('url' === $row['type']) {
|
||||
$item['url'] = $row['url'];
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
@@ -24,7 +25,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -45,96 +46,106 @@ final class PageController extends AbstractController
|
||||
return $this->redirectToRoute('home', status: 301);
|
||||
}
|
||||
$page = $this->pages->findPublished($path);
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($page);
|
||||
$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);
|
||||
}
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
}
|
||||
if (null === $page) {
|
||||
// An address that moved or was deliberately removed answers before the 404 does.
|
||||
$redirect = $this->redirects->find($path);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
}
|
||||
$response = $this->render('not-found.html.twig', ['areas' => $areas], new Response(status: null !== $redirect ? 410 : 404));
|
||||
} else {
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
'needsMotion' => $needsMotion,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'breadcrumbs' => $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
// Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt
|
||||
// die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar
|
||||
// nichts, und die Pflicht ist mit dem Baustein erfuellt.
|
||||
'hasLegalBlock' => $this->hasType($page['document'], 'legal')
|
||||
|| (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')),
|
||||
]);
|
||||
|
||||
return $this->notFound($request, null !== $redirect ? 410 : 404);
|
||||
}
|
||||
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
|
||||
return $this->renderPage($request, $page, $path);
|
||||
}
|
||||
|
||||
public function notFound(Request $request, int $status = 404): Response
|
||||
{
|
||||
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
|
||||
}
|
||||
|
||||
/** Published pages and error content share theme, areas, block rendering and CSP. */
|
||||
private function renderPage(Request $request, array $page, string $path, int $status = 200): Response
|
||||
{
|
||||
$styles = '';
|
||||
$googleFonts = null;
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($status >= 400 ? null : $page);
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
// Liegen die Schriften auf dem eigenen Server, wird nichts von Google geholt - weder
|
||||
// die Datei noch die IP-Adresse des Besuchers.
|
||||
if ($this->fonts->hasLocal($theme)) {
|
||||
$localFonts = $this->fonts->preload($theme);
|
||||
} else {
|
||||
$googleFonts = $this->theme->googleFontsUrl($theme);
|
||||
}
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()),
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $this->postLists($page['document'], max(1, $request->query->getInt('seite', 1))),
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $needsSlider,
|
||||
'needsMotion' => $needsMotion,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||||
// Own scripts arrive as one file per page, never inline, so the policy can stay
|
||||
// hash based and no page without a script loses its strict setting.
|
||||
'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js',
|
||||
'forms' => $this->formStates($page, $request->query->getString('gesendet'), $request),
|
||||
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $status >= 400 ? [] : $this->structuredData($page),
|
||||
'areas' => $areas,
|
||||
'sections' => $sections,
|
||||
// Rendered before the page content, so the header decides first.
|
||||
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
|
||||
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
|
||||
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
|
||||
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
|
||||
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
|
||||
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
|
||||
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
|
||||
'pageStyles' => $styles,
|
||||
'googleFonts' => $googleFonts,
|
||||
'localFonts' => $localFonts,
|
||||
'styleBundle' => $this->bundle->url([] !== $localFonts),
|
||||
], new Response(status: $status));
|
||||
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $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));
|
||||
$mitTurnstile = $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
}
|
||||
|
||||
if ($status >= 400) {
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||
}
|
||||
|
||||
@@ -441,7 +452,7 @@ final class PageController extends AbstractController
|
||||
if (null !== $slug) {
|
||||
$term = $this->posts->findTerm($kind, $slug);
|
||||
if (null === $term) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
}
|
||||
$page = max(1, $request->query->getInt('seite', 1));
|
||||
@@ -449,7 +460,7 @@ final class PageController extends AbstractController
|
||||
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
|
||||
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
|
||||
if (0 === $result['total']) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
|
||||
@@ -468,10 +479,10 @@ final class PageController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||
public function feed(): Response
|
||||
public function feed(Request $request): Response
|
||||
{
|
||||
if (!$this->posts->anyPublished()) {
|
||||
return $this->shell($this->render('not-found.html.twig', ['navigation' => $this->navigation('')], new Response(status: 404)));
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$result = $this->posts->published(1, null, 20);
|
||||
$response = $this->shell($this->render('feed.xml.twig', [
|
||||
|
||||
@@ -34,8 +34,8 @@ final class SeoController extends AbstractController
|
||||
// Only published pages that are allowed to be indexed. A draft or a noindex page in the
|
||||
// sitemap would ask a search engine to fetch something it must not list.
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL ORDER BY p.path",
|
||||
['default'],
|
||||
"SELECT p.path, p.type, p.published_at, r.created_at FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY p.path",
|
||||
['default', $this->settings->load()['notFoundPageId']],
|
||||
) as $row) {
|
||||
$entries[] = [
|
||||
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
||||
@@ -99,8 +99,8 @@ final class SeoController extends AbstractController
|
||||
}
|
||||
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
|
||||
$rows = $this->db->fetchAllAssociative(
|
||||
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? ORDER BY p.path",
|
||||
['default', $type],
|
||||
"SELECT p.path, p.excerpt, r.document FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE p.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.type = ? AND p.id <> ? ORDER BY p.path",
|
||||
['default', $type, $settings['notFoundPageId']],
|
||||
);
|
||||
if ([] === $rows) {
|
||||
continue;
|
||||
|
||||
@@ -5,11 +5,13 @@ namespace App\Controller;
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -17,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme, private NotFoundPage $notFoundPage)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,6 +37,7 @@ final class SettingsController extends AbstractController
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'settings' => $this->settings->load(),
|
||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||
'choosableHome' => $choosable,
|
||||
'consentGroups' => Consent::GROUPS,
|
||||
@@ -63,6 +66,34 @@ final class SettingsController extends AbstractController
|
||||
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/not-found', name: 'admin_not_found_save', methods: ['POST'])]
|
||||
public function saveNotFound(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$expectedId = $request->request->getString('expectedId');
|
||||
if ('create' === $request->request->getString('action')) {
|
||||
$id = $this->notFoundPage->create($expectedId);
|
||||
$this->addFlash('success', 'Die 404-Vorlage wurde als Entwurf angelegt und zugewiesen. Nach dem Veröffentlichen ersetzt sie die Standard-404.');
|
||||
|
||||
return $this->redirectToRoute('admin_editor', ['id' => $id]);
|
||||
}
|
||||
$id = $request->request->getString('notFoundPageId');
|
||||
$this->settings->assignNotFoundPage($id, $expectedId);
|
||||
$selection = $this->notFoundPage->selected();
|
||||
$message = '' === $id ? 'Die Standard-404 im Website-Design ist wieder aktiv.'
|
||||
: ($selection['published'] ? 'Die 404-Seite wurde zugewiesen.' : 'Die Seite wurde zugewiesen. Bis zu ihrer Veröffentlichung erscheint die Standard-404.');
|
||||
$this->addFlash('success', $message);
|
||||
} catch (InvalidPage|StaleWrite $error) {
|
||||
$this->addFlash('error', $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'not-found']);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||
public function saveRedirect(Request $request): Response
|
||||
{
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Controller\PageController;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/** Also handle human-facing unknown URLs outside the content slug grammar. */
|
||||
final class PublicNotFoundSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private PageController $pages, private string $adminPath)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::EXCEPTION => ['onException', 10]];
|
||||
}
|
||||
|
||||
public function onException(ExceptionEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
if (!$event->isMainRequest() || !$event->getThrowable() instanceof NotFoundHttpException
|
||||
|| $request->attributes->has('_route') || !in_array($request->getMethod(), ['GET', 'HEAD'], true)
|
||||
|| $request->isXmlHttpRequest() || 'html' !== $request->getPreferredFormat('html')) {
|
||||
return;
|
||||
}
|
||||
$path = $request->getPathInfo();
|
||||
if (preg_match('#^/(?:'.preg_quote($this->adminPath, '#').'|media|assets|formular|install)(?:/|$)#', $path)
|
||||
|| preg_match('/\.(?:css|js|mjs|json|xml|txt|png|jpe?g|webp|gif|svg|ico|woff2?|ttf|pdf)$/iD', $path)) {
|
||||
return;
|
||||
}
|
||||
$event->setResponse($this->pages->notFound($request));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ final class ConnectionFactory
|
||||
}
|
||||
$connection = DriverManager::getConnection($params, $configuration);
|
||||
if ('pdo_sqlite' === $params['driver']) {
|
||||
// SQLite's built-in LOWER only handles ASCII: searching for "über" must also find
|
||||
// "Über uns", just as on MySQL. No database rewrite or search-specific schema needed.
|
||||
$connection->getNativeConnection()->sqliteCreateFunction('lower', static fn (?string $value): ?string => null === $value ? null : mb_strtolower($value, 'UTF-8'), 1, \PDO::SQLITE_DETERMINISTIC);
|
||||
$connection->executeStatement('PRAGMA foreign_keys = ON');
|
||||
$connection->executeStatement('PRAGMA busy_timeout = 5000');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||
nicht mehr. #}
|
||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider'] %}
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'color-control', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %}
|
||||
<div class="section-head"><h2>{{ status == 'trash' ? 'Papierkorb' : plural }} <span class="count">{{ result.total }}</span></h2><span class="muted">{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}</span></div>
|
||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
|
||||
<div class="rows">
|
||||
{% for page in pages %}
|
||||
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
||||
an attribute here would simply be dropped and the tree would look flat. #}
|
||||
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
||||
<div class="row-line">
|
||||
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
||||
<div class="row-main">
|
||||
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
||||
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
|
||||
</div>
|
||||
{% if page.published_revision_id is null %}
|
||||
<span class="pill pill-off">Nicht veröffentlicht</span>
|
||||
{% elseif page.published_revision_id == page.draft_revision_id %}
|
||||
<span class="pill pill-live">Veröffentlicht</span>
|
||||
{% else %}
|
||||
<span class="pill pill-draft">Entwurf geändert</span>
|
||||
{% endif %}
|
||||
<div class="row-actions">
|
||||
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
||||
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
|
||||
{# One disclosure per row instead of six popovers next to each other: everything that is
|
||||
not the daily „bearbeiten“ lives here and opens in place. #}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
|
||||
<div class="sheet-body">
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
|
||||
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
|
||||
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
|
||||
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="robots-{{ page.id }}">Suchmaschinen</label>
|
||||
<select id="robots-{{ page.id }}" name="robots">
|
||||
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
|
||||
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
|
||||
</select>
|
||||
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
||||
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
||||
</div>
|
||||
{% if page.type == 'post' %}
|
||||
<div>
|
||||
<label for="date-{{ page.id }}">Veröffentlicht am</label>
|
||||
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="author-{{ page.id }}">Autor</label>
|
||||
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="categories-{{ page.id }}">Kategorien</label>
|
||||
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tags-{{ page.id }}">Schlagwörter</label>
|
||||
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
|
||||
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="media-{{ page.id }}">Beitragsbild</label>
|
||||
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
||||
</div>
|
||||
<div>
|
||||
<label for="excerpt-{{ page.id }}">Auszug</label>
|
||||
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
|
||||
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="primary" type="submit">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<div class="sheet-part">
|
||||
<h4>Adresse</h4>
|
||||
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="slug-{{ page.id }}">Adresssegment</label>
|
||||
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
|
||||
</div>
|
||||
<div>
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'parent-' ~ page.id, name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true, selected: parentChoices[page.parent_id]|default(null)} %}
|
||||
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Adresse ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Kopf- und Fußbereich</h4>
|
||||
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
|
||||
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
|
||||
<div>
|
||||
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
|
||||
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
|
||||
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
|
||||
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
|
||||
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
|
||||
{% endif %}{% endfor %}
|
||||
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="secondary" type="submit">Bereiche speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Veröffentlichung planen</h4>
|
||||
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="field">
|
||||
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
|
||||
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
|
||||
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Zeitpunkt speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Entfernen</h4>
|
||||
<div class="sheet-actions">
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="secondary" type="submit">In den Papierkorb</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
{# The class is what the security test looks for: an editor must not even be
|
||||
offered this. #}
|
||||
<div class="delete-panel">
|
||||
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
{% if status != 'trash' %}<div class="empty"><h3>{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}</h3>{% if result.total == 0 %}<p>{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}</p>{% endif %}</div>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if trash is not empty %}
|
||||
|
||||
<div class="rows">
|
||||
{% for entry in trash %}
|
||||
<article class="row">
|
||||
<span class="row-icon" aria-hidden="true">🗑</span>
|
||||
<div class="row-main">
|
||||
<h3>/{{ entry.path }}</h3>
|
||||
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="row-actions">
|
||||
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<button class="secondary" type="submit">Wiederherstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status == 'trash' and trash is empty %}<div class="empty"><h3>Der Papierkorb ist leer.</h3></div>{% endif %}
|
||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %}
|
||||
@@ -0,0 +1,59 @@
|
||||
<div class="section-head">
|
||||
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
||||
<span class="muted">{{ library.total ? (library.page - 1) * library.perPage + 1 : 0 }}–{{ min(library.page * library.perPage, library.total) }} von {{ library.total }}</span>
|
||||
</div>
|
||||
|
||||
{% if library.items is empty %}
|
||||
<div class="empty">
|
||||
<h3>{{ query ? 'Keine passenden Bilder.' : (archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.') }}</h3>
|
||||
<p>{{ query ? 'Ändere oder leere die Suche.' : (is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.') }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="library-grid">
|
||||
{% for item in library.items %}
|
||||
<li class="library-tile">
|
||||
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
||||
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
||||
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
||||
</a>
|
||||
<div class="library-body">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
||||
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
||||
<code>{{ item.id }}</code>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="title-{{ item.id }}">Bildtitel</label>
|
||||
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<label for="alt-{{ item.id }}">Alternativtext</label>
|
||||
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
||||
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
||||
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if library.pages > 1 %}
|
||||
<nav class="pager" aria-label="Seiten der Mediathek">
|
||||
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null, q: query}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||||
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
||||
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null, q: query}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'media' %}
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||
{% block content %}
|
||||
@@ -9,7 +9,7 @@
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
{# A drop area rather than the browser's file widget. The input stays a real file input and
|
||||
stays focusable, so the form works by keyboard and without JavaScript; the script only adds
|
||||
@@ -30,66 +30,12 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-head">
|
||||
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
||||
<span class="muted">
|
||||
{% if archived %}<a href="{{ path('admin_media') }}">Archivierte ausblenden</a>{% else %}<a href="{{ path('admin_media', {archiv: 1}) }}">Archivierte anzeigen</a>{% endif %}
|
||||
{% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if library.items is empty %}
|
||||
<div class="empty">
|
||||
<h3>{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}</h3>
|
||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="library-grid">
|
||||
{% for item in library.items %}
|
||||
<li class="library-tile">
|
||||
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
||||
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
||||
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
||||
</a>
|
||||
<div class="library-body">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
||||
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
||||
<code>{{ item.id }}</code>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="title-{{ item.id }}">Bildtitel</label>
|
||||
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<label for="alt-{{ item.id }}">Alternativtext</label>
|
||||
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
||||
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
||||
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if library.pages > 1 %}
|
||||
<nav class="pager" aria-label="Seiten der Mediathek">
|
||||
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||||
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
||||
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
<form method="get" action="{{ path('admin_media') }}" class="list-toolbar" role="search" data-live-search>
|
||||
<div class="list-search"><label for="media-list-search">Bilder durchsuchen</label><input id="media-list-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel oder Alternativtext …"></div>
|
||||
<div><label for="media-list-archive">Status</label><select id="media-list-archive" name="archiv" data-search-default="0"><option value="0"{{ not archived ? ' selected' : '' }}>Aktive Bilder</option><option value="1"{{ archived ? ' selected' : '' }}>Mit archivierten Bildern</option></select></div>
|
||||
<button class="secondary search-submit" type="submit">Anwenden</button>
|
||||
<a class="text-button" data-search-reset href="{{ path('admin_media') }}"{{ not query and not archived ? ' hidden' : '' }}>Zurücksetzen</a>
|
||||
</form>
|
||||
{% include 'admin/search-feedback.html.twig' %}
|
||||
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
|
||||
{% endblock %}
|
||||
@@ -12,6 +12,7 @@
|
||||
{% else %}
|
||||
<label for="menu-url-{{ n }}">Adresse</label><input id="menu-url-{{ n }}" data-key="url" name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt">
|
||||
{% endif %}
|
||||
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<label for="link-label">Beschriftung</label><input id="link-label" maxlength="80" placeholder="Kontakt">
|
||||
<label for="link-url">Adresse</label><input id="link-url" maxlength="2048" placeholder="https://… oder /kontakt">
|
||||
<p class="field-help">Auch mailto: und tel: sind möglich.</p>
|
||||
<label class="check-label" for="link-new-tab"><input id="link-new-tab" type="checkbox"> In neuem Tab öffnen</label>
|
||||
<button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
|
||||
</div></details>
|
||||
<p class="field-help">Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.</p>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %}
|
||||
{% block title %}{{ plural }}{% endblock %}
|
||||
{% block heading %}{{ plural }}{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
@@ -43,231 +43,13 @@
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search">
|
||||
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
|
||||
<div><label for="page-status">Status</label><select id="page-status" name="status">{% for value, label in {all: 'Alle aktiven', draft: 'Entwürfe', published: 'Veröffentlicht', changed: 'Entwurf geändert', trash: 'Papierkorb'} %}<option value="{{ value }}"{{ status == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label for="page-limit">Pro Seite</label><select id="page-limit" name="limit">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
|
||||
<button class="secondary" type="submit">Anwenden</button>
|
||||
{% if query or status != 'all' %}<a class="text-button" href="{{ path(listRoute) }}">Zurücksetzen</a>{% endif %}
|
||||
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search" data-live-search>
|
||||
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
|
||||
<div><label for="page-status">Status</label><select id="page-status" name="status" data-search-default="all">{% for value, label in {all: 'Alle aktiven', draft: 'Entwürfe', published: 'Veröffentlicht', changed: 'Entwurf geändert', trash: 'Papierkorb'} %}<option value="{{ value }}"{{ status == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||
<div><label for="page-limit">Pro Seite</label><select id="page-limit" name="limit" data-search-default="20">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
|
||||
<button class="secondary search-submit" type="submit">Anwenden</button>
|
||||
<a class="text-button" data-search-reset href="{{ path(listRoute) }}"{{ not query and status == 'all' and result.limit == 20 ? ' hidden' : '' }}>Zurücksetzen</a>
|
||||
</form>
|
||||
<div class="section-head"><h2>{{ status == 'trash' ? 'Papierkorb' : plural }} <span class="count">{{ result.total }}</span></h2><span class="muted">{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}</span></div>
|
||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
|
||||
<div class="rows">
|
||||
{% for page in pages %}
|
||||
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
||||
an attribute here would simply be dropped and the tree would look flat. #}
|
||||
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
||||
<div class="row-line">
|
||||
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
||||
<div class="row-main">
|
||||
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
||||
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
|
||||
</div>
|
||||
{% if page.published_revision_id is null %}
|
||||
<span class="pill pill-off">Nicht veröffentlicht</span>
|
||||
{% elseif page.published_revision_id == page.draft_revision_id %}
|
||||
<span class="pill pill-live">Veröffentlicht</span>
|
||||
{% else %}
|
||||
<span class="pill pill-draft">Entwurf geändert</span>
|
||||
{% endif %}
|
||||
<div class="row-actions">
|
||||
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
||||
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
|
||||
{# One disclosure per row instead of six popovers next to each other: everything that is
|
||||
not the daily „bearbeiten“ lives here and opens in place. #}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
|
||||
<div class="sheet-body">
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
|
||||
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
|
||||
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
|
||||
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="robots-{{ page.id }}">Suchmaschinen</label>
|
||||
<select id="robots-{{ page.id }}" name="robots">
|
||||
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
|
||||
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
|
||||
</select>
|
||||
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
||||
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
||||
</div>
|
||||
{% if page.type == 'post' %}
|
||||
<div>
|
||||
<label for="date-{{ page.id }}">Veröffentlicht am</label>
|
||||
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="author-{{ page.id }}">Autor</label>
|
||||
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="categories-{{ page.id }}">Kategorien</label>
|
||||
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tags-{{ page.id }}">Schlagwörter</label>
|
||||
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
|
||||
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="media-{{ page.id }}">Beitragsbild</label>
|
||||
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
||||
</div>
|
||||
<div>
|
||||
<label for="excerpt-{{ page.id }}">Auszug</label>
|
||||
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
|
||||
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="primary" type="submit">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<div class="sheet-part">
|
||||
<h4>Adresse</h4>
|
||||
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="slug-{{ page.id }}">Adresssegment</label>
|
||||
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
|
||||
</div>
|
||||
<div>
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'parent-' ~ page.id, name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true, selected: parentChoices[page.parent_id]|default(null)} %}
|
||||
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Adresse ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Kopf- und Fußbereich</h4>
|
||||
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
|
||||
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="fields">
|
||||
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
|
||||
<div>
|
||||
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
|
||||
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
|
||||
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
|
||||
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
|
||||
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
|
||||
{% endif %}{% endfor %}
|
||||
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button class="secondary" type="submit">Bereiche speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Veröffentlichung planen</h4>
|
||||
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<div class="field">
|
||||
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
|
||||
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
|
||||
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Zeitpunkt speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
|
||||
<div class="sheet-part">
|
||||
<h4>Entfernen</h4>
|
||||
<div class="sheet-actions">
|
||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="secondary" type="submit">In den Papierkorb</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
{# The class is what the security test looks for: an editor must not even be
|
||||
offered this. #}
|
||||
<div class="delete-panel">
|
||||
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
{% if status != 'trash' %}<div class="empty"><h3>{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}</h3>{% if result.total == 0 %}<p>{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}</p>{% endif %}</div>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if trash is not empty %}
|
||||
|
||||
<div class="rows">
|
||||
{% for entry in trash %}
|
||||
<article class="row">
|
||||
<span class="row-icon" aria-hidden="true">🗑</span>
|
||||
<div class="row-main">
|
||||
<h3>/{{ entry.path }}</h3>
|
||||
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<div class="row-actions">
|
||||
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
|
||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||
<button class="secondary" type="submit">Wiederherstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status == 'trash' and trash is empty %}<div class="empty"><h3>Der Papierkorb ist leer.</h3></div>{% endif %}
|
||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %}
|
||||
{% include 'admin/search-feedback.html.twig' %}
|
||||
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/content-results.html.twig' %}</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,2 @@
|
||||
<p id="search-status" class="field-help search-status" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div id="search-error" class="notice error" hidden><p role="alert"></p><button id="search-retry" class="secondary" type="button">Erneut suchen</button></div>
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}Einstellungen{% endblock %}
|
||||
{% block heading %}Unternehmen und Adressen{% endblock %}
|
||||
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
@@ -20,6 +21,36 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="stack-section" id="not-found" aria-labelledby="not-found-title">
|
||||
<div class="section-head"><h2 id="not-found-title">404-Seite</h2><a class="text-button" href="/__404-vorschau__" target="_blank" rel="noopener">404-Vorschau ↗</a></div>
|
||||
<div class="card card-body">
|
||||
<p>Bei unbekannten Adressen erscheint diese Seite mit dem Standard-Header, Standard-Footer und Design deiner Website.</p>
|
||||
{% if notFoundSelection %}
|
||||
<p class="notice">{{ notFoundSelection.published ? 'Aktiv:' : 'Zugewiesener Entwurf:' }} <strong>{{ notFoundSelection.title }}</strong>{% if not notFoundSelection.published %} · Bis zur Veröffentlichung erscheint die Standard-404.{% endif %}</p>
|
||||
<p><a class="secondary" href="{{ path('admin_editor', {id: notFoundSelection.id}) }}">404-Seite bearbeiten</a></p>
|
||||
{% else %}
|
||||
<p class="notice">Aktiv: Standard-404 im Website-Design.</p>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_PUBLISHER') %}
|
||||
<form action="{{ path('admin_not_found_save') }}" method="post" id="not-found-form">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<input type="hidden" name="expectedId" value="{{ settings.notFoundPageId }}">
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'not-found-picker', name: 'notFoundPageId', type: 'page', label: '404-Seite suchen', selected: notFoundSelection} only %}
|
||||
<p class="field-help">Nur der veröffentlichte Stand wird angezeigt. Ohne Auswahl wird die Standard-404 verwendet.</p>
|
||||
<button class="primary" type="submit">404-Zuordnung speichern</button>
|
||||
</form>
|
||||
{% if not notFoundSelection %}
|
||||
<form action="{{ path('admin_not_found_save') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
<input type="hidden" name="expectedId" value="{{ settings.notFoundPageId }}">
|
||||
<input type="hidden" name="action" value="create">
|
||||
<p class="field-help">Noch keine passende Seite? Lege eine fertige Vorlage an und bearbeite sie im Editor.</p>
|
||||
<button class="secondary" type="submit">404-Vorlage anlegen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}<p class="field-help">Die Zuordnung kann von einem Benutzer mit Veröffentlichungsrechten geändert werden.</p>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack-section">
|
||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
||||
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
|
||||
|
||||
@@ -53,17 +53,6 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
||||
{% endif %}
|
||||
{% elseif type == 'button' %}
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||
{% elseif type == 'image' %}
|
||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
||||
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
|
||||
@@ -38,19 +38,20 @@
|
||||
{% set annahme = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px' %}
|
||||
{% set imageSizes = festeBreite ends with 'px' ? festeBreite : (eager or inHeader|default(false) ? annahme : 'auto, ' ~ annahme) %}
|
||||
{% set klick = props.click|default(props.href|default('') ? 'link' : 'keine') %}
|
||||
{% set imageLinked = klick == 'link' and props.href|default('') %}
|
||||
{% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %}
|
||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">
|
||||
{%- if klick == 'vergroessern' -%}
|
||||
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der
|
||||
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
|
||||
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild') }} vergrößern">
|
||||
{%- elseif props.href|default('') -%}
|
||||
<a class="image-link" href="{{ props.href }}">
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{%- elseif imageLinked -%}
|
||||
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{% elseif type == 'iconcard' %}
|
||||
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
||||
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
|
||||
<h3>{% if props.href|default('') %}<a href="{{ props.href }}">{{ props.title }}</a>{% else %}{{ props.title }}{% endif %}</h3>
|
||||
<h3>{% if props.href|default('') %}<a href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.title }}</a>{% else %}{{ props.title }}{% endif %}</h3>
|
||||
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
||||
</div>
|
||||
{% elseif type == 'stat' %}
|
||||
@@ -73,7 +74,7 @@
|
||||
<h2>{{ props.title }}</h2>
|
||||
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
||||
</div>
|
||||
<a class="button" href="{{ props.href }}">{{ props.buttonText }}</a>
|
||||
<a class="button" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.buttonText }}</a>
|
||||
</div>
|
||||
{% elseif type == 'contact' %}
|
||||
<address id="{{ block.id }}" class="block-contact{{ designClasses }}">
|
||||
@@ -160,7 +161,7 @@
|
||||
something carries its label as text for screen readers. #}
|
||||
{% set iconBody %}{% include 'blocks/icon.html.twig' with {name: props.icon} only %}{% if not props.decorative %}<span class="visually-hidden">{{ props.label }}</span>{% endif %}{% endset %}
|
||||
{% if props.href|default('') %}
|
||||
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
||||
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
||||
{% else %}
|
||||
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% if newTab|default(false) %} target="_blank" rel="noopener"{% endif -%}
|
||||
@@ -6,12 +6,12 @@
|
||||
<details class="menu-branch">
|
||||
<summary>{{ link.label }}</summary>
|
||||
<div class="menu-branch-body">
|
||||
<a href="{{ link.href }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
{% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}"{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
<a href="{{ link.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: link.newTab|default(false)} only %}{% if link.current %} aria-current="page"{% endif %}>{{ link.label }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2,17 +2,6 @@
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Seite nicht gefunden · KantSites</title><link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">{% include 'favicon.html.twig' %}</head>
|
||||
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
<body class="{{ consentOpen ? 'consent-open' : '' }}"><main class="shell error-page"><p>404 · KantSites</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main>
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{% if legal is not empty or revocable %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
|
||||
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||
{% endif %}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
{% set seoTitle = page.seo_title|default('') ?: page.document.title %}
|
||||
<title>{{ seoTitle }}</title>
|
||||
<meta name="description" content="{{ page.document.description }}">
|
||||
<link rel="canonical" href="{{ canonical }}">
|
||||
{% if canonical|default(null) %}<link rel="canonical" href="{{ canonical }}">{% endif %}
|
||||
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
|
||||
<meta property="og:title" content="{{ seoTitle }}">
|
||||
<meta property="og:description" content="{{ page.document.description }}">
|
||||
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
|
||||
<meta property="og:url" content="{{ canonical }}">
|
||||
{% if canonical|default(null) %}<meta property="og:url" content="{{ canonical }}">{% endif %}
|
||||
{# In der Vorschau des Editors gibt es den Websitenamen nicht - dort fehlt das Feld einfach. #}
|
||||
{% if page.website_name|default('') %}<meta property="og:site_name" content="{{ page.website_name }}">{% endif %}
|
||||
<meta property="og:locale" content="{{ page.locale == 'de' ? 'de_DE' : page.locale }}">
|
||||
@@ -91,18 +91,6 @@
|
||||
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</footer>
|
||||
{% endif %}
|
||||
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
|
||||
reachable from every page, so they are part of the template and not of the footer area,
|
||||
which someone could delete. One labelled landmark, so they are not loose text either. #}
|
||||
{% set legal = consentGate.legalLinks() %}
|
||||
{% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
|
||||
{# Nur, wenn die Seite die Zeile nicht selbst enthaelt: sonst stuende sie zweimal da. #}
|
||||
{% if (legal is not empty or revocable) and not hasLegalBlock|default(false) %}
|
||||
<nav class="legal-bar" aria-label="Rechtliches">
|
||||
{% for path, label in legal %}{% if not loop.first %} · {% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
|
||||
{% if revocable %}{% if legal is not empty %} · {% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% set oben = siteSettings.load() %}
|
||||
{% if oben.scrollTop|default(false) and not editing|default(false) %}
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #}
|
||||
|
||||
+191
-16
@@ -10,6 +10,7 @@ use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostLayouts;
|
||||
@@ -296,6 +297,115 @@ final class CmsTest extends TestCase
|
||||
self::assertSame(405, $this->request('/', 'POST')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDefault404UsesTheNormalAreasAndThemeForEveryPublicUrlShape(): void
|
||||
{
|
||||
foreach (['/missing', '/Missing/old.html', '/eins/zwei/drei/vier/fuenf', '/__404-vorschau__', '/kategorie/missing'] as $path) {
|
||||
$response = $this->request($path);
|
||||
self::assertSame(404, $response->getStatusCode(), $path);
|
||||
self::assertSame('noindex, follow', $response->headers->get('X-Robots-Tag'));
|
||||
self::assertFalse($response->headers->has('Location'));
|
||||
$html = $response->getContent();
|
||||
self::assertStringContainsString('Seite nicht gefunden', $html);
|
||||
self::assertStringContainsString('class="site-header', $html);
|
||||
self::assertStringContainsString('class="site-footer', $html);
|
||||
self::assertStringContainsString('--k-', $html);
|
||||
self::assertStringNotContainsString('rel="canonical"', $html);
|
||||
self::assertStringNotContainsString('application/ld+json', $html);
|
||||
}
|
||||
$head = $this->request('/Missing/old.html', 'HEAD');
|
||||
self::assertSame(404, $head->getStatusCode());
|
||||
self::assertSame('', $head->getContent());
|
||||
}
|
||||
|
||||
public function testCustom404KeepsDraftPrivateAndSurvivesRenamingAndFallsBackAfterDeletion(): void
|
||||
{
|
||||
$document = NotFoundPage::document();
|
||||
$document['blocks'][0]['children'][1]['props']['text'] = 'Unser individueller Fehlerhinweis';
|
||||
$id = $this->pages->createPage('fehlerseite', null, 'page', $document);
|
||||
$this->site->assignNotFoundPage($id, '');
|
||||
self::assertStringNotContainsString('Unser individueller Fehlerhinweis', $this->request('/unbekannt')->getContent());
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
$this->pages->saveAreas($id, 'none', 'none', 2);
|
||||
foreach (['/unbekannt', '/fehlerseite', '/alte.Datei.html'] as $url) {
|
||||
$response = $this->request($url);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
self::assertStringContainsString('Unser individueller Fehlerhinweis', $response->getContent());
|
||||
self::assertStringContainsString('class="site-header', $response->getContent());
|
||||
self::assertStringContainsString('class="site-footer', $response->getContent());
|
||||
}
|
||||
$this->site->save(['homePath' => 'home', 'llmsTxt' => true]);
|
||||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||||
self::assertStringNotContainsString('/fehlerseite', $this->request('/sitemap.xml')->getContent());
|
||||
self::assertStringNotContainsString('/fehlerseite', $this->request('/llms.txt')->getContent());
|
||||
$this->pages->movePage($id, 'fehler-umbenannt', null, 3, $this->redirects);
|
||||
self::assertStringContainsString('Unser individueller Fehlerhinweis', $this->request('/weiterhin-falsch')->getContent());
|
||||
$document['blocks'][0]['children'][1]['props']['text'] = 'Noch geheimer Entwurf';
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$revision = $this->pages->saveDraft($id, $document, $page['version']);
|
||||
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $revision, $page['version']);
|
||||
self::assertStringContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->trashPage($id, $page['version']);
|
||||
self::assertStringContainsString('Seite nicht gefunden', $this->request('/weiterhin-falsch')->getContent());
|
||||
self::assertStringNotContainsString('Noch geheimer Entwurf', $this->request('/weiterhin-falsch')->getContent());
|
||||
}
|
||||
|
||||
public function test404CreationPreservesExistingPagesAndRejectsStaleAssignments(): void
|
||||
{
|
||||
$existing = $this->pages->createPage('404', null, 'page', DemoContent::starter('Vorhandene Seite'));
|
||||
$service = new NotFoundPage($this->site, $this->pages, $this->db);
|
||||
$id = $service->create('');
|
||||
$created = $this->pages->findForEditor($id);
|
||||
self::assertSame('404-2', $created['path']);
|
||||
self::assertNull($created['published_revision_id']);
|
||||
self::assertSame('noindex', $created['robots']);
|
||||
self::assertSame('Vorhandene Seite', $this->pages->findForEditor($existing)['document']['title']);
|
||||
$count = $this->db->fetchOne('SELECT COUNT(*) FROM page');
|
||||
try {
|
||||
$service->create('');
|
||||
self::fail('Repeated creation must not create another page.');
|
||||
} catch (InvalidPage) {
|
||||
self::assertSame($count, $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
try {
|
||||
$this->site->assignNotFoundPage('', '');
|
||||
self::fail('A stale settings form must not reset a newer assignment.');
|
||||
} catch (StaleWrite) {
|
||||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||||
}
|
||||
foreach (['page-1', 'missing', $this->pages->createPage('beitrag', null, 'post', DemoContent::starter('Beitrag', 'post'))] as $invalid) {
|
||||
try {
|
||||
$this->site->assignNotFoundPage($invalid, $id);
|
||||
self::fail('The home page, posts and missing pages cannot serve as the 404 page.');
|
||||
} catch (InvalidPage) {
|
||||
self::assertSame($id, $this->site->load()['notFoundPageId']);
|
||||
}
|
||||
}
|
||||
$this->site->assignNotFoundPage('', $id);
|
||||
self::assertSame('', $this->site->load()['notFoundPageId']);
|
||||
}
|
||||
|
||||
public function testCustom404DoesNotReplaceRedirectsGoneResourcesOrAdminEndpoints(): void
|
||||
{
|
||||
$document = NotFoundPage::document();
|
||||
$document['title'] = 'CUSTOM404MARKER';
|
||||
$id = $this->pages->createPage('fehler', null, 'page', $document);
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
$this->site->assignNotFoundPage($id, '');
|
||||
$this->redirects->save('umgezogen', 'home', 301);
|
||||
$this->redirects->save('entfernt', '', 410);
|
||||
self::assertSame(301, $this->request('/umgezogen')->getStatusCode());
|
||||
self::assertSame(410, $this->request('/entfernt')->getStatusCode());
|
||||
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request('/entfernt')->getContent());
|
||||
foreach (['/assets/missing.js', '/media/missing', '/admin/api/missing', '/missing.json', '/llms.txt'] as $url) {
|
||||
self::assertStringNotContainsString('CUSTOM404MARKER', $this->request($url)->getContent(), $url);
|
||||
}
|
||||
}
|
||||
|
||||
public function testHomeAliasRedirectsToCanonical(): void
|
||||
{
|
||||
$response = $this->request('/home');
|
||||
@@ -1008,12 +1118,9 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testTheLegalLineCanLiveInsideTheFooterInsteadOfUnderneathIt(): void
|
||||
public function testLegalLinksAppearOnlyWhereAnAuthorPlacesThem(): void
|
||||
{
|
||||
// Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser
|
||||
// lösen." Die eingebaute Leiste klebte als heller Streifen unter einem dunklen Fuß. Als
|
||||
// Baustein steht sie dort, wo sie hingehört, und erbt die Farben ihres Bereichs.
|
||||
// Zwei verschiedene Seiten: die Zeile fuehrt zu Datenschutz und Impressum.
|
||||
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
|
||||
$datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz'));
|
||||
$seite = $this->pages->findForEditor($datenschutz);
|
||||
$this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']);
|
||||
@@ -1022,9 +1129,10 @@ final class CmsTest extends TestCase
|
||||
$document['schemaVersion'] = 19;
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
// Ohne Baustein: die eingebaute Leiste am Seitenende.
|
||||
// Ohne selbst platzierte Links bleibt die Seite ohne Rechtszeile.
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<nav class="legal-bar"', $html);
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $html);
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">', $html);
|
||||
|
||||
// Mit Baustein: nur noch dieser, und zwar an seiner Stelle im Dokument.
|
||||
$document['blocks'][] = ['id' => 'rechtszeile', 'type' => 'legal', 'props' => []];
|
||||
@@ -1032,7 +1140,7 @@ final class CmsTest extends TestCase
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
||||
$mit = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<nav id="rechtszeile" class="block-legal"', $mit);
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $mit, 'Zweimal dieselbe Zeile ist schlechter als einmal.');
|
||||
self::assertStringNotContainsString('<nav class="legal-bar"', $mit);
|
||||
self::assertStringContainsString('Datenschutz', $mit);
|
||||
}
|
||||
|
||||
@@ -2569,6 +2677,69 @@ final class CmsTest extends TestCase
|
||||
$this->partials->delete($this->partials->findByKind('header')['id']);
|
||||
}
|
||||
|
||||
public function testNewTabLinksPersistForBlocksAndNestedMenus(): void
|
||||
{
|
||||
$document = DemoContent::starter('Links');
|
||||
$document['schemaVersion'] = 20;
|
||||
$types = [
|
||||
'button' => ['text' => 'Öffnen'],
|
||||
'image' => ['src' => '/bild.png', 'width' => 120, 'height' => 80, 'alt' => 'Bild', 'decorative' => false, 'click' => 'link'],
|
||||
'iconcard' => ['title' => 'Karte', 'text' => '', 'icon' => 'star'],
|
||||
'cta' => ['title' => 'Aufruf', 'text' => '', 'buttonText' => 'Öffnen'],
|
||||
'icon' => ['icon' => 'star', 'size' => 24, 'decorative' => false, 'label' => 'Öffnen'],
|
||||
];
|
||||
foreach ($types as $type => $props) {
|
||||
$document['blocks'][] = ['id' => 'new-'.$type, 'type' => $type, 'props' => [...$props, 'href' => '/kontakt', 'newTab' => true]];
|
||||
}
|
||||
$revision = $this->pages->saveDraft('page-1', $document, 0);
|
||||
$this->pages->publish('page-1', $revision, 1);
|
||||
$this->menus->save('header', 'Links', [['label' => 'Eltern', 'type' => 'url', 'url' => '/', 'newTab' => true, 'children' => [['label' => 'Kind', 'type' => 'url', 'url' => '/kontakt', 'newTab' => true]]]], 0);
|
||||
$crawler = new \Symfony\Component\DomCrawler\Crawler($this->request('/')->getContent());
|
||||
foreach (['#new-button', '#new-image a', '#new-iconcard a', '#new-cta a', '#new-icon'] as $selector) {
|
||||
self::assertSame('_blank', $crawler->filter($selector)->attr('target'));
|
||||
self::assertSame('noopener', $crawler->filter($selector)->attr('rel'));
|
||||
}
|
||||
self::assertCount(2, $crawler->filter('.site-header .menu-list a[target="_blank"][rel="noopener"]'));
|
||||
$document['blocks'][array_key_last($document['blocks'])]['props']['newTab'] = 'yes';
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
public function testNewTabAndRemovedBackgroundRequireSchemaTwenty(): void
|
||||
{
|
||||
foreach ([['type' => 'button', 'props' => ['text' => 'Link', 'href' => '/', 'newTab' => true]], ['type' => 'section', 'style' => ['tablet' => ['backgroundImage' => 'none']], 'children' => []]] as $block) {
|
||||
$document = DemoContent::starter('Schema');
|
||||
$document['schemaVersion'] = 19;
|
||||
$document['blocks'][] = ['id' => 'new-option', ...$block];
|
||||
try {
|
||||
(new DocumentValidator())->validate($document);
|
||||
self::fail('An explicit schema upgrade is required.');
|
||||
} catch (InvalidDocument) {
|
||||
$document['schemaVersion'] = 20;
|
||||
(new DocumentValidator())->validate($document);
|
||||
self::assertSame(20, $document['schemaVersion']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testRemovingAnInheritedBackgroundAlsoRemovesItOnNarrowerDevices(): void
|
||||
{
|
||||
$id = str_repeat('a', 32);
|
||||
$this->db->insert('media', ['id' => $id, 'title' => 'Klein', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$id.'/192', 'width' => 192, 'height' => 64, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$compiler = new StyleCompiler(new MediaLibrary($this->db, $this->path.'-media'));
|
||||
$document = ['blocks' => [['id' => 'background', 'style' => ['desktop' => ['backgroundImage' => $id], 'tablet' => ['backgroundImage' => 'none']]]]];
|
||||
$css = $compiler->compileDocument($document);
|
||||
self::assertStringContainsString('#background{background-image:url("/media/'.$id.'/192")}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#background{background-image:none}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#background{background-image:none}}', $css);
|
||||
$document['blocks'][0]['style']['desktop'] += ['gradientFrom' => '#000000', 'gradientTo' => '#ffffff', 'gradientAngle' => 90];
|
||||
$gradientCss = $compiler->compileDocument($document);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#background{background-image:linear-gradient(90deg,#000000,#ffffff)}}', $gradientCss);
|
||||
self::assertStringContainsString('@media (max-width:767px){#background{background-image:linear-gradient(90deg,#000000,#ffffff)}}', $gradientCss);
|
||||
unset($document['blocks'][0]['style']['tablet']);
|
||||
self::assertStringNotContainsString('background-image:none', $compiler->compileDocument($document));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string}>
|
||||
*/
|
||||
@@ -2585,6 +2756,8 @@ final class CmsTest extends TestCase
|
||||
yield 'removes an iframe' => ['<iframe src="https://evil.example"></iframe>ok', 'ok'];
|
||||
yield 'closes unbalanced markup' => ['<strong>unge<em>schlossen', '<strong>unge<em>schlossen</em></strong>'];
|
||||
yield 'keeps a local link' => ['<a href="/kontakt">hier</a>', '<a href="/kontakt">hier</a>'];
|
||||
yield 'new local tab has no opener' => ['<a href="/kontakt" target="_blank" rel="opener">hier</a>', '<a href="/kontakt" target="_blank" rel="noopener">hier</a>'];
|
||||
yield 'rejects named frames' => ['<a href="/kontakt" target="_top">hier</a>', '<a href="/kontakt">hier</a>'];
|
||||
yield 'marks an external link' => ['<a href="https://example.com">dort</a>', '<a href="https://example.com" rel="noopener">dort</a>'];
|
||||
yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3<br>Zwei'];
|
||||
}
|
||||
@@ -3381,7 +3554,7 @@ final class CmsTest extends TestCase
|
||||
yield 'corners' => [['borderRadius' => ['topLeft' => '18px']], '#b1{border-top-left-radius:18px}'];
|
||||
yield 'outline' => [['outlineWidth' => '3px', 'outlineColor' => '#2c6b4f', 'outlineOffset' => '2px'], '#b1{outline-width:3px;outline-color:#2c6b4f;outline-offset:2px}'];
|
||||
yield 'position and layer' => [['position' => 'absolute', 'top' => '0', 'right' => '12px', 'zIndex' => '5'], '#b1{position:absolute;top:0;right:12px;z-index:5}'];
|
||||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}'];
|
||||
yield 'flex item' => [['alignSelf' => 'center', 'flexGrow' => '2', 'flexBasis' => '40%'], '#b1{align-self:center;flex-grow:2;flex-basis:40%}', 'column'];
|
||||
yield 'separate gaps' => [['rowGap' => '8px', 'columnGap' => '24px'], '#b1{row-gap:8px;column-gap:24px}'];
|
||||
yield 'inner shadow' => [['shadowY' => '4px', 'shadowBlur' => '12px', 'shadowInset' => 'innen'], '#b1{box-shadow:inset 0 4px 12px 0 #00000026}'];
|
||||
yield 'outer shadow stays outer' => [['shadowY' => '4px'], '#b1{box-shadow:0 4px 0 0 #00000026}'];
|
||||
@@ -3397,11 +3570,11 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
|
||||
#[DataProvider('styleDeclarations')]
|
||||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected): void
|
||||
public function testEveryStylePropertyCompilesToTheDeclarationItPromises(array $style, string $expected, string $type = 'image'): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
self::assertSame($expected, $compiler->compileDocument([
|
||||
'blocks' => [['id' => 'b1', 'type' => 'image', 'style' => ['desktop' => $style]]],
|
||||
'blocks' => [['id' => 'b1', 'type' => $type, 'style' => ['desktop' => $style]]],
|
||||
]));
|
||||
// And what compiles has to survive the validator, or the editor could offer it in vain.
|
||||
$document = DemoContent::starter('Mit Stil');
|
||||
@@ -3420,8 +3593,7 @@ final class CmsTest extends TestCase
|
||||
$panel = (string) file_get_contents(__DIR__.'/../public/assets/design-panel.js');
|
||||
foreach (array_keys($groups) as $group) {
|
||||
// A group the panel does not know would hold its properties in an unnamed accordion.
|
||||
self::assertMatchesRegularExpression('/
|
||||
'.$group.': /', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||||
self::assertMatchesRegularExpression('/^\h*'.preg_quote($group, '/').':\h/m', $panel, sprintf('Die Gruppe „%s“ fehlt im Panel.', $group));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3435,12 +3607,12 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
self::assertSame('', $this->site->load()[$key]);
|
||||
}
|
||||
// A page that exists is accepted and then linked on every page.
|
||||
// A page that exists is accepted; placing its link remains an editorial choice.
|
||||
$this->publishedPage('datenschutz', 'Datenschutz');
|
||||
$this->site->save(['homePath' => 'home', 'privacyPath' => 'datenschutz']);
|
||||
self::assertSame('datenschutz', $this->site->load()['privacyPath']);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
public function testADeliveredPageIncrementsOneCounterAndTheAdministrationDoesNot(): void
|
||||
@@ -3475,7 +3647,10 @@ final class CmsTest extends TestCase
|
||||
|
||||
public function testTheQuestionAppearsUntilItIsAnsweredAndCanBeChangedAgain(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['schemaVersion'] = 19;
|
||||
$document['blocks'][] = ['id' => 'consent-links', 'type' => 'legal', 'props' => []];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$this->askConsent();
|
||||
|
||||
$response = $this->request('/');
|
||||
|
||||
@@ -82,6 +82,39 @@ final class EditorSecurityTest extends WebTestCase
|
||||
return $client;
|
||||
}
|
||||
|
||||
public function test404SettingsCreateDraftAndProtectAssignmentFromCsrfAndStaleWrites(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
self::assertSelectorExists('#not-found-picker[role="combobox"]');
|
||||
$form = $crawler->selectButton('404-Vorlage anlegen')->form();
|
||||
$values = $form->getPhpValues();
|
||||
$client->request('POST', '/admin/settings/not-found', [...$values, '_csrf_token' => 'invalid']);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
$client->submit($form);
|
||||
self::assertResponseRedirects();
|
||||
$id = json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId'];
|
||||
self::assertResponseRedirects('/admin/pages/'.$id);
|
||||
self::assertNull($this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]));
|
||||
$client->request('POST', '/admin/settings/not-found', ['_csrf_token' => $values['_csrf_token'], 'notFoundPageId' => '', 'expectedId' => '']);
|
||||
self::assertSame($id, json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']);
|
||||
$client->followRedirect();
|
||||
self::assertSelectorTextContains('body', 'inzwischen geändert');
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
$client->submit($crawler->selectButton('404-Zuordnung speichern')->form(['notFoundPageId' => '']));
|
||||
self::assertSame('', json_decode($this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']), true)['notFoundPageId']);
|
||||
}
|
||||
|
||||
public function test404AssignmentRequiresPublicationRights(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/settings');
|
||||
self::assertSelectorNotExists('#not-found-form');
|
||||
$client->request('POST', '/admin/settings/not-found', ['action' => 'create']);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
|
||||
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
@@ -142,6 +175,47 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
|
||||
public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
foreach (['/admin', '/admin/posts', '/admin/media', '/admin/api/media/'.str_repeat('a', 32)] as $url) {
|
||||
$client->request('GET', $url.'?q=secret', server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', 'HTTP_ACCEPT' => 'application/json']);
|
||||
self::assertResponseRedirects('http://localhost/admin/login');
|
||||
self::assertStringNotContainsString('"html":', $client->getResponse()->getContent());
|
||||
}
|
||||
}
|
||||
|
||||
public function testLiveSearchReturnsFilteredEscapedResultsWithTheSamePermissionsAsTheFullScreen(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
foreach (['page', 'post'] as $type) {
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['title'] = 'Suchprobe Über uns <img src=x onerror=alert(1)> '.$type;
|
||||
$pages->createPage('suchprobe-'.$type, null, $type, $document);
|
||||
}
|
||||
foreach (['/admin' => 'page', '/admin/posts' => 'post'] as $url => $type) {
|
||||
$headers = ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'];
|
||||
$client->request('GET', $url.'?q=suchprobe&status=draft&seite=99&limit=50', server: $headers);
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertResponseHeaderSame('Content-Type', 'application/json');
|
||||
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame(1, $data['total']);
|
||||
self::assertSame(['q' => 'suchprobe', 'status' => 'draft', 'limit' => 50, 'seite' => 1], $data['parameters']);
|
||||
self::assertStringContainsString('suchprobe-'.$type, $data['html']);
|
||||
self::assertStringNotContainsString('suchprobe-'.('page' === $type ? 'post' : 'page'), $data['html']);
|
||||
self::assertStringNotContainsString('<img src=x', $data['html']);
|
||||
self::assertStringContainsString('<img src=x', $data['html']);
|
||||
self::assertStringNotContainsString('delete-panel', $data['html']);
|
||||
self::assertStringNotContainsString('<html', $data['html']);
|
||||
self::assertStringContainsString('name="_list_query" value="suchprobe"', $data['html']);
|
||||
$client->request('GET', $url.'?q='.rawurlencode('über uns'), server: $headers);
|
||||
self::assertSame(1, json_decode($client->getResponse()->getContent(), true)['total']);
|
||||
$client->request('GET', $url.'?q=suchprobe&status=published', server: $headers);
|
||||
self::assertSame(0, json_decode($client->getResponse()->getContent(), true)['total']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testCreatingAPageNeedsACsrfTokenAndStartsAsAPrivateDraft(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
@@ -506,6 +580,22 @@ final class EditorSecurityTest extends WebTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testMediaPreviewReadsSmallAndArchivedImagesWithoutListingTheLibrary(): void
|
||||
{
|
||||
$id = str_repeat('a', 32);
|
||||
$this->db->insert('media', ['id' => $id, 'title' => 'Kleines Logo', 'alt' => 'Logo', 'variants' => json_encode([['src' => '/media/'.$id.'/192', 'width' => 192, 'height' => 64, 'bytes' => 1]]), 'archived' => 1, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/api/media/'.$id);
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame($id, $data['item']['id']);
|
||||
self::assertSame(192, $data['item']['variants'][0]['width']);
|
||||
self::assertTrue($data['item']['archived']);
|
||||
self::assertArrayNotHasKey('items', $data);
|
||||
$client->request('GET', '/admin/api/media/'.str_repeat('b', 32));
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testTheMediaScreenUploadsAndEditsWithoutJavaScript(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
@@ -553,7 +643,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Nochmal', 'alt' => '', 'version' => 1]);
|
||||
self::assertResponseRedirects('/admin/media');
|
||||
self::assertSame('Bergblick', $this->db->fetchOne('SELECT title FROM media WHERE id = ?', [$id]));
|
||||
self::assertCount(1, $client->followRedirect()->filter('.notice.error'));
|
||||
self::assertCount(1, $client->followRedirect()->filter('.notice.error:not([hidden])'));
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
@@ -593,7 +683,42 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertResponseRedirects('/admin/media?seite=2');
|
||||
}
|
||||
|
||||
public function testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
|
||||
public function testMediaLiveSearchCoversAllPagesAltTextArchiveAndLiteralWildcards(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
for ($number = 1; $number <= 27; ++$number) {
|
||||
$this->db->insert('media', [
|
||||
'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT),
|
||||
'title' => 1 === $number ? 'Seltenes Motiv 100%_!' : 'Bild '.$number,
|
||||
'alt' => 2 === $number ? 'Einzigartiger Alternativtext Äußerer Bereich' : '',
|
||||
'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]),
|
||||
'archived' => 27 === $number ? 1 : 0,
|
||||
'version' => 1,
|
||||
'created_at' => gmdate('Y-m-d H:i:s', 1700000000 + $number),
|
||||
]);
|
||||
}
|
||||
foreach ([['SELTENES', false, 1], ['ALTERNATIVTEXT', false, 1], ['äußerer', false, 1], ['%_!', false, 1], ['Bild', false, 25], ['Bild', true, 26], ['fehlt', false, 0]] as [$query, $archived, $total]) {
|
||||
$parameters = ['q' => $query, 'archiv' => $archived ? 1 : 0, 'seite' => 99];
|
||||
$client->request('GET', '/admin/media?'.http_build_query($parameters), server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']);
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame($total, $data['total'], $query);
|
||||
self::assertSame($total > 24 ? 2 : 1, $data['parameters']['seite']);
|
||||
self::assertStringNotContainsString('<html', $data['html']);
|
||||
}
|
||||
// The HTML fallback uses the same global search, and saving retains its context.
|
||||
$crawler = $client->request('GET', '/admin/media?q=Seltenes');
|
||||
self::assertCount(1, $crawler->filter('.library-tile'));
|
||||
$form = $crawler->filter('.library-tile form')->form(['alt' => 'Geändert']);
|
||||
$client->submit($form);
|
||||
self::assertResponseRedirects('/admin/media?q=Seltenes');
|
||||
self::assertCount(1, $client->followRedirect()->filter('.library-tile'));
|
||||
$crawler = $client->request('GET', '/admin/media?q=0');
|
||||
$client->submit($crawler->filter('.library-tile form')->first()->form(['alt' => 'Ziffernsuche']));
|
||||
self::assertResponseRedirects('/admin/media?q=0');
|
||||
}
|
||||
|
||||
public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/settings');
|
||||
@@ -620,11 +745,11 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertStringContainsString('NOCH AUSZUFÜLLEN', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
// And they are linked from an ordinary page, without anyone editing the footer.
|
||||
// Creating the pages does not alter the author's footer or append a link bar.
|
||||
$client->request('GET', '/');
|
||||
$content = $client->getResponse()->getContent();
|
||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
||||
self::assertStringContainsString('<a href="/impressum">Impressum</a>', $content);
|
||||
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
||||
self::assertStringNotContainsString('<a href="/impressum">Impressum</a>', $content);
|
||||
|
||||
// Running it twice keeps the existing pages: they may already carry the real text.
|
||||
$before = $this->db->fetchOne('SELECT COUNT(*) FROM revision');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\StyleCompiler;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ImageAlignmentTest extends TestCase
|
||||
{
|
||||
public function testAlignmentOnlyAddsRulesToImagesWithAnExplicitSetting(): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
self::assertSame('#plain{width:50%}#text{align-self:center}', $compiler->compileDocument(['blocks' => [
|
||||
['id' => 'plain', 'type' => 'image', 'style' => ['desktop' => ['width' => '50%']]],
|
||||
['id' => 'text', 'type' => 'text', 'style' => ['desktop' => ['alignSelf' => 'center']]],
|
||||
]]));
|
||||
self::assertSame('', $compiler->compileDocument(['blocks' => [
|
||||
['id' => 'bad{selector}', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center']]],
|
||||
]]));
|
||||
}
|
||||
|
||||
public function testResponsiveResetRestoresAuthoredMarginsAndClearsInnerAlignment(): void
|
||||
{
|
||||
foreach (['auto', 'stretch', 'baseline'] as $reset) {
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'logo', 'type' => 'image', 'style' => [
|
||||
'desktop' => ['width' => '50%', 'alignSelf' => 'center', 'margin' => ['left' => '11px', 'right' => '7px', 'top' => '20px']],
|
||||
'tablet' => ['alignSelf' => 'flex-end'],
|
||||
'mobile' => ['alignSelf' => $reset, 'margin' => ['left' => '15px']],
|
||||
],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#logo{margin-left:auto;margin-right:0;justify-self:end}#logo img{margin-left:auto;margin-right:0}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#logo{margin-left:15px;margin-right:7px;justify-self:auto}#logo img{margin-left:0;margin-right:0}}', $css);
|
||||
self::assertSame(1, substr_count($css, 'margin-top:20px'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testChangingParentFlexAxisRecomputesInheritedImageAlignment(): void
|
||||
{
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'group', 'type' => 'column', 'style' => [
|
||||
'desktop' => ['display' => 'inline-flex', 'flexDirection' => 'row-reverse'],
|
||||
'tablet' => ['flexDirection' => 'column-reverse'],
|
||||
'mobile' => ['flexDirection' => 'row'],
|
||||
], 'children' => [[
|
||||
'id' => 'logo', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center', 'margin' => ['left' => '9px']]],
|
||||
]],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{align-self:center;margin-left:9px}', $css);
|
||||
self::assertStringContainsString('#logo{margin-left:9px;margin-right:0;justify-self:auto}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#logo{margin-left:9px;margin-right:0;justify-self:auto}#logo img{margin-left:auto;margin-right:auto}}', $css);
|
||||
}
|
||||
|
||||
public function testSectionAccordionAndSliderInnerWrappersUseBlockAlignment(): void
|
||||
{
|
||||
foreach (['section', 'accordionitem', 'slider'] as $type) {
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'group', 'type' => $type, 'style' => ['desktop' => ['display' => 'flex']],
|
||||
'children' => [['id' => 'logo', 'type' => 'image', 'style' => ['desktop' => ['alignSelf' => 'center']]]],
|
||||
]]]);
|
||||
self::assertStringContainsString('#logo{margin-left:auto;margin-right:auto;justify-self:center}#logo img{margin-left:auto;margin-right:auto}', $css);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,17 @@ test('v6 containers accept only their own children and an accordion starts with
|
||||
assert.equal(canContain(block, { type: 'accordionitem' }), true);
|
||||
assert.equal(canContain({ type: 'section', children: [] }, { type: 'accordionitem' }), false);
|
||||
assert.equal(canContain(block.children[0], { type: 'text' }), true);
|
||||
// Selecting the group must not silently put a text outside it. Choose the entry first.
|
||||
assert.equal(insertionTarget(history.document, accordion, 'text'), null);
|
||||
assert.equal(insertBlock(history.document, 'text', accordion), null);
|
||||
const entry = block.children[0].id;
|
||||
const text = history.change(doc => insertBlock(doc, 'text', entry));
|
||||
assert.equal(locate(history.document.blocks, text).parent.id, entry);
|
||||
history.undo();
|
||||
assert.equal(locate(history.document.blocks, entry).block.children.length, 0);
|
||||
history.redo();
|
||||
assert.equal(locate(history.document.blocks, text).parent.id, entry);
|
||||
history.undo();
|
||||
|
||||
// A logo strip takes images only, like a gallery.
|
||||
const logos = history.change(doc => insertBlock(doc, 'logos', 's2'));
|
||||
@@ -305,6 +316,45 @@ test('free styles inherit from the wider device, reset individually and leave no
|
||||
assert.deepEqual(history.document, fixture());
|
||||
});
|
||||
|
||||
test('removing an inherited background upgrades the schema and can be reset or undone', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
history.change(doc => setStyle(doc, 's1', 'desktop', 'backgroundImage', 'a'.repeat(32)));
|
||||
assert.equal(history.document.schemaVersion, 10);
|
||||
history.change(doc => setStyle(doc, 's1', 'tablet', 'backgroundImage', 'none'));
|
||||
const block = () => locate(history.document.blocks, 's1').block;
|
||||
assert.equal(history.document.schemaVersion, 20);
|
||||
assert.deepEqual(effectiveStyle(block(), 'mobile', 'backgroundImage'), { value: 'none', source: 'tablet' });
|
||||
history.change(doc => setStyle(doc, 's1', 'tablet', 'backgroundImage', ''));
|
||||
assert.deepEqual(effectiveStyle(block(), 'mobile', 'backgroundImage'), { value: 'a'.repeat(32), source: 'desktop' });
|
||||
history.undo();
|
||||
assert.equal(block().style.tablet.backgroundImage, 'none');
|
||||
history.undo();
|
||||
assert.equal(history.document.schemaVersion, 10);
|
||||
});
|
||||
|
||||
test('replacing an image preserves its link, caption and layout and remains one undo step', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
const first = { id: 'a'.repeat(32), alt: 'First', variants: [{ src: '/media/a/192.webp', width: 192, height: 96 }] };
|
||||
const second = { id: 'b'.repeat(32), alt: 'Second', variants: [{ src: '/media/b/480.webp', width: 480, height: 320 }] };
|
||||
const galleryId = history.change(doc => insertMedia(doc, 's1', [first]));
|
||||
const id = locate(history.document.blocks, galleryId).block.children[0].id;
|
||||
history.change(doc => {
|
||||
const block = locate(doc.blocks, id).block;
|
||||
Object.assign(block.props, { href: '/kontakt', click: 'link', newTab: true, caption: 'Meine Bildunterschrift' });
|
||||
setStyle(doc, id, 'desktop', 'width', '50%'); upgradeDocument(doc, 20);
|
||||
});
|
||||
const before = structuredClone(history.document);
|
||||
history.change(doc => insertMedia(doc, id, [second]));
|
||||
const replaced = locate(history.document.blocks, id).block;
|
||||
assert.equal(replaced.props.mediaId, second.id);
|
||||
assert.equal(replaced.props.width, 480); assert.equal(replaced.props.height, 320);
|
||||
assert.equal(replaced.props.alt, 'Second'); assert.equal(replaced.props.href, '/kontakt');
|
||||
assert.equal(replaced.props.click, 'link'); assert.equal(replaced.props.newTab, true);
|
||||
assert.equal(replaced.props.caption, 'Meine Bildunterschrift');
|
||||
assert.equal(replaced.style.desktop.width, '50%');
|
||||
history.undo(); assert.deepEqual(history.document, before);
|
||||
});
|
||||
|
||||
test('a row arrives with its columns, inside the selected container', () => {
|
||||
const history = new EditorHistory(fixture());
|
||||
|
||||
|
||||
Reference in new issue
Block a user