Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55dab4bc1e | ||
|
|
c1b152b376 | ||
|
|
8f475d1374 | ||
|
|
ab86312e87 | ||
|
|
16da844934 | ||
|
|
a3c89bb69d | ||
|
|
25fd388f80 | ||
|
|
e18c881db8 | ||
|
|
6d21c452bd | ||
|
|
b8991ddd5c |
No files matched your search
@@ -22,8 +22,18 @@ 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.
|
**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
|
## Anmelden und bearbeiten
|
||||||
|
|
||||||
|
**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:
|
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -36,6 +46,8 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
|||||||
|
|
||||||
**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/`.
|
**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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ services:
|
|||||||
App\EventSubscriber\AdminHeadersSubscriber:
|
App\EventSubscriber\AdminHeadersSubscriber:
|
||||||
arguments:
|
arguments:
|
||||||
$adminPath: '%kant.admin_path%'
|
$adminPath: '%kant.admin_path%'
|
||||||
|
App\EventSubscriber\PublicNotFoundSubscriber:
|
||||||
|
arguments:
|
||||||
|
$adminPath: '%kant.admin_path%'
|
||||||
App\Content\Statistics:
|
App\Content\Statistics:
|
||||||
arguments:
|
arguments:
|
||||||
$adminPath: '%kant.admin_path%'
|
$adminPath: '%kant.admin_path%'
|
||||||
|
|||||||
@@ -2,6 +2,46 @@
|
|||||||
|
|
||||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||||
|
|
||||||
|
## 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)
|
## 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.
|
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.
|
||||||
|
|||||||
+63
-1
@@ -1,12 +1,74 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**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. Release 1.8.2 bereitstellen und über die Systemverwaltung einspielen; Kundenhostings werden 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)
|
## 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.
|
**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.
|
**Geprüft:** `npm run test:image-size` auf einer isolierten Kant-IT-Kopie mit dem TeamViewer-Logo: Regler mit echter Mausbewegung und Tastatur, Zahlenfeld, Fokus, ungültiger Wert, Undo/Redo, responsive Vererbung/Reset, Speichern/Laden, unveränderte Bilddaten und Abgleich mit Design → Größe. Veröffentlichte Ausgabe ohne JavaScript bei 1600/900/390 px: 60/75/30 % und unverzerrtes Seitenverhältnis gemessen. Neue Steuerung auf Desktop/Mobil ohne Überlauf und axe-Verstöße, Screenshots visuell geprüft (`reports/image-size/`). Vollständige Editor-Browserprüfung erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). 30 JavaScript-Tests und 35 PHP-Tests zu Styles/Bildern mit 67 Assertions erfolgreich. Alle Schreibtests in eigenen Datenbankkopien.
|
||||||
|
|
||||||
**Offen:** Geprüfte Fassung als 1.7.2 veröffentlichen. Kundenhostings wurden nicht verändert. Da nur die Editorsteuerung vorhandener Style-Werte geändert wurde, keine erneuten öffentlichen Lighthouse-Messungen; die Prozentdarstellung wurde im gemeinsamen Renderer direkt überprüft.
|
**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)
|
## 1.7.1 – Livesuche in der Verwaltung (14.09.2026)
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -11,7 +11,10 @@
|
|||||||
"audit": "node scripts/audit.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:search": "node scripts/live-search-check.mjs",
|
||||||
"test:image-size": "node scripts/image-size-check.mjs"
|
"test:image-size": "node scripts/image-size-check.mjs",
|
||||||
|
"test:link-media": "node scripts/link-media-check.mjs",
|
||||||
|
"test:404": "node scripts/not-found-check.mjs",
|
||||||
|
"test:colors": "node scripts/color-picker-check.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.10.0",
|
"@axe-core/playwright": "^4.10.0",
|
||||||
|
|||||||
@@ -708,6 +708,7 @@ details.card > summary:hover { background: #fafbf8 }
|
|||||||
|
|
||||||
/* Search and forms share usable widths throughout administration. */
|
/* 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}
|
.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}
|
.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{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%}
|
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
||||||
|
|||||||
@@ -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.
|
* appear here without touching this file.
|
||||||
*/
|
*/
|
||||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
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 = {
|
export const GROUPS = {
|
||||||
text: 'Typografie',
|
text: 'Typografie',
|
||||||
@@ -204,31 +205,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
|||||||
return wrap;
|
return wrap;
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorControl = (id, value, apply) => {
|
const colorControl = (id, value, apply, label, inheritedValue = '') => createColorControl({
|
||||||
const wrap = element('div', 'color-control');
|
id, value, label, inheritedValue, colors: schema.colors, onChange: apply,
|
||||||
const picker = document.createElement('input');
|
onComplete: () => {
|
||||||
picker.type = 'color';
|
const focusedId = document.activeElement?.id;
|
||||||
picker.id = id;
|
onChange();
|
||||||
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
|
if (focusedId) document.getElementById(focusedId)?.focus();
|
||||||
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 lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||||
const wrap = element('div', 'length-control');
|
const wrap = element('div', 'length-control');
|
||||||
@@ -444,15 +428,18 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
|||||||
|
|
||||||
const mediaControl = (key, value, apply) => {
|
const mediaControl = (key, value, apply) => {
|
||||||
const wrap = element('div', 'media-control');
|
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 hasImage = Boolean(value && value !== 'none');
|
||||||
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
const preview = createMediaPreview({ mediaId: hasImage ? value : '' });
|
||||||
|
const choose = element('button', 'secondary', hasImage ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||||
choose.type = 'button';
|
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); } }));
|
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
|
||||||
wrap.append(status, choose);
|
wrap.append(preview.element, choose);
|
||||||
if (value) {
|
if (hasImage) {
|
||||||
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
||||||
remove.type = 'button';
|
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);
|
wrap.append(remove);
|
||||||
}
|
}
|
||||||
return wrap;
|
return wrap;
|
||||||
@@ -536,12 +523,12 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
|||||||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||||||
continue;
|
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 === '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 === '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 === '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 === '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;
|
else continue;
|
||||||
|
|
||||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
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)) {
|
if (schema.hoverable.includes(key)) {
|
||||||
const hoverId = `${id}-hover`;
|
const hoverId = `${id}-hover`;
|
||||||
const hoverValue = hoverStyle(block, deviceName, key);
|
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 hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||||||
const hoverControl = property.type === 'color'
|
const hoverControl = property.type === 'color'
|
||||||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited)
|
||||||
: property.type === 'number'
|
: property.type === 'number'
|
||||||
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
|
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
|
||||||
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
|
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
|
||||||
@@ -618,6 +610,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
|||||||
};
|
};
|
||||||
|
|
||||||
function describe(property, value) {
|
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;
|
if (property.type === 'choice') return property.values[value] ?? value;
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
|||||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
// 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
|
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||||
// nirgends. Eine Zahl, an einer Stelle.
|
// 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;
|
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
|
||||||
|
|
||||||
export function upgradeDocument(document, version = 2) {
|
export function upgradeDocument(document, version = 2) {
|
||||||
@@ -310,7 +310,7 @@ export function mediaProps(media) {
|
|||||||
export function insertMedia(document, selected, items) {
|
export function insertMedia(document, selected, items) {
|
||||||
const target = locate(document.blocks, selected)?.block;
|
const target = locate(document.blocks, selected)?.block;
|
||||||
upgradeDocument(document, 3);
|
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;
|
let parent = target;
|
||||||
if (target?.type !== 'gallery') {
|
if (target?.type !== 'gallery') {
|
||||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||||
@@ -496,6 +496,7 @@ export function setStyle(document, id, device, key, value, { side = null, hover
|
|||||||
if (!block) return;
|
if (!block) return;
|
||||||
const empty = value === '' || value === null || value === undefined;
|
const empty = value === '' || value === null || value === undefined;
|
||||||
if (!empty) upgradeDocument(document, 10);
|
if (!empty) upgradeDocument(document, 10);
|
||||||
|
if (key === 'backgroundImage' && value === 'none') upgradeDocument(document, 20);
|
||||||
|
|
||||||
block.style ??= {};
|
block.style ??= {};
|
||||||
block.style[device] ??= {};
|
block.style[device] ??= {};
|
||||||
|
|||||||
@@ -131,15 +131,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-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{margin-top:14px}
|
||||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
.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{color:#2f5d4a;font-weight:700}
|
||||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
.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-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}
|
.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{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
|
||||||
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
|
.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-control .color-text{font-size:12px;min-height:38px}
|
.color-trigger:hover{background:#f5f7f3}
|
||||||
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
|
.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{display:grid;grid-template-columns:1fr 74px;gap:6px}
|
||||||
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
||||||
.length-control.is-inherited input{color:var(--muted)}
|
.length-control.is-inherited input{color:var(--muted)}
|
||||||
@@ -193,6 +217,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
|||||||
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
|
.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{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 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{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:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
|
||||||
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
||||||
|
|||||||
+48
-10
@@ -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 { 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 { createDesignPanel } from './design-panel.js';
|
||||||
import { createRichText } from './rich-text.js';
|
import { createRichText } from './rich-text.js';
|
||||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||||
@@ -177,7 +177,9 @@ function renderProperties() {
|
|||||||
}
|
}
|
||||||
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
|
if (block && propertyPanel === 'advanced') { renderAdvanced(block, panel); return; }
|
||||||
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||||||
|
let imagePreview;
|
||||||
const field = (label, key, value, options = {}) => {
|
const field = (label, key, value, options = {}) => {
|
||||||
|
const container = options.container || panel;
|
||||||
const id = `property-${key}`;
|
const id = `property-${key}`;
|
||||||
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
|
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
|
||||||
let input;
|
let input;
|
||||||
@@ -189,8 +191,13 @@ function renderProperties() {
|
|||||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
|
if (options.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.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||||||
if (options.maxLength) input.maxLength = options.maxLength;
|
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;
|
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 => {
|
history.change(doc => {
|
||||||
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
@@ -203,14 +210,20 @@ function renderProperties() {
|
|||||||
else current[key] = next;
|
else current[key] = next;
|
||||||
if (key === 'caption') upgradeDocument(doc, 3);
|
if (key === 'caption') upgradeDocument(doc, 3);
|
||||||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
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; }
|
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||||
}, `${selected || 'page'}:${key}`);
|
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||||
if (key === 'decorative') renderProperties();
|
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||||
|
options.onInput?.(next);
|
||||||
|
if (key === 'decorative') { renderProperties(); document.getElementById(id)?.focus({ preventScroll: true }); }
|
||||||
renderTree(); controls(); schedulePreview();
|
renderTree(); controls(); schedulePreview();
|
||||||
});
|
};
|
||||||
|
input.addEventListener('input', applyValue);
|
||||||
|
if (options.choices) input.addEventListener('change', applyValue);
|
||||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||||
if (options.checkbox) panel.append(labelEl); else panel.append(labelEl, input);
|
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); panel.append(help); }
|
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) {
|
if (!block) {
|
||||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||||
@@ -315,7 +328,7 @@ function renderProperties() {
|
|||||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true });
|
||||||
}
|
}
|
||||||
if (block.type === 'legal') {
|
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') {
|
if (block.type === 'postnav') {
|
||||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||||
@@ -334,10 +347,19 @@ function renderProperties() {
|
|||||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||||
if (block.type === 'image') {
|
if (block.type === 'image') {
|
||||||
renderImageWidth(block, panel);
|
renderImageWidth(block, panel);
|
||||||
field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' });
|
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||||
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 /.' });
|
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';
|
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||||
|
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('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 });
|
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
|
||||||
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
|
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
|
||||||
@@ -351,6 +373,22 @@ function renderProperties() {
|
|||||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
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') {
|
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('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.' });
|
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.' });
|
||||||
|
|||||||
@@ -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 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 } = {}) {
|
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||||
const opener = document.activeElement;
|
const opener = document.activeElement;
|
||||||
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
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.');
|
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
function render() {
|
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 el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
|
||||||
const rows = () => [...list.children].map(row => {
|
const rows = () => [...list.children].map(row => {
|
||||||
const data = { level: Number(row.dataset.level) };
|
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.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
|
||||||
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
|
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
|
||||||
return data;
|
return data;
|
||||||
@@ -49,7 +49,10 @@ function createRow(data) {
|
|||||||
const field = (key, value, label, type = 'text') => {
|
const field = (key, value, label, type = 'text') => {
|
||||||
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
|
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
|
||||||
input.id = `menu-${key}-${id}`;
|
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);
|
else row.append(input);
|
||||||
return input;
|
return input;
|
||||||
};
|
};
|
||||||
@@ -68,6 +71,7 @@ function createRow(data) {
|
|||||||
body.append(picker); setupPicker(picker);
|
body.append(picker); setupPicker(picker);
|
||||||
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
||||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
} 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');
|
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' })) {
|
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);
|
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', () => {
|
form.querySelector('#menu-add-url').addEventListener('click', () => {
|
||||||
const name = form.querySelector('#link-label'), url = form.querySelector('#link-url');
|
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; }
|
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('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
|
||||||
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
|
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
|||||||
linkInput.type = 'text';
|
linkInput.type = 'text';
|
||||||
linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…';
|
linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…';
|
||||||
linkInput.setAttribute('aria-label', 'Linkziel');
|
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 confirmLink = () => {
|
||||||
const target = linkInput.value.trim();
|
const target = linkInput.value.trim();
|
||||||
linkRow.hidden = true;
|
linkRow.hidden = true;
|
||||||
@@ -93,8 +96,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
|||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
selection.addRange(savedRange);
|
selection.addRange(savedRange);
|
||||||
}
|
}
|
||||||
if (target) document.execCommand('createLink', false, target);
|
if (target) {
|
||||||
else document.execCommand('unlink');
|
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);
|
push(surface.innerHTML);
|
||||||
linkInput.value = '';
|
linkInput.value = '';
|
||||||
};
|
};
|
||||||
@@ -102,14 +113,20 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
|||||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
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', () => {
|
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||||
const selection = window.getSelection();
|
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;
|
linkRow.hidden = false;
|
||||||
linkInput.focus();
|
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 toggle = button('HTML', 'Zwischen Formatierung und HTML wechseln', () => {
|
||||||
const toSource = source.hidden;
|
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-footer .block-column{border:0;border-radius:0;padding:0}
|
||||||
.site-header .block-text,
|
.site-header .block-text,
|
||||||
.site-footer .block-text{margin:0}
|
.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
|
/* 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. */
|
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 }
|
.block-image img, .block-gallery img, .block-logos img { border-radius: inherit }
|
||||||
|
|||||||
@@ -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');
|
await frameValue('#headline', 'fontSize', '72px');
|
||||||
|
|
||||||
// Colours: a palette reference resolves to the theme variable, a hex value is taken as it is.
|
// 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)');
|
await frameValue('#headline', 'color', 'rgb(164, 81, 15)');
|
||||||
const contrast = page.locator('.contrast-note');
|
const contrast = page.locator('.contrast-note');
|
||||||
await contrast.waitFor();
|
await contrast.waitFor();
|
||||||
@@ -572,9 +573,10 @@ try {
|
|||||||
// The background group is collapsed by default, and the readable field next to the swatch is
|
// The background group is collapsed by default, and the readable field next to the swatch is
|
||||||
// what a person types into.
|
// what a person types into.
|
||||||
await openGroup('Hintergrund');
|
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.fill('#112233');
|
||||||
await hoverColor.blur();
|
await hoverColor.press('Enter');
|
||||||
// The preview carries its stylesheet as template text, applied by the editor as a constructed
|
// 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.
|
// stylesheet; the hover rule has to arrive through that path, not as a style element.
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
@@ -857,7 +859,7 @@ try {
|
|||||||
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
|
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
|
||||||
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
|
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('.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 });
|
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.
|
// And the way back: the consent page can refuse again, which blocks the embed once more.
|
||||||
await visitorPage.goto(origin + '/einwilligung');
|
await visitorPage.goto(origin + '/einwilligung');
|
||||||
|
|||||||
@@ -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,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
|
public function validate(array $document, bool $requireHeading = true): void
|
||||||
{
|
{
|
||||||
$schemaVersion = $document['schemaVersion'] ?? null;
|
$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['title'] ?? null, 160);
|
||||||
$this->text($document['description'] ?? null, 320);
|
$this->text($document['description'] ?? null, 320);
|
||||||
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
$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);
|
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
|
||||||
$this->text($props['text'] ?? null, $limit);
|
$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) {
|
if ('heading' === $type) {
|
||||||
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
||||||
$headings += 1 === $props['level'] ? 1 : 0;
|
$headings += 1 === $props['level'] ? 1 : 0;
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ final class MenuRepository
|
|||||||
if ('' === trim($label)) {
|
if ('' === trim($label)) {
|
||||||
continue;
|
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;
|
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) {
|
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.');
|
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 ('page' === ($item['type'] ?? null)) {
|
||||||
if (!is_string($item['pageId'] ?? null) || 1 !== preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $item['pageId'])) {
|
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.');
|
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' => [],
|
'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [],
|
||||||
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
|
'code' => [], 'mark' => [], 'sup' => [], 'sub' => [],
|
||||||
'ul' => [], 'ol' => [], 'li' => [],
|
'ul' => [], 'ol' => [], 'li' => [],
|
||||||
'a' => ['href'],
|
'a' => ['href', 'target'],
|
||||||
];
|
];
|
||||||
/** Tags whose content is dropped along with them; unwrapping them would leak code as text. */
|
/** 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'];
|
private const DROP_ENTIRELY = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math'];
|
||||||
@@ -121,7 +121,10 @@ final class RichText
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$child->setAttribute('href', $href);
|
$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');
|
$child->setAttribute('rel', 'noopener');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Content;
|
namespace App\Content;
|
||||||
|
|
||||||
use App\Domain\InvalidPage;
|
use App\Domain\InvalidPage;
|
||||||
|
use App\Domain\StaleWrite;
|
||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Doctrine\DBAL\Exception\TableNotFoundException;
|
use Doctrine\DBAL\Exception\TableNotFoundException;
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ final class SiteSettings
|
|||||||
'robotsExtra' => '',
|
'robotsExtra' => '',
|
||||||
// Which stored page answers at the site root. Everything else derives from this.
|
// Which stored page answers at the site root. Everything else derives from this.
|
||||||
'homePath' => 'home',
|
'homePath' => 'home',
|
||||||
|
'notFoundPageId' => '',
|
||||||
// The consent question. Off by default, because a site that loads nothing from third
|
// 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.
|
// parties has nothing to ask about, and a banner without a reason is only in the way.
|
||||||
'consentBanner' => false,
|
'consentBanner' => false,
|
||||||
@@ -96,6 +98,8 @@ final class SiteSettings
|
|||||||
public function save(array $input): void
|
public function save(array $input): void
|
||||||
{
|
{
|
||||||
$values = self::DEFAULTS;
|
$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) {
|
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);
|
$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.');
|
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.
|
// 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));
|
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['homePath'] = $home;
|
||||||
|
|
||||||
$values['consentBanner'] = (bool) ($input['consentBanner'] ?? false);
|
$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.
|
* 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),
|
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)),
|
'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.'),
|
default => $this->require(false, 'Unknown style type.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ final class StyleCompiler
|
|||||||
$this->css(['type' => 'color'], (string) $values['gradientTo']),
|
$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']);
|
$item = $this->media->find((string) $values['backgroundImage']);
|
||||||
if (null !== $item && [] !== $item['variants']) {
|
if (null !== $item && [] !== $item['variants']) {
|
||||||
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
// A phone should not download the 2400 pixel variant of a background. CSS has no
|
||||||
@@ -244,7 +244,7 @@ final class StyleCompiler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [] === $layers ? '' : 'background-image:'.implode(',', $layers);
|
return [] === $layers ? ('none' === ($values['backgroundImage'] ?? null) ? 'background-image:none' : '') : 'background-image:'.implode(',', $layers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shadow(array $values): string
|
private function shadow(array $values): string
|
||||||
@@ -348,21 +348,19 @@ final class StyleCompiler
|
|||||||
*/
|
*/
|
||||||
private function scaleBackground(array $style): array
|
private function scaleBackground(array $style): array
|
||||||
{
|
{
|
||||||
$image = $style['desktop']['backgroundImage'] ?? null;
|
$inherited = $style['desktop'] ?? [];
|
||||||
if (null === $image) {
|
|
||||||
return $style;
|
|
||||||
}
|
|
||||||
foreach (['tablet', 'mobile'] as $device) {
|
foreach (['tablet', 'mobile'] as $device) {
|
||||||
if (!isset($style[$device]['backgroundImage'])) {
|
if (isset($inherited['backgroundImage']) || isset($style[$device]['backgroundImage'])) {
|
||||||
$style[$device]['backgroundImage'] = $image;
|
$style[$device]['backgroundImage'] ??= $inherited['backgroundImage'];
|
||||||
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
|
// Verlauf und Überlagerung gehören zur selben Regel: ohne sie stünde auf dem
|
||||||
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
|
// Telefon plötzlich ein unabgedunkeltes Bild hinter hellem Text.
|
||||||
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
|
foreach (['gradientFrom', 'gradientTo', 'gradientAngle', 'overlayColor', 'overlayOpacity', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'backgroundAttachment'] as $begleiter) {
|
||||||
if (isset($style['desktop'][$begleiter]) && !isset($style[$device][$begleiter])) {
|
if (isset($inherited[$begleiter]) && !isset($style[$device][$begleiter])) {
|
||||||
$style[$device][$begleiter] = $style['desktop'][$begleiter];
|
$style[$device][$begleiter] = $inherited[$begleiter];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$inherited = array_replace($inherited, $style[$device] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $style;
|
return $style;
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ final class MediaController extends AbstractController
|
|||||||
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
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/upload', methods: ['POST'])]
|
||||||
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], 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
|
public function write(Request $request, ?string $id = null): Response
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ final class MenuController extends AbstractController
|
|||||||
$rows[] = [
|
$rows[] = [
|
||||||
'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
|
'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
|
||||||
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
|
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
|
||||||
|
'newTab' => $item['newTab'] ?? false,
|
||||||
];
|
];
|
||||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,7 @@ final class MenuController extends AbstractController
|
|||||||
'pageId' => (string) ($row['pageId'] ?? ''),
|
'pageId' => (string) ($row['pageId'] ?? ''),
|
||||||
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
|
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
|
||||||
'url' => trim((string) ($row['url'] ?? '')),
|
'url' => trim((string) ($row['url'] ?? '')),
|
||||||
|
'newTab' => '1' === (string) ($row['newTab'] ?? ''),
|
||||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||||
'position' => (int) ($row['position'] ?? 0),
|
'position' => (int) ($row['position'] ?? 0),
|
||||||
];
|
];
|
||||||
@@ -110,6 +112,9 @@ final class MenuController extends AbstractController
|
|||||||
$items = [];
|
$items = [];
|
||||||
foreach ($flat as $row) {
|
foreach ($flat as $row) {
|
||||||
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
|
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
|
||||||
|
if ($row['newTab']) {
|
||||||
|
$item['newTab'] = true;
|
||||||
|
}
|
||||||
if ('url' === $row['type']) {
|
if ('url' === $row['type']) {
|
||||||
$item['url'] = $row['url'];
|
$item['url'] = $row['url'];
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Content\FontLibrary;
|
|||||||
use App\Content\FormRepository;
|
use App\Content\FormRepository;
|
||||||
use App\Content\MediaLibrary;
|
use App\Content\MediaLibrary;
|
||||||
use App\Content\MenuRepository;
|
use App\Content\MenuRepository;
|
||||||
|
use App\Content\NotFoundPage;
|
||||||
use App\Content\PageRepository;
|
use App\Content\PageRepository;
|
||||||
use App\Content\PartialRepository;
|
use App\Content\PartialRepository;
|
||||||
use App\Content\PostRepository;
|
use App\Content\PostRepository;
|
||||||
@@ -24,7 +25,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
|
|
||||||
final class PageController extends AbstractController
|
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);
|
return $this->redirectToRoute('home', status: 301);
|
||||||
}
|
}
|
||||||
$page = $this->pages->findPublished($path);
|
$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) {
|
if (null === $page) {
|
||||||
// An address that moved or was deliberately removed answers before the 404 does.
|
|
||||||
$redirect = $this->redirects->find($path);
|
$redirect = $this->redirects->find($path);
|
||||||
if (null !== $redirect && 301 === $redirect['status']) {
|
if (null !== $redirect && 301 === $redirect['status']) {
|
||||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
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 {
|
return $this->notFound($request, null !== $redirect ? 410 : 404);
|
||||||
$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')),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
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
|
// 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.
|
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||||
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
$mitTurnstile = $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']));
|
$wantsScript = ($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));
|
$frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||||
if ($mitTurnstile) {
|
if ($mitTurnstile) {
|
||||||
$frames[] = 'challenges.cloudflare.com';
|
$frames[] = 'challenges.cloudflare.com';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($status >= 400) {
|
||||||
|
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||||
|
}
|
||||||
|
|
||||||
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
return $this->shell($response, $wantsScript, $styles, null !== $googleFonts, $frames);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +452,7 @@ final class PageController extends AbstractController
|
|||||||
if (null !== $slug) {
|
if (null !== $slug) {
|
||||||
$term = $this->posts->findTerm($kind, $slug);
|
$term = $this->posts->findTerm($kind, $slug);
|
||||||
if (null === $term) {
|
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));
|
$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 -
|
// 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.
|
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
|
||||||
if (0 === $result['total']) {
|
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'];
|
$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'])]
|
#[Route('/feed.xml', name: 'feed', methods: ['GET'])]
|
||||||
public function feed(): Response
|
public function feed(Request $request): Response
|
||||||
{
|
{
|
||||||
if (!$this->posts->anyPublished()) {
|
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);
|
$result = $this->posts->published(1, null, 20);
|
||||||
$response = $this->shell($this->render('feed.xml.twig', [
|
$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
|
// 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.
|
// sitemap would ask a search engine to fetch something it must not list.
|
||||||
foreach ($this->db->fetchAllAssociative(
|
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",
|
"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'],
|
['default', $this->settings->load()['notFoundPageId']],
|
||||||
) as $row) {
|
) as $row) {
|
||||||
$entries[] = [
|
$entries[] = [
|
||||||
'loc' => $origin.$this->settings->publicPath($row['path'], $home),
|
'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) {
|
foreach (['page' => '## Seiten', 'post' => '## Beiträge'] as $type => $heading) {
|
||||||
$rows = $this->db->fetchAllAssociative(
|
$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",
|
"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],
|
['default', $type, $settings['notFoundPageId']],
|
||||||
);
|
);
|
||||||
if ([] === $rows) {
|
if ([] === $rows) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ namespace App\Controller;
|
|||||||
use App\Content\Consent;
|
use App\Content\Consent;
|
||||||
use App\Content\DemoContent;
|
use App\Content\DemoContent;
|
||||||
use App\Content\MediaLibrary;
|
use App\Content\MediaLibrary;
|
||||||
|
use App\Content\NotFoundPage;
|
||||||
use App\Content\PageRepository;
|
use App\Content\PageRepository;
|
||||||
use App\Content\RedirectRepository;
|
use App\Content\RedirectRepository;
|
||||||
use App\Content\SiteSettings;
|
use App\Content\SiteSettings;
|
||||||
use App\Content\Theme;
|
use App\Content\Theme;
|
||||||
use App\Domain\InvalidPage;
|
use App\Domain\InvalidPage;
|
||||||
|
use App\Domain\StaleWrite;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -17,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
|||||||
|
|
||||||
final class SettingsController extends AbstractController
|
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', [
|
return $this->render('admin/settings.html.twig', [
|
||||||
'settings' => $this->settings->load(),
|
'settings' => $this->settings->load(),
|
||||||
|
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||||
'choosableHome' => $choosable,
|
'choosableHome' => $choosable,
|
||||||
'consentGroups' => Consent::GROUPS,
|
'consentGroups' => Consent::GROUPS,
|
||||||
@@ -63,6 +66,34 @@ final class SettingsController extends AbstractController
|
|||||||
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
|
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'])]
|
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||||||
public function saveRedirect(Request $request): Response
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||||
nicht mehr. #}
|
nicht mehr. #}
|
||||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
<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', 'live-search'] %}
|
{%- 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 ? '' : ',' }}
|
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
}}</script>
|
}}</script>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
{% else %}
|
{% 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">
|
<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 %}
|
{% 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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="menu-entry-actions" role="group" aria-label="{{ row.label }} verschieben oder entfernen">
|
<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-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">
|
<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>
|
<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>
|
<button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
|
||||||
</div></details>
|
</div></details>
|
||||||
<p class="field-help">Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.</p>
|
<p class="field-help">Änderungen werden mit „Menü speichern“ auf der Website sichtbar. Unterpunkte gehören zum jeweils darüberstehenden Haupteintrag.</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block title %}Einstellungen{% endblock %}
|
{% block title %}Einstellungen{% endblock %}
|
||||||
{% block heading %}Unternehmen und Adressen{% 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 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 %}
|
{% block content %}
|
||||||
|
|
||||||
{% if missingLegal is not empty %}
|
{% if missingLegal is not empty %}
|
||||||
@@ -20,6 +21,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<section class="stack-section">
|
||||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
<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">
|
<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 %}
|
{% 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>
|
</footer>
|
||||||
{% endif %}
|
{% 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) %}
|
{% 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 %}
|
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elseif type == 'button' %}
|
{% 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' %}
|
{% elseif type == 'image' %}
|
||||||
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
|
{# 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
|
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 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 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 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 %}
|
{% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %}
|
||||||
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">
|
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">
|
||||||
{%- if klick == 'vergroessern' -%}
|
{%- if klick == 'vergroessern' -%}
|
||||||
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der
|
{# 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. #}
|
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">
|
<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('') -%}
|
{%- elseif imageLinked -%}
|
||||||
<a class="image-link" href="{{ props.href }}">
|
<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 props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
{%- 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' %}
|
{% elseif type == 'iconcard' %}
|
||||||
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
||||||
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
|
{% 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 %}
|
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% elseif type == 'stat' %}
|
{% elseif type == 'stat' %}
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
<h2>{{ props.title }}</h2>
|
<h2>{{ props.title }}</h2>
|
||||||
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% elseif type == 'contact' %}
|
{% elseif type == 'contact' %}
|
||||||
<address id="{{ block.id }}" class="block-contact{{ designClasses }}">
|
<address id="{{ block.id }}" class="block-contact{{ designClasses }}">
|
||||||
@@ -160,7 +161,7 @@
|
|||||||
something carries its label as text for screen readers. #}
|
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 %}
|
{% 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('') %}
|
{% 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 %}
|
{% else %}
|
||||||
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{% if newTab|default(false) %} target="_blank" rel="noopener"{% endif -%}
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
<details class="menu-branch">
|
<details class="menu-branch">
|
||||||
<summary>{{ link.label }}</summary>
|
<summary>{{ link.label }}</summary>
|
||||||
<div class="menu-branch-body">
|
<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 %}
|
{% include 'nav.html.twig' with {links: link.children, level: (level|default(1)) + 1} only %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% 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>
|
<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) %}
|
{% 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>
|
<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) %}
|
{% 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 %}
|
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
{% set seoTitle = page.seo_title|default('') ?: page.document.title %}
|
{% set seoTitle = page.seo_title|default('') ?: page.document.title %}
|
||||||
<title>{{ seoTitle }}</title>
|
<title>{{ seoTitle }}</title>
|
||||||
<meta name="description" content="{{ page.document.description }}">
|
<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 %}
|
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
|
||||||
<meta property="og:title" content="{{ seoTitle }}">
|
<meta property="og:title" content="{{ seoTitle }}">
|
||||||
<meta property="og:description" content="{{ page.document.description }}">
|
<meta property="og:description" content="{{ page.document.description }}">
|
||||||
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
|
<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. #}
|
{# 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 %}
|
{% 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 }}">
|
<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 %}
|
{% 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>
|
</footer>
|
||||||
{% endif %}
|
{% 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() %}
|
{% set oben = siteSettings.load() %}
|
||||||
{% if oben.scrollTop|default(false) and not editing|default(false) %}
|
{% if oben.scrollTop|default(false) and not editing|default(false) %}
|
||||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #}
|
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #}
|
||||||
|
|||||||
+187
-11
@@ -10,6 +10,7 @@ use App\Content\FontLibrary;
|
|||||||
use App\Content\FormRepository;
|
use App\Content\FormRepository;
|
||||||
use App\Content\MediaLibrary;
|
use App\Content\MediaLibrary;
|
||||||
use App\Content\MenuRepository;
|
use App\Content\MenuRepository;
|
||||||
|
use App\Content\NotFoundPage;
|
||||||
use App\Content\PageRepository;
|
use App\Content\PageRepository;
|
||||||
use App\Content\PartialRepository;
|
use App\Content\PartialRepository;
|
||||||
use App\Content\PostLayouts;
|
use App\Content\PostLayouts;
|
||||||
@@ -296,6 +297,115 @@ final class CmsTest extends TestCase
|
|||||||
self::assertSame(405, $this->request('/', 'POST')->getStatusCode());
|
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
|
public function testHomeAliasRedirectsToCanonical(): void
|
||||||
{
|
{
|
||||||
$response = $this->request('/home');
|
$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'));
|
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
|
// Die hinterlegten Adressen erzeugen keine zusätzliche Leiste unter dem Footer.
|
||||||
// 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.
|
|
||||||
$datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz'));
|
$datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz'));
|
||||||
$seite = $this->pages->findForEditor($datenschutz);
|
$seite = $this->pages->findForEditor($datenschutz);
|
||||||
$this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']);
|
$this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']);
|
||||||
@@ -1022,9 +1129,10 @@ final class CmsTest extends TestCase
|
|||||||
$document['schemaVersion'] = 19;
|
$document['schemaVersion'] = 19;
|
||||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
$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();
|
$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.
|
// Mit Baustein: nur noch dieser, und zwar an seiner Stelle im Dokument.
|
||||||
$document['blocks'][] = ['id' => 'rechtszeile', 'type' => 'legal', 'props' => []];
|
$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);
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
|
||||||
$mit = $this->request('/')->getContent();
|
$mit = $this->request('/')->getContent();
|
||||||
self::assertStringContainsString('<nav id="rechtszeile" class="block-legal"', $mit);
|
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);
|
self::assertStringContainsString('Datenschutz', $mit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2569,6 +2677,69 @@ final class CmsTest extends TestCase
|
|||||||
$this->partials->delete($this->partials->findByKind('header')['id']);
|
$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}>
|
* @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 '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 '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 '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 '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'];
|
yield 'escapes plain text and keeps the line break' => ["Eins <3\nZwei", 'Eins <3<br>Zwei'];
|
||||||
}
|
}
|
||||||
@@ -3435,12 +3608,12 @@ final class CmsTest extends TestCase
|
|||||||
}
|
}
|
||||||
self::assertSame('', $this->site->load()[$key]);
|
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->publishedPage('datenschutz', 'Datenschutz');
|
||||||
$this->site->save(['homePath' => 'home', 'privacyPath' => 'datenschutz']);
|
$this->site->save(['homePath' => 'home', 'privacyPath' => 'datenschutz']);
|
||||||
self::assertSame('datenschutz', $this->site->load()['privacyPath']);
|
self::assertSame('datenschutz', $this->site->load()['privacyPath']);
|
||||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
$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
|
public function testADeliveredPageIncrementsOneCounterAndTheAdministrationDoesNot(): void
|
||||||
@@ -3475,7 +3648,10 @@ final class CmsTest extends TestCase
|
|||||||
|
|
||||||
public function testTheQuestionAppearsUntilItIsAnsweredAndCanBeChangedAgain(): void
|
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();
|
$this->askConsent();
|
||||||
|
|
||||||
$response = $this->request('/');
|
$response = $this->request('/');
|
||||||
|
|||||||
@@ -82,6 +82,39 @@ final class EditorSecurityTest extends WebTestCase
|
|||||||
return $client;
|
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
|
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
|
||||||
{
|
{
|
||||||
$client = $this->login();
|
$client = $this->login();
|
||||||
@@ -145,7 +178,7 @@ final class EditorSecurityTest extends WebTestCase
|
|||||||
public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void
|
public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void
|
||||||
{
|
{
|
||||||
$client = self::createClient();
|
$client = self::createClient();
|
||||||
foreach (['/admin', '/admin/posts', '/admin/media'] as $url) {
|
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']);
|
$client->request('GET', $url.'?q=secret', server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', 'HTTP_ACCEPT' => 'application/json']);
|
||||||
self::assertResponseRedirects('http://localhost/admin/login');
|
self::assertResponseRedirects('http://localhost/admin/login');
|
||||||
self::assertStringNotContainsString('"html":', $client->getResponse()->getContent());
|
self::assertStringNotContainsString('"html":', $client->getResponse()->getContent());
|
||||||
@@ -547,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
|
public function testTheMediaScreenUploadsAndEditsWithoutJavaScript(): void
|
||||||
{
|
{
|
||||||
$client = $this->login('editor');
|
$client = $this->login('editor');
|
||||||
@@ -669,7 +718,7 @@ final class EditorSecurityTest extends WebTestCase
|
|||||||
self::assertResponseRedirects('/admin/media?q=0');
|
self::assertResponseRedirects('/admin/media?q=0');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
|
public function testLegalPagesCanBeCreatedWithoutInjectingFooterLinks(): void
|
||||||
{
|
{
|
||||||
$client = $this->login('editor');
|
$client = $this->login('editor');
|
||||||
$crawler = $client->request('GET', '/admin/settings');
|
$crawler = $client->request('GET', '/admin/settings');
|
||||||
@@ -696,11 +745,11 @@ final class EditorSecurityTest extends WebTestCase
|
|||||||
self::assertStringContainsString('NOCH AUSZUFÜLLEN', $client->getResponse()->getContent());
|
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', '/');
|
$client->request('GET', '/');
|
||||||
$content = $client->getResponse()->getContent();
|
$content = $client->getResponse()->getContent();
|
||||||
self::assertStringContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
self::assertStringNotContainsString('<a href="/datenschutz">Datenschutz</a>', $content);
|
||||||
self::assertStringContainsString('<a href="/impressum">Impressum</a>', $content);
|
self::assertStringNotContainsString('<a href="/impressum">Impressum</a>', $content);
|
||||||
|
|
||||||
// Running it twice keeps the existing pages: they may already carry the real text.
|
// Running it twice keeps the existing pages: they may already carry the real text.
|
||||||
$before = $this->db->fetchOne('SELECT COUNT(*) FROM revision');
|
$before = $this->db->fetchOne('SELECT COUNT(*) FROM revision');
|
||||||
|
|||||||
@@ -305,6 +305,45 @@ test('free styles inherit from the wider device, reset individually and leave no
|
|||||||
assert.deepEqual(history.document, fixture());
|
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', () => {
|
test('a row arrives with its columns, inside the selected container', () => {
|
||||||
const history = new EditorHistory(fixture());
|
const history = new EditorHistory(fixture());
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user