Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
937344e642 | ||
|
|
232e6fe6dc | ||
|
|
533e181590 | ||
|
|
5c2dabdd5d | ||
|
|
32ee28888e | ||
|
|
f6350de636 | ||
|
|
d05dba3042 | ||
|
|
4d1f51801c | ||
|
|
160c394c3f | ||
|
|
a4ee04533c | ||
|
|
fb7cfa37d1 |
No files matched your search
+12
-2
@@ -25,9 +25,9 @@
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-17c7f81e0ae6/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8087 -t public bin/dev-router.php"
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-1d19ca1bb9ca/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8087
|
||||
"port": 8091
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
@@ -58,6 +58,16 @@
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantit.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; $env:SITE_ORIGIN='http://127.0.0.1:8130'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8130 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8130
|
||||
},
|
||||
{
|
||||
"name": "maler-drews",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/maler-drews.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/maler-drews-media'; $env:SITE_ORIGIN='http://127.0.0.1:8093'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8093 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8093
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,24 @@ Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.
|
||||
|
||||
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Spaltenbreiten einer Zeile durch Ziehen (ab 1.16.0)
|
||||
|
||||
Wähle eine Zeile oder eine ihrer Spalten: auf jeder Grenze zwischen zwei Spalten liegt ein Griff. Ziehen macht die eine Spalte breiter und die andere schmaler, Loslassen ist ein Schritt für „Rückgängig“; die Pfeiltasten verschieben um fünf Prozentpunkte. Der Wert steht auch im Design-Bedienfeld der Zeile unter **Layout → Spaltenbreiten**, etwa `2 1` oder `25 50 25`, und gilt je Gerät. Wo die Spalten gestapelt sind, etwa auf dem Handy, gibt es keinen Griff und die Spalten bleiben untereinander.
|
||||
|
||||
## Eigene Absenderadresse je Formular (ab 1.15.1)
|
||||
|
||||
Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absender**. Bisher ging jede Nachricht von der Empfängeradresse an sich selbst; das scheitert, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist, weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt. Leer heißt weiterhin: Absender ist der Empfänger. Die Antwortadresse aus dem Formularfeld bleibt davon unberührt.
|
||||
|
||||
## Formulare, Statistik, Design und Seitenleiste überarbeitet (ab 1.15.0)
|
||||
|
||||
**Formulare** sind ein Editor: jedes Feld eine Karte mit Nummer, Beschriftung, Typ, technischem Namen, Hilfetext und Pflicht-Schalter, dazu „Feld hinzufügen“ und ein Entfernen-Knopf je Karte. Der technische Name folgt der Beschriftung, bis du ihn selbst schreibst. Unter **Aussehen** bekommt jedes Formular eine Buttonbeschriftung sowie Rahmen- und Hintergrundfarbe der Felder, Button- und Buttonschriftfarbe – aus der Palette oder als eigener Wert, leer heißt „wie die Website“. Die Vorschau daneben folgt jeder Eingabe, zeichnet den Absendeknopf mit dem Standardbutton des Designs und nennt den Kontrast der Buttonschrift.
|
||||
|
||||
**Die Statistik** beginnt mit Kennzahlen: Aufrufe mit Vergleich zum gleich langen Zeitraum davor, Schnitt je Tag, stärkster Tag, aufgerufene Seiten, Aufrufe über Verweise, nicht gefundene Adressen. Der Verlauf hat Rasterlinien, jeder Balken nennt beim Überfahren Tag und Zahl, der stärkste Tag ist hervorgehoben; neu ist ein Wochentagsdiagramm im Schnitt. Die Ranglisten tragen Rang und Anteilsbalken.
|
||||
|
||||
**Im Design** ist die Palette eine Reihe von Farbkarten, deren Fläche der Farbwähler selbst ist. Sieben fertige Paletten (Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht) lassen sich per Klick übernehmen; eigene Farben bleiben stehen. Text-, Seiten- und Linkfarbe wählst du aus der Palette oder trägst einen eigenen Wert ein. Jede Schrift zeigt eine Probe in sich selbst, die Schnitte sind Chips, und eine Typografie-Vorschau zeigt Schrift, Größe, Zeilenhöhe, Skala, Farben und Unterstreichung live.
|
||||
|
||||
**Die Seitenleiste** endet in einer Kontokarte mit Avatar, Name, Rolle und Abmeldesymbol, darunter die Fassung mit dem Update-Hinweis. Auf Tablets bleiben Konto und Abmelden rechts im Bild, nur die Liste wischt.
|
||||
|
||||
## Fußzeile des Einwilligungshinweises (ab 1.14.2)
|
||||
|
||||
„Einzeln wählen“ steht links, Datenschutz und Impressum rechts auf einer Linie; aufgeklappt nimmt die Einzelwahl die volle Breite, die Links rutschen rechtsbündig darunter.
|
||||
@@ -311,6 +329,10 @@ In der App-Vorschau erledigt das die Startkonfiguration `elektro-bernhard` in `.
|
||||
|
||||
Der Bestand wird nicht von Hand gepflegt, sondern aufgebaut: `scripts/seed-elektro-bernhard.php <Ordner mit den Originalbildern>` legt Gestaltung, Medien, Formular, Kopf- und Fußbereich, elf Seiten, vier Menüs, Einstellungen, Weiterleitungen und ein Administratorkonto an. Das Skript weigert sich, wenn die Datenbank schon Seiten enthält oder `DATABASE_URL` nicht auf diese Installation zeigt. `sh scripts/reset-elektro-bernhard.sh <Bilderordner>` wirft die Installation weg, migriert neu und baut sie wieder auf; das Passwort des Kontos steht am Ende der Ausgabe. Vorlage ist das statische Paket `Elektro-Bernhard-Website`; die drei Stellen, an denen der Nachbau bewusst abweicht, stehen in `docs/STATUS.md`.
|
||||
|
||||
### Vierte lokale Referenzwebsite: Ihr Malerservice Drews
|
||||
|
||||
Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `var/maler-drews.db` mit Medien in `var/maler-drews-media`; die Startkonfiguration `maler-drews` in `.claude/launch.json` nutzt Port 8093. `scripts/seed-maler-drews.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, acht Seiten, 31 Beiträge in zwei Kategorien, Menüs, Formular, Einstellungen und Weiterleitungen auf; der Wortlaut der Beiträge, des Impressums und der Datenschutzerklärung steht in `scripts/seed-maler-drews-inhalte.php`. `sh scripts/reset-maler-drews.sh [Bilderordner]` (Standard `var/maler-drews-assets`, Zuordnung in `manifest.json`) wirft die Installation weg und baut sie neu auf; die Schriften Lato 400/700 und Cormorant Garamond 700 holt es nur, wenn die Cormorant-Dateien in `public/assets/fonts` fehlen, weil `cms:fonts:fetch` die geteilte `fonts.css` sonst nur für dieses Design neu schreibt (Kant-IT und Elektro Bernhard brauchen Lato 900). Für den Abruf liegt `var/cacert.pem` (curl.se) bereit, weil das PHP 8.4 auf diesem Rechner keine Stammzertifikate kennt. Die bewussten Abweichungen vom Original stehen im Kopf des Skripts.
|
||||
|
||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||
|
||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||
+13
-1
@@ -2,6 +2,18 @@
|
||||
|
||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## ADR 098 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „bei Rows die Breite ändern – am besten per Drag and Drop im Editor.“ Bis dahin gab es nur gleich breite Spalten: die klassische Vorgabe `columns` (one/two/three/four) und die freie Eigenschaft `gridColumns` erzeugten beide `repeat(n, minmax(0, 1fr))`. Ein Symbol neben einem Text – die Karte aus seinem Beispiel – brauchte eine schmale und eine breite Spalte.
|
||||
|
||||
**Entscheidung:** Eine neue freie Stileigenschaft `columnWidths` am **Zeilenblock**, Typ `fractions`: bis zu zwölf Zahlen mit Leerzeichen, `2 1` oder `66.7 33.3`, je Gerät wie jede andere freie Eigenschaft (`StyleSchema::FRACTIONS`, Prüfung in `StyleCompiler::scalar()`, null ist ausgeschlossen). Die Anteile liegen an der Zeile und nicht an den Spalten, weil eine Grenze immer zwei Spalten zugleich verändert und die Reihenfolge der Kinder die Zuordnung ist. Kein neues Dokumentschema: die Eigenschaft fällt unter die freien Stile ab v10.
|
||||
|
||||
**CSS.** `StyleCompiler::columnTracks()` schreibt je Gerät `#id{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}`. Zwei Feinheiten: Fehlende Anteile (eine später eingefügte Spalte) bekommen den Durchschnitt, überzählige fallen weg, also passt die Regel immer zur Kinderzahl. Und die Desktop-Regel trägt eine ID und würde die Klasse `.d-mobile-columns-one` schlagen, mit der ein Handy die Spalten stapelt – deshalb bekommt ein Gerät, dessen wirksame Aufteilung `one` (klassisch) oder `gridColumns` 1 ist, unter seiner Medienabfrage `minmax(0,1fr)`. Die Abfragen sind die des Stilcompilers (1024/767 px), nicht die 650 px der klassischen Klassen; zwischen 651 und 767 px stapelt eine Zeile mit Anteilen also, wo sie ohne Anteile noch zwei Spalten hätte. Das ist gewollt: Querformat-Handys sind Handys.
|
||||
|
||||
**Editor.** `canvas-overlay.js` zeichnet für die gewählte Zeile – oder die Zeile um die gewählte Spalte – einen Griff auf jede Grenze zwischen zwei nebeneinander liegenden Spalten (`.ov-resize`, ein Knopf mit Beschriftung „Breite zwischen Spalte 1 und 2 ändern“). Ausgangswert ist die **gemessene** Breite der Spalten, nicht der gespeicherte Wert, damit der Griff auch bei geerbten oder klassischen Aufteilungen dort anfängt, wo die Spalten wirklich stehen. Während des Ziehens wird die Zeile im Vorschaudokument inline umgestellt (`grid-template-columns`), erst beim Loslassen entsteht **ein** Verlaufsschritt (`onResize` → `setStyle(row, device, 'columnWidths')`); die Vorschau lädt danach vom Server neu. Pfeiltasten verschieben um fünf Punkte und behalten den Fokus auf dem Griff über die Neuzeichnung hinweg. Kein Griff, wo die Spalten gestapelt sind, und keiner während ein Element gezogen wird. Im Design-Bedienfeld steht derselbe Wert als Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen), für alle, die lieber Zahlen tippen.
|
||||
|
||||
**Verworfen:** Anteile je Spalte (`span` aus zwölf) wie in Flatsome – zwei Werte je Grenze zu pflegen und eine Summe, die 12 ergeben muss, sind für ein Drag-Ergebnis unnötig streng; Anteile summieren sich beliebig. Eine eigene Klasse je Verhältnis (`1-3`, `2-3`) – deckt genau die Fälle ab, die jemand vorhergesehen hat, und das Ziehen erzeugt jeden anderen.
|
||||
|
||||
## ADR 097 – Der Einwilligungshinweis bekommt ein Gesicht, und der Nach-oben-Knopf weicht ihm aus (15.09.2026)
|
||||
|
||||
**Anlass.** Umut gefiel das Banner nicht: nicht gestaltbar, ein kantiger Kasten in Papierfarbe. Und der Nach-oben-Knopf „funktionierte auf dem Handy nicht“ – er war ausgeblendet, solange die Frage offen stand, und auf dem Telefon bleibt sie das oft.
|
||||
@@ -414,7 +426,7 @@ Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe,
|
||||
|
||||
## ADR 017 – Formulare (Dokumentschema v8, 12.09.2026)
|
||||
|
||||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Absender (seit 1.15.1 wählbar, leer heißt Empfänger – manche Hoster stellen nur zu, was von einer Adresse der eigenen Domain kommt), Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||||
|
||||
**Kein CSRF-Token und keine Sitzung auf dem öffentlichen Weg.** Symfonys CSRF-Schutz braucht eine Sitzung, und eine Sitzung bedeutet ein Cookie auf jeder öffentlichen Seite; das vermeidet das Projekt bewusst. Die Route ist anonym und ändert nichts, was einem Besucher gehört: eine gefälschte Anfrage könnte höchstens eine Nachricht senden, und genau dagegen wirkt der Spamschutz. Die Verwaltung der Formulardefinitionen bleibt dagegen sitzungsgebunden und CSRF-geschützt.
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Ihr Malerservice Drews gemessen, 15.09.2026 (1.16.0 + zwei unveröffentlichte Korrekturen)
|
||||
|
||||
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der vierten Referenzwebsite „Ihr Malerservice Drews“ (`var/maler-drews.db`, Port 8092), sechs Adressen: `/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`. Ergebnis: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 – Startseite 99 mobil und 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, alle übrigen 100. Der Rest zur 100 ist überall das Foto hinter dem Seitenkopf als CSS-Hintergrund (LCP mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte unter `reports/lighthouse-maler-drews/`.
|
||||
|
||||
Drei Messrunden führten dorthin. Die erste zeigte Barrierefreiheit 91–93: das Gold `#c6a36b` der Vorlage erreicht auf Weiß 2,4:1 (Kleinzeilen, Datum, Links, weiße Buttonschrift auf Gold), die Beitragslisten sprangen von H1 auf H3, der Copyright-Link hob sich nur durch Farbe ab, und der Nach-oben-Knopf lag außerhalb jeder Landmarke (axe „region“, auf jeder Seite). Behoben durch `@gold-text` (`#8a6a2f`, 5,0:1) für Text auf hellem Grund, dunkle Buttonschrift (6:1), eine unsichtbare H2 vor den Listen, Unterstreichung, und im CMS ein `nav` um den Knopf. Die zweite Runde ließ noch den aktiven Menüpunkt (Gold auf Weiß) und graue Formularbeschriftungen (`#999`, 2,8:1) übrig. Für die Leistung: nur das erste Hintergrundbild einer Seite wird vorgeladen – der Slider der Startseite liegt eine Ebene tiefer, deshalb trägt der äußere Abschnitt dasselbe Foto; jede im Design genannte Schriftstärke wird vorgeladen, deshalb Lato nur 400/700 (ohne das 300 der Vorlage, 23 KB weniger vor dem größten Bild); Kacheln und Galeriebilder bekamen feste Breiten, weil ohne sie eine 270 px breite Kachel die 2400er Datei lud (108 KB Ersparnis auf der Startseite laut `image-delivery`).
|
||||
|
||||
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px, nach dem Durchscrollen und zwei Sekunden Wartezeit) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); die erste Runde hatte 58.
|
||||
|
||||
Maschinenlesbar: `llms.txt` (8 Seiten, 31 Beiträge mit Beschreibung), `robots.txt`, `sitemap.xml` (41 Adressen), `feed.xml`, `LocalBusiness` mit Adresse, Telefon, Öffnungszeiten Mo–Do/Fr, Einsatzgebiet und Instagram als `sameAs`. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, ein echtes Touchgerät, das tatsächliche Hosting.
|
||||
|
||||
## Ergänzung: Elektro Bernhard gemessen, 14.09.2026 (1.12.1)
|
||||
|
||||
Lighthouse **13** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der Referenzwebsite Elektro Bernhard (`var/elektro-bernhard.db`, Port 8090), vier Adressen: `/`, `/leistungen`, `/leistungen/notdienst`, `/kontakt`. Ergebnis nach den Korrekturen: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung überall 100 bis auf die mobile Startseite mit **99** (LCP 2,1 s durch das Hero-Foto als Hintergrund unter Mobildrosselung; CLS 0, TBT 30 ms). Berichte unter `reports/lighthouse-elektro-bernhard-2/`; der erste Lauf davor (`reports/lighthouse-elektro-bernhard/`) zeigte Barrierefreiheit 95–97.
|
||||
|
||||
+108
@@ -1,5 +1,113 @@
|
||||
# Projektstatus
|
||||
|
||||
## Einzelne Spalte in einer Sektion, Buttons ausrichten (15.09.2026)
|
||||
|
||||
**Anlass:** Umut konnte im Editor keine Spalte direkt in eine Sektion legen (ausgegraut) – so zentriert er einen Button –, und der Button „Alle Mitteilungen“ auf der Drews-Startseite stand links, ohne eine Option dafür.
|
||||
|
||||
**Editor (`editor-model.js`):** `REQUIRE.column` ist jetzt `['row', 'section']`. Eine einzelne Spalte darf direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente), in einer Spalte oder lose im Dokument weiter nicht. Test angepasst.
|
||||
|
||||
**Buttons (`design-tokens.json`, `site.css`, `design.css` neu gebaut):** Das Design-Token „Ausrichtung“ gilt jetzt auch für Buttons. Weil ein Button `inline-flex` ist, zentriert ihn `margin:auto` nicht; `site.css` macht ihn bei `align-center`/`-right` zum Block mit eigener Breite (`display:flex;width:fit-content;margin-inline:auto`, dazu `justify-self` für Raster), je Gerät an den Breakpoints von `design.css` (1024/650 px). Das Drews-Aufbauskript nutzt das Token für alle mittigen Buttons statt der wirkungslosen auto-Ränder.
|
||||
|
||||
**Geprüft:** `npm test` 33 grün (darunter der geänderte Einfügetest), PHP-Tests zu Design-Tokens 11 grün; Drews-Startseite nach Neuaufbau: „Alle Mitteilungen“ und „Alle Projekte ansehen“ mittig, Aufruf-Buttons mittig, auf 1440 und 390 px. Unveröffentlicht, gehört mit den beiden Korrekturen darunter in 1.16.1.
|
||||
|
||||
## Ihr Malerservice Drews nachgebaut, 15.09.2026
|
||||
|
||||
**Anlass:** Umut wollte maler-drews.com (Flatsome-Auftritt, Malermeister Axel Drews, Gernsbach) nach dem bekannten Prinzip in eigener Installation nachbauen – exakt im Viewer, mit Lighthouse, axe und maschinenlesbaren Ausgaben auf 100.
|
||||
|
||||
**Vorlage ausgelesen, nicht geschätzt.** Alle 9 Seiten und 31 Beiträge der laufenden Website als HTML geholt, Struktur, eigenes CSS (`malerdrews-*`, `drews-*`), Abschnittsfarben und Inline-Werte extrahiert, Screenshots in 1440 und 390 px als Vergleich. Befund: Lato + Cormorant Garamond 700, Gold `#c6a36b`, Creme `#f7f5f2`, dunkle Bänder `#232323`/`#111`/`#0a0a0a`, Kopf 90 px weiß mit Versalmenü, Startbild als Slider mit drei Folien und goldener Welle, Kartenraster aus Bild und Verlauf, Zickzackliste der Projekte, Zeitleiste der Mitteilungen. `/about-us` und `/innenaustattung` sind auf der Vorlage Lorem-ipsum-Reste, `/little-greene` ein 404.
|
||||
|
||||
**Nachbau (`scripts/seed-maler-drews.php`, `scripts/seed-maler-drews-inhalte.php`, `scripts/reset-maler-drews.sh`).** Eigene Installation `var/maler-drews.db`, `var/maler-drews-media`, Bildquellen `var/maler-drews-assets` (139 Bilder, auf 2400 px verkleinert, Zuordnung in `manifest.json`), Startkonfiguration `maler-drews` auf Port 8093. Acht Seiten (Start, Aktuelles, Unsere Projekte, Kunstmalerei, Kontakt, Impressum, Datenschutz, 404), 31 Beiträge mit Bildkopf, Text, Galerie mit Bildbetrachter, Nachbarn und Aufruf, Kategorien „Aktuelles“ (5) und „Projekte“ (26); die Listen sind Beitragslisten-Bausteine (Karten auf der Startseite, Liste als Zeitleiste, Wechselseitig als Zickzack). Kopf mit Marke und Versalmenü, Fuß in vier Spalten mit Instagram, Öffnungszeiten und Rechtszeile. Formular serverseitig, Google-Karte und elf Videos als eingebettete Inhalte hinter der Einwilligungsleiste. Impressum und Datenschutz wörtlich übernommen (die Datenschutzerklärung nennt Google Analytics und reCAPTCHA, die der Nachbau nicht nutzt – vor dem Livegang zu bereinigen). Weiterleitungen für die drei toten Vorlagenadressen und die eine über 80 Zeichen lange Beitragsadresse. Bewusste Abweichungen stehen im Kopf des Skripts.
|
||||
|
||||
**Für 100 geändert, nicht für die Optik:** Gold auf Weiß erreicht 2,4:1 – Kleinzeilen, Datum, Links und aktive Menüpunkte auf hellem Grund tragen `@gold-text` (`#8a6a2f`, 5,0:1), Buttons dunkle statt weiße Schrift auf Gold (6:1), der Einwilligungsknopf ebenso; vor den Beitragslisten steht eine nur für Vorlesesoftware sichtbare H2; der Copyright-Link ist unterstrichen. Das Startbild wird über den äußeren Abschnitt vorgeladen (nur das erste Hintergrundbild einer Seite bekommt ein `preload`).
|
||||
|
||||
**Zwei Lücken im CMS behoben (`site.css`, `page.html.twig`, unveröffentlicht, gehört in 1.16.1):** Abschnitte mit Breite „breit“ oder „voll“ blieben so schmal wie alle anderen, weil `.kant .shell{max-width:var(--k-container)}` des Designs die Breitenregeln schlug – jetzt mit `.kant` davor. Der Nach-oben-Knopf stand außerhalb jeder Landmarke (axe „region“) und steckt jetzt in einem `nav`. Beim Bauen gelernt: eigenes CSS erlaubt kein `@media`, keine Anführungszeichen, keine Klammern in Selektoren (`:not()`, `nth-child()`), keine Kommaselektoren, und eine leere `&{…}`-Regel geht verloren – Eigenschaften des Elements ohne `&{}` schreiben.
|
||||
|
||||
**Geprüft:** Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus (`APP_ENV=prod`, Port 8092) auf sechs Adressen (`/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`): **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 (Startseite 99 mobil / 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, sonst 100; LCP ist jeweils das Foto hinter dem Seitenkopf, mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte in `reports/lighthouse-maler-drews/`. Drei Messrunden davor zeigten Barrierefreiheit 91–93 (Gold-Kontrast, Überschriftenfolge, Link nur durch Farbe), dann 96–100 (aktiver Menüpunkt, Formularbeschriftungen), jetzt 100. axe (WCAG 2.0/2.1 A+AA und Best Practice, Playwright, 1440 und 390 px, nach dem Durchscrollen) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); vorher 58, davon 36 Kontrast, 18 „region“ am Nach-oben-Knopf, 2 Überschriftenfolge, 2 Link im Text. Maschinenlesbar: `llms.txt` mit allen Seiten und Beiträgen, `sitemap.xml` mit 41 Adressen, `robots.txt`, `feed.xml`, `LocalBusiness` mit Adresse, Öffnungszeiten, Einsatzgebiet und Instagram als `sameAs`. Alle 41 Adressen der Vorlage antworten mit 200 oder 301. Screenshots des Nachbaus in 1440 und 390 px gegen die der Vorlage verglichen (Startseite, Aktuelles, Projekte, Kunstmalerei, Kontakt, Beitrag, Impressum). PHP-Tests 512 grün (PHP 8.4), `npm test` 33 grün, Twig-Lint, php-cs-fixer auf den neuen Skripten.
|
||||
|
||||
**Nicht geprüft:** der Editor auf den Drews-Dokumenten im Browser (kein Anmelden mit Passwort in dieser Sitzung), Firefox/Safari, ein echtes Touchgerät, das Hosting. Die Videos liegen weiter auf maler-drews.com und brauchen für den Livegang einen eigenen Speicherort.
|
||||
|
||||
## 1.16.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.16.0` (Commit `32ee288`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.0 – Spaltenbreiten einer Zeile durch Ziehen im Editor.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.0 v1.16.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.16.0.zip` (4,7 MB, SHA-256 `8aa8d40d79dfe7bf972ca005ed92d688cf63beb93a4807c20d5dcabb9b2f9264`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.0.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.15.1 → 1.16.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.0`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann im Editor eine Zeile wählen und die Spaltengrenze ziehen – etwa bei einer Karte mit Symbol links und Text rechts.
|
||||
|
||||
## 1.16.0 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut, mit der Karte „HausKlar“ (Symbol links, Text rechts) als Beispiel: „bei Rows die Breite ändern, am besten per Drag and Drop im Editor.“ Zeilen kannten bisher nur gleich breite Spalten. Entscheidung und Begründung: ADR 098.
|
||||
|
||||
**Umsetzung:** Neue freie Stileigenschaft `columnWidths` an der Zeile (Typ `fractions`, `StyleSchema::FRACTIONS`, z. B. `66.7 33.3`), je Gerät, ohne neues Dokumentschema. `StyleCompiler::columnTracks()` macht daraus `grid-template-columns:minmax(0,66.7fr) minmax(0,33.3fr)`, füllt fehlende Anteile mit dem Durchschnitt auf, kappt überzählige und setzt ein Gerät, dessen Aufteilung stapelt (`one` oder `gridColumns` 1), auf eine Spur – sonst schlüge die ID-Regel des Desktops die Stapel-Klasse des Handys. Im Editor (`canvas-overlay.js`) liegt auf jeder Grenze zwischen zwei nebeneinander stehenden Spalten der gewählten Zeile (oder der Zeile um die gewählte Spalte) ein Griff `.ov-resize`: Ziehen stellt die Zeile in der Vorschau live um, Loslassen schreibt einen Verlaufsschritt (`onResize` in `editor.js` → `setStyle`), Pfeiltasten verschieben um fünf Punkte und behalten den Fokus. Ausgangswert sind die gemessenen Spaltenbreiten. Im Design-Bedienfeld gibt es dazu das Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen). Kein Griff bei gestapelten Spalten und während ein Element gezogen wird.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 512 von 512 grün, mit PHP 8.2 512 grün mit der vorbestehenden Warnung in `UpdaterTest`; neu `tests/ColumnWidthsTest.php` (Spuren, Stapeln je Gerät, Auffüllen und Kappen, Ablehnung ungültiger Werte). `npm test` 33 grün (Relevanz nur für Zeilen). `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden – neu darin: echter Mauszug am Griff verbreitert die erste Spalte, der Wert steht im Bedienfeld der Zeile, auf „Mobil“ gibt es keinen Griff, ein Rückgängig stellt gleiche Spalten her (`reports/editor/column-widths.png`). php-cs-fixer ohne Befund. Ein erster Lauf des Checks scheiterte an `openGroup`, das im Skript erst später definiert ist – die Gruppe wird jetzt an Ort und Stelle geöffnet.
|
||||
|
||||
**Nebenbei:** `.claude/launch.json` trägt in der Arbeitskopie den noch nicht committeten Eintrag `maler-drews` einer anderen Sitzung auf Port 8091; die Pilotkonfiguration ist deshalb lokal auf 8092 verschoben, die Datei bleibt aus diesem Commit heraus.
|
||||
|
||||
## 1.15.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.15.1` (Commit `d05dba3`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.1 – eigene Absenderadresse je Formular.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.1 v1.15.1`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.15.1.zip` (4,7 MB, SHA-256 `e15a7a69f299c16d86d372b65610b52f15b40b93904fa6402ea6991de7ed21f7`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.15.0 → 1.15.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.1`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare eine Absenderadresse der eigenen Domain eintragen und eine Testanfrage senden.
|
||||
|
||||
## 1.15.1 – Eigene Absenderadresse je Formular (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Versandadresse bei Kontaktformularen sollte man noch festlegen können – aktuell wird's über die gleiche Adresse verschickt wie der Empfänger?" Stimmte: `FormController::deliver()` setzte `From` auf die Empfängeradresse. Das geht schief, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist (z. B. eine GMX- oder Gmail-Adresse), weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt, und SPF/DMARC des fremden Anbieters die Post als Fälschung abweisen.
|
||||
|
||||
**Umsetzung:** Neue Formulareinstellung `sender` (freiwillig). `FormRepository::validateSettings()` prüft sie wie den Empfänger mit `FILTER_VALIDATE_EMAIL`, leer bleibt erlaubt; die Verwaltung (`forms.html.twig`, Abschnitt Zustellung, Feld „Absender“ direkt unter dem Empfänger mit Hinweis) und `FormAdminController` reichen sie durch. Beim Versand gilt `sender ?: recipient` – bestehende Formulare ohne den Schlüssel verhalten sich wie bisher, es ist keine Migration nötig. Die Antwortadresse aus dem Formularfeld bleibt `Reply-To`.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 509 von 509 grün, mit PHP 8.2 509 grün mit der vorbestehenden Warnung in `UpdaterTest`; `npm test` 33 grün; `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden, darin die axe-Prüfung der Formularseite. Neu in `EditorSecurityTest`: `testAFormMaySendFromItsOwnAddress` (From = Absender, To = Empfänger, Reply-To = Feld; ungültiger Absender abgewiesen, Leerzeichen werden zu leer) und im bestehenden Absendetest die Prüfung, dass ohne Absender From = To ist – beides über `getMailerMessage()` mit dem `null://`-Transport der Tests. php-cs-fixer ohne Befund. In der Pilotkopie im Browser (`kantsites-pilot`, jetzt Port 8091, weil 8087 von einer anderen Sitzung belegt war): Feld sichtbar, `formular@kant-it.de` gespeichert und nach dem Neuladen erhalten, Meldung „Das Formular wurde gespeichert.“ Ein echter Versand über SMTP wurde nicht geprüft.
|
||||
|
||||
**Nächster Schritt:** Auf `web.kant-it.de` nach dem nächsten Update unter Formulare eine Absenderadresse der eigenen Domain eintragen (z. B. `formular@kant-it.de`), dann eine Testanfrage senden und im Postfach Absender und Zustellung kontrollieren.
|
||||
|
||||
## 1.15.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.15.0` (Commit `160c394`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.0 – Formulare, Statistik, Design und Seitenleiste der Verwaltung überarbeitet, Formulare mit eigenen Farben und Vorschau.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.0 v1.15.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.15.0.zip` (4,7 MB, SHA-256 `86ef728812db3b2f565f07dd1d8aebf8d5d41b75875695544134684b77a193ed`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.0.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.14.2 → 1.15.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus, weil der saubere Checkout weder `vendor/` noch `var/kantit.db` hat); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.0`, Installeradresse 200. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare das Aussehen des Kontaktformulars wählen.
|
||||
|
||||
## 1.15.0 – Formulare, Statistik, Design und Seitenleiste überarbeitet (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand die Formularanlage unschön und wollte Felder löschen können, die Statistik „lieblos“, auf der Designseite vordefinierte Farben, eigene Farben für Link- und Seitenfarbe und eine Vorschau der Schriften, und den Fuß der Seitenleiste (Fassung, Benutzer, Abmelden) besser. Nachgeschoben: eine Vorschau des Kontaktformulars, in der Rahmen-, Hintergrund- und Buttonfarbe direkt gewählt werden.
|
||||
|
||||
**Formulare (`forms.html.twig`, neu `form-editor.js`).** Jedes Feld ist eine Karte mit Nummer, Beschriftung, Typ, technischem Name, Hilfetext, Pflicht-Schalter und einem Entfernen-Knopf; „Feld hinzufügen“ klont eine `<template>`-Karte. Der technische Name folgt der Beschriftung (`Größe der Fläche` → `groesse_der_flaeche`), bis jemand ihn selbst schreibt; Optionen erscheinen nur bei einer Auswahl; eine Einwilligung ist sichtbar immer Pflicht. Ohne Skript bleibt alles bedienbar: eine Karte ohne Beschriftung wird nicht gespeichert. Neu ist der Abschnitt **Aussehen**: Buttonbeschriftung, Rahmen und Hintergrund der Felder, Button- und Buttonschriftfarbe, jeweils aus der Palette oder als eigener Wert (`FormRepository::COLORS`, geprüft mit `StyleSchema::COLOR`). Daneben eine **Vorschau**, die jeder Eingabe folgt und den Absendeknopf mit dem Standardbutton der Website zeichnet (`buttonSample`). Auf der Website schreibt `FormRepository::css()` je Formular eine Regel `.block-form[data-form="…"]{…}` in den gehashten Stylesblock (`PageController::renderPage`); Felder lesen `--form-border`/`--form-bg` (`site.css`), der Button bekommt `--kb-fill`/`--kb-ink` **und** die Darstellungsvariablen neu – am Wurzelelement wären sie schon mit den alten Werten aufgelöst. Das „Neues Formular“ ist ein zugeklapptes Karten-Details am Ende.
|
||||
|
||||
**Statistik (`statistics.html.twig`, `Statistics::overview()`).** Sechs Kennzahlkacheln: Aufrufe mit Vergleich zum gleich langen Zeitraum davor (`previousTotal`), Schnitt je Tag, stärkster Tag mit Datum und Wochentag (`busiestDay`), aufgerufene Seiten (`pageCount`), Aufrufe über Verweise (`referred`) und nicht gefundene Adressen (`missingTotal`). Der Verlauf hat Rasterlinien, jeder Balken einen `<title>` mit Tag und Zahl, der stärkste Tag ist hervorgehoben; dazu ein neues Wochentagsdiagramm im Schnitt (`weekdays`). Die Ranglisten tragen Rang und einen Anteilsbalken (SVG-Breite, kein Inline-Stil). Der Zeitraum ist eine Leiste mit gedrücktem Knopf in der Kopfzeile; „Was diese Zahlen nicht sagen“ ist zugeklappt.
|
||||
|
||||
**Design (`theme.html.twig`, neu `theme-panel.js`).** Die Palette sind Farbkarten, deren Fläche der Farbwähler selbst ist, mit Hexwert, Name, Schlüssel und Entfernen. Sieben fertige Paletten (`Theme::PALETTES`: Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht; die Linkfarbe erreicht auf dem Papierton mindestens 4,5:1) ersetzen die fünf Grundschlüssel und lassen eigene Farben stehen. Schriften sind Karten mit einer Probe in der Schrift selbst (lokale `fonts.css` wird geladen, wenn vorhanden – `FontLibrary::hasStylesheet()`), Schnitte als Chips, die Auswahlen für Fließtext/Überschriften/Button folgen neuen Schriften. Text-, Seiten- und Linkfarbe nutzen das Palettenbedienelement des Editors (`createColorControl`), das bei jeder Palettenänderung neu aufgebaut wird; daneben eine Typografie-Vorschau mit Überschriftenskala, Zeilenhöhe, Farben und Unterstreichung.
|
||||
|
||||
**Seitenleiste (`shell.html.twig`).** Unten eine Kontokarte: Avatar, Name, Rolle (Administrator/Veröffentlicher/Redakteur), rechts ein Abmeldesymbol mit Beschriftung für Vorlesesoftware; darunter „KantSites 1.14.2“ mit dem Update-Hinweis in einer Zeile. Neue Symbole `logout`, `plus`, `remove`. Auf Tablets wischt nur noch die Liste, Konto und Abmelden bleiben rechts im Bild; dafür braucht die Rasterspalte `minmax(0, 1fr)`, sonst schob die Leiste die Seite über den Rand.
|
||||
|
||||
Die Formularvorschau nennt den Kontrast der Buttonschrift auf der Buttonfarbe (WCAG-Formel, grün ab 4,5:1, gelb ab 3:1, sonst rot), damit eine unlesbare Wahl auffällt, bevor sie auf der Website steht. Die `pattern`-Attribute der Schlüsselfelder maskieren den Bindestrich (`[a-z][a-z0-9\-]*`): Chrome prüft `pattern` inzwischen mit dem `v`-Flag und lehnte das alte Muster mit einem Konsolenfehler ab – die Prüfung war damit wirkungslos.
|
||||
|
||||
**Geprüft:** `phpunit` mit PHP 8.4 508 von 508 grün, mit PHP 8.2 508 grün mit einer vorbestehenden Warnung in `UpdaterTest` (Git-Umgebung); neu getestet: Formularfarben auf der Seite samt CSP-Hash, Ablehnung ungültiger Farben, Statistiksummen und Vorzeitraum, drei Diagramme mit Titeln. `npm run test:editor` vollständig bestanden – darin die axe-Prüfung von Formularen, Statistik und Design. `test:buttons` besteht den Designseiten-Teil (Vorlage wählen, speichern, Werte erhalten) und scheitert danach wie `test:navigation-ux` an einem Klick auf den Strukturbaum des Editors (`#structure [data-block-id=…]` „not visible“) – eine Stelle außerhalb dieser Änderung, nicht weiter untersucht. `test:admin` scheitert vor den Formularen an zwei vorbestehenden Befunden: dem Wiedergabeknopf des öffentlichen Sliders bei reduzierter Bewegung und `nested-interactive` in den Menü-Zusammenfassungen (Ziehgriff im `summary`). In der Pilotkopie im Browser: Feld hinzufügen/entfernen, Namensableitung, Farbwahl mit Vorschau und Kontrasthinweis, Speichern, Regel und Buttonfarbe auf der öffentlichen Startseite; Palette „Ozean“ übernehmen, Schrift hinzufügen, Linkfarbe wählen; Seitenleiste und Bildschirme auf 1440, 900 und 375 px. Codestil mit php-cs-fixer (verbleibende Abweichung in `tests/CmsTest.php` ist vorbestehend). Veröffentlicht als 1.15.0, siehe Eintrag oben.
|
||||
|
||||
## 1.14.2 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.2` (Commit `0aa1ff6`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.2 – Fußzeile des Einwilligungshinweises: Einzelwahl links, Rechtslinks rechts.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.14.2 v1.14.2`), nicht aus der Arbeitskopie: dort lagen zu diesem Zeitpunkt nicht committete Änderungen einer anderen Sitzung (`site.css`, `archive.html.twig`, `page.html.twig`, `tests/CmsTest.php`, neu `src/Twig/HeaderExtension.php`), und ein erster Build aus der Arbeitskopie hatte sie mitgenommen – der wurde verworfen. Das Archiv enthält keine vorgefertigten `assets/build/`-Bündel mehr (die Arbeitskopie hatte welche, sie entstehen beim ersten Aufruf); daher 3546 statt 3552 Dateien.
|
||||
|
||||
**Anhänge:** `kantsites-1.14.2.zip` (4,7 MB, SHA-256 `236dff086267591a51198b55ddfbaf2ee999db8a081ec5bb1d28752260a3db3f`), `.sha256`, `install.php`; hochgeladen mit `var/publish-release-1.14.2.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** PHP 506 Tests grün (ein erster Lauf zeigte einen Fehler, während die andere Sitzung `tests/CmsTest.php` bearbeitete; zwei weitere Läufe grün); HTTP-Archiv-Update 1.14.1 → 1.14.2 `ok`; nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.14.2`, Installeradresse 200.
|
||||
|
||||
## 1.14.2 – Einwilligungshinweis: Fußzeile aufgeräumt (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand „Einzeln wählen“ und die Rechtslinks unordentlich und schlug links/rechts vor.
|
||||
|
||||
+223
-63
@@ -147,31 +147,61 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
|
||||
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
|
||||
|
||||
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
|
||||
.shell-user { display: flex; align-items: center; gap: 10px; padding: 8px 10px; font-size: 12px }
|
||||
.shell-user strong { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
/* Der Fuss der Leiste: Kontokarte, Abmeldesymbol, Fassung. Eine Einheit statt dreier Zeilen. */
|
||||
.shell-foot { margin-top: auto; padding-top: 14px; border-top: 1px solid #2a3d35; display: grid; gap: 8px }
|
||||
.shell-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
border-radius: 13px;
|
||||
background: #ffffff0b;
|
||||
border: 1px solid #ffffff10;
|
||||
}
|
||||
.shell-user {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 9px;
|
||||
text-decoration: none;
|
||||
color: var(--shell-ink);
|
||||
}
|
||||
.shell-user:hover, .shell-user[aria-current=page] { background: #ffffff12 }
|
||||
.shell-user[aria-current=page] .shell-avatar { box-shadow: 0 0 0 2px var(--mint) }
|
||||
.shell-user-text { display: grid; min-width: 0; line-height: 1.3 }
|
||||
.shell-user-text strong { font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.shell-user-text span { font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px; height: 28px;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #2b4a3b;
|
||||
color: var(--mint);
|
||||
font-size: 12px;
|
||||
background: var(--mint);
|
||||
color: var(--shell);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shell-foot form { margin: 0 }
|
||||
.shell-foot button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
.shell-logout {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px; height: 38px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--shell-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.shell-foot button:hover { background: #21362d; color: var(--shell-ink) }
|
||||
.shell-logout .nav-icon { color: inherit }
|
||||
.shell-logout:hover { background: #ffffff14; color: #fff }
|
||||
.shell-logout:focus-visible { outline-color: var(--mint); outline-offset: -2px }
|
||||
.shell-version { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; padding: 0 8px 2px; font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-version a { text-decoration: none }
|
||||
.shell-version > a:hover { color: var(--shell-ink); text-decoration: underline }
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
@@ -457,7 +487,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.table .check-cell input { width: 18px; min-height: 18px }
|
||||
.table-new input, .table-new select { background: #fafbf8 }
|
||||
.table form { margin: 0 }
|
||||
/* Column widths per screen: the two tables carry different things. */
|
||||
/* Column widths per screen: the menu table carries text, links and switches. */
|
||||
.menu-table td:first-child input { width: 82px }
|
||||
.menu-table td:nth-child(2) select { width: 148px }
|
||||
.menu-table td:nth-child(3) input { min-width: 170px }
|
||||
@@ -465,15 +495,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.menu-table td:nth-child(5) select { min-width: 190px }
|
||||
.menu-table td:nth-child(6) input { width: 130px }
|
||||
.menu-table td:nth-child(7) input { min-width: 190px }
|
||||
.field-table td:first-child input { width: 140px }
|
||||
.field-table td:nth-child(2) select { width: 150px }
|
||||
.field-table td:nth-child(3) input { min-width: 170px }
|
||||
.field-table td:nth-child(5) input { min-width: 180px }
|
||||
.field-table td:nth-child(6) input { min-width: 170px }
|
||||
.theme-table td input { width: 190px }
|
||||
.theme-table td select { width: 150px }
|
||||
.theme-table td input[type=color] { width: 52px }
|
||||
.theme-table .weights-cell { white-space: nowrap }
|
||||
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
|
||||
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
|
||||
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
|
||||
@@ -485,12 +506,12 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.card-body .section-head:first-child { margin-top: 14px }
|
||||
.card-body > .primary, .card-body > .secondary { margin-top: 24px }
|
||||
|
||||
/* A screen made of several equal sections (menu slots, form definitions). */
|
||||
/* A screen made of several equal sections (menu slots). */
|
||||
.stack-section { display: grid; gap: 10px }
|
||||
.menu-form > label:first-of-type, .form-def > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > input[name=name] { max-width: 420px }
|
||||
.menu-form .table, .form-def .table { margin-top: 22px }
|
||||
.menu-form .primary, .form-def .primary { margin-top: 20px }
|
||||
.menu-form .table { margin-top: 22px }
|
||||
.menu-form .primary { margin-top: 20px }
|
||||
|
||||
.empty {
|
||||
padding: 34px;
|
||||
@@ -508,10 +529,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.contrast-ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.contrast-warn { background: #fff6e2; color: #6f4e18; border: 1px solid #e6cf9c }
|
||||
.contrast-bad { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.theme-form .theme-swatch { width: 52px; min-height: 38px; padding: 2px; cursor: pointer }
|
||||
.theme-form .weights-cell { white-space: nowrap }
|
||||
.theme-form .weights-cell label { display: inline-flex; align-items: center; gap: 4px; margin: 0 9px 0 0; font-size: 11px; font-weight: 400 }
|
||||
.theme-form .weights-cell input { width: 14px; min-height: 14px }
|
||||
|
||||
/* ---- submissions ----------------------------------------------------------------------- */
|
||||
|
||||
@@ -547,25 +564,33 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
/* ---- narrow screens: the sidebar becomes a strip above the content --------------------- */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.admin-shell { grid-template-columns: 1fr }
|
||||
/* minmax(0, 1fr), nicht 1fr: sonst nimmt die Spalte die Mindestbreite der Leiste an und
|
||||
schiebt die ganze Seite ueber den rechten Rand hinaus. */
|
||||
.admin-shell { grid-template-columns: minmax(0, 1fr) }
|
||||
.shell-nav {
|
||||
position: static;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow: visible;
|
||||
gap: 6px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
/* Nur die Liste wischt; Konto und Abmelden bleiben rechts im Bild. */
|
||||
.shell-menu { flex: 1 1 auto; min-width: 0; overflow-x: auto }
|
||||
.shell-foot { flex: none }
|
||||
.shell-nav .brand { margin: 0 10px 0 0; font-size: 0 }
|
||||
.shell-nav .brand-icon { margin: 0; font-size: 15px }
|
||||
.nav-label { display: none }
|
||||
.nav-list { display: flex; gap: 4px }
|
||||
.nav-item { white-space: nowrap; padding: 8px 12px }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 14px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center }
|
||||
.shell-user strong { display: none }
|
||||
.shell-foot button { width: auto }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
|
||||
.shell-account { padding: 3px; gap: 2px }
|
||||
.shell-user { padding: 3px }
|
||||
.shell-user-text { display: none }
|
||||
.shell-version { padding: 0 }
|
||||
.shell-top { padding: 22px 20px 18px }
|
||||
.shell-body { padding: 20px 20px 48px }
|
||||
}
|
||||
@@ -598,32 +623,61 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-set > legend { font-size: 12px; font-weight: 700; padding: 0 6px }
|
||||
.field-set .check-label { margin-top: 14px }
|
||||
|
||||
/* ---- statistics: charts drawn by the server, styled by class ---------------------------- */
|
||||
/* ---- statistics: tiles, charts drawn by the server, ranked lists ------------------------ */
|
||||
|
||||
.range-switch { display: flex; gap: 8px; flex-wrap: wrap }
|
||||
.range-switch a { font-size: 12px; padding: 8px 14px; min-height: 36px }
|
||||
.chart { display: block; width: 100%; height: 180px; overflow: visible }
|
||||
/* Der Zeitraum als eine Leiste mit einem gedrueckten Knopf, nicht vier lose Schaltflaechen. */
|
||||
.range-switch { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper) }
|
||||
.range-switch a { display: inline-flex; align-items: center; min-height: 32px; padding: 6px 13px; border-radius: 7px; font-size: 12px; font-weight: 600; text-decoration: none; color: var(--muted) }
|
||||
.range-switch a:hover { color: var(--ink); background: #eef2ea }
|
||||
.range-switch a[aria-current=page] { background: var(--shell); color: #fff }
|
||||
/* Die Kennzahlen: eine grosse dunkle Kachel fuer die Summe, kleine helle fuer alles, was sie einordnet. */
|
||||
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); gap: 12px }
|
||||
.stat-tile { display: grid; align-content: start; gap: 6px; min-width: 0; padding: 16px 18px; background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
.stat-tile-main { grid-column: span 2; background: var(--shell); border-color: var(--shell); color: var(--shell-ink) }
|
||||
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted) }
|
||||
.stat-value { font-size: 28px; font-weight: 650; letter-spacing: -.8px; line-height: 1.1; font-variant-numeric: tabular-nums }
|
||||
.stat-note { font-size: 12px; line-height: 1.45; color: var(--muted); overflow-wrap: anywhere }
|
||||
.stat-tile-main .stat-label, .stat-tile-main .stat-note { color: var(--shell-muted) }
|
||||
.stat-tile-main .stat-value { font-size: 40px; color: #fff }
|
||||
.stat-tile-main .stat-up { color: #bfe3a5 }
|
||||
.stat-tile-main .stat-down { color: #f3b9a8 }
|
||||
.chart { display: block; width: 100%; height: 190px; overflow: visible }
|
||||
.chart-hours, .chart-weekdays { height: 150px }
|
||||
.chart-bar { fill: var(--accent) }
|
||||
.chart-bar:hover { fill: #1c4735 }
|
||||
.chart-axis { stroke: var(--line); stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-bar-peak { fill: var(--ring) }
|
||||
.chart-grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 3 3 }
|
||||
.chart-axis { stroke: #c3cec7; stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; gap: 8px; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-legend-even span { flex: 1; text-align: center }
|
||||
.chart-table { margin-top: 18px }
|
||||
.chart-table > summary { cursor: pointer; font-size: 12px; font-weight: 600; padding: 6px 0 }
|
||||
.table-wrap { max-height: 320px; overflow: auto; margin-top: 10px }
|
||||
.stats-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px }
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px; counter-reset: rank }
|
||||
.stats-columns-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)) }
|
||||
/* Rang, Name, Anteil am Spitzenwert als Balken, Zahl. */
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px }
|
||||
.stats-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) 72px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-top: 1px solid #eef1ed;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stats-list li:first-child { border-top: 0 }
|
||||
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
|
||||
.stats-rank { font-size: 11px; font-weight: 700; color: var(--muted); text-align: center }
|
||||
.stats-list span:not(.stats-rank), .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-share { display: block; width: 100%; height: 6px; overflow: visible }
|
||||
.stats-share-bar { fill: var(--accent); opacity: .5 }
|
||||
.stats-list li:first-child .stats-share-bar { opacity: 1 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right }
|
||||
@media (max-width: 620px) {
|
||||
.stat-tile-main { grid-column: 1 / -1 }
|
||||
.stats-list li { grid-template-columns: 22px minmax(0, 1fr) auto }
|
||||
.stats-share { display: none }
|
||||
}
|
||||
|
||||
/* ---- system: preflight, update steps, log ---------------------------------------------- */
|
||||
|
||||
@@ -647,7 +701,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.check-bad { color: var(--danger) }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
|
||||
|
||||
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||
|
||||
@@ -743,9 +796,8 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
|
||||
.shell-version { display: none }
|
||||
.shell-user strong { display: none }
|
||||
}
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
@@ -768,14 +820,122 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
|
||||
|
||||
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
|
||||
.form-def{min-width:0}.field-table{table-layout:fixed;width:100%}.field-table input:not([type=checkbox]),.field-table select{width:100%;min-width:0;max-width:100%}
|
||||
@media(max-width:1100px){
|
||||
.field-table,.field-table tbody{display:block}.field-table thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)}
|
||||
.field-table tbody{display:grid;gap:16px}.field-table tr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px}
|
||||
.field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left}
|
||||
}
|
||||
@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}}
|
||||
.form-def{min-width:0;margin:0}
|
||||
|
||||
.version-update{display:block;width:fit-content;margin-top:8px;padding:5px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
|
||||
.version-update{display:inline-block;padding:4px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
|
||||
|
||||
.menu-entry summary::after{content:none}.menu-entry summary{list-style:none;gap:10px;padding:10px 14px}.menu-entry summary::-webkit-details-marker{display:none}.menu-drag-handle{display:grid;place-items:center;width:36px;min-width:36px;height:40px;padding:0;border:0;background:transparent;color:#576e60;cursor:grab;touch-action:none}.menu-drag-handle:active{cursor:grabbing}.menu-chevron{flex:none;color:#52665a;transition:transform .15s}.menu-entry details[open] .menu-chevron{transform:rotate(180deg)}.menu-entry-kind{font-size:11px;border:1px solid #dae4dc;border-radius:5px;padding:3px 6px;color:#506659;background:#f7f9f6;white-space:nowrap}.menu-entry-title{display:flex;align-items:center;gap:8px;min-width:0}.menu-entry-title svg{width:19px;height:19px;flex:none}.menu-dragging{opacity:.6;box-shadow:0 4px 16px #203b3426;border-color:#2c6b4f}.menu-icon-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}@media(max-width:560px){.menu-entry summary{padding:8px}.menu-entry-kind{display:none}.menu-icon-fields{grid-template-columns:1fr}}
|
||||
|
||||
/* ---- the form editor: a card per field, the look, and a preview beside it -------------- */
|
||||
|
||||
.form-screens { display: grid; gap: 20px }
|
||||
.form-editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 20px 36px; padding-top: 2px }
|
||||
.form-editor-main { min-width: 0 }
|
||||
.form-part { margin: 26px 0 0; font-size: 11px; font-weight: 700; letter-spacing: .9px; text-transform: uppercase; color: var(--muted) }
|
||||
.form-editor-main > .form-part:first-child, .form-editor-preview .form-part, .type-preview-column .form-part { margin-top: 14px }
|
||||
.form-fields-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 28px }
|
||||
.form-fields-head .form-part { margin: 0 }
|
||||
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
|
||||
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
|
||||
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
|
||||
.field-card { position: relative; padding: 2px 16px 16px; border: 1px solid var(--line); border-radius: 11px; background: #fafbf8 }
|
||||
.field-card-new { border-style: dashed; background: var(--paper) }
|
||||
.field-card-head { display: grid; grid-template-columns: 26px minmax(0, 1fr) 190px 38px; gap: 0 12px; align-items: end }
|
||||
.field-card-index { display: grid; place-items: center; width: 26px; height: 26px; margin-bottom: 8px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card label { margin-top: 12px }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 38px }
|
||||
.field-card-body .field-help { margin-top: 5px }
|
||||
.field-card-flags { align-self: end; padding-bottom: 11px }
|
||||
.field-card-flags .check-label { margin: 12px 0 0 }
|
||||
.field-remove { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 2px; padding: 0; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted) }
|
||||
.field-remove .nav-icon { width: 17px; height: 17px; color: inherit }
|
||||
.field-remove:hover { background: #fff0eb; border-color: #e2b2a7; color: var(--danger) }
|
||||
/* Die Vorschau bleibt beim Blaettern durch lange Feldlisten im Bild. */
|
||||
.form-editor-preview { min-width: 0; position: sticky; top: 16px; align-self: start }
|
||||
/* Die Vorschau uebernimmt Text- und Seitenfarbe der Website per Skript; die Felder lesen dieselben
|
||||
Variablen wie auf der Website selbst. */
|
||||
.form-preview { display: grid; gap: 16px; margin-top: 12px; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; font-size: 14px; line-height: 1.5 }
|
||||
.form-preview-field { display: grid; gap: 6px }
|
||||
.form-preview-label { font-weight: 600 }
|
||||
.form-preview-input { display: block; min-height: 40px; padding: 9px 12px; border: 1px solid var(--form-border, currentColor); border-radius: 8px; background: var(--form-bg, #fff); color: inherit }
|
||||
.form-preview-select { display: flex; align-items: center; justify-content: space-between }
|
||||
.form-preview-select::after { content: '⌄'; font-size: 16px; line-height: 1 }
|
||||
.form-preview-area { min-height: 92px }
|
||||
.form-preview-check { display: flex; align-items: flex-start; gap: 9px }
|
||||
.form-preview-box { flex: none; width: 18px; height: 18px; margin-top: 2px; border: 1px solid var(--form-border, currentColor); border-radius: 4px; background: var(--form-bg, #fff) }
|
||||
.form-preview-help { font-size: 12px }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
.form-editor-foot { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.form-editor-foot .muted { font-size: 12px }
|
||||
.form-look .color-control { margin-top: 0 }
|
||||
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
|
||||
@media (max-width: 700px) {
|
||||
.field-card { padding-top: 8px }
|
||||
.field-card-head { grid-template-columns: 26px minmax(0, 1fr) }
|
||||
.field-card-title, .field-card-type { grid-column: 2 }
|
||||
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
|
||||
.field-card-body { padding-left: 0 }
|
||||
}
|
||||
|
||||
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
|
||||
|
||||
.theme-form > .card { margin: 0 }
|
||||
.palette-presets { margin-top: 14px }
|
||||
.palette-presets-label { display: block; margin-bottom: 8px; font-size: 12px; font-weight: 600 }
|
||||
.palette-preset-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.palette-preset { display: grid; gap: 6px; justify-items: center; padding: 8px 10px 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); font-size: 11px; font-weight: 600 }
|
||||
.palette-preset svg { display: block; width: 90px; height: 24px; border-radius: 6px; overflow: hidden; box-shadow: inset 0 0 0 1px #203b3426 }
|
||||
.palette-preset:hover { border-color: var(--accent); background: #f5f8f1 }
|
||||
.palette-presets .field-help { margin-top: 10px }
|
||||
.swatch-list { list-style: none; margin: 18px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px }
|
||||
.swatch-card { position: relative; display: grid; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.swatch-card-new { border-style: dashed }
|
||||
/* Der Farbwaehler ist die Flaeche selbst: nichts liegt dazwischen. */
|
||||
.swatch-well { position: relative; display: grid; margin: 0; cursor: pointer }
|
||||
.swatch-input { display: block; width: 100%; height: 76px; min-height: 0; padding: 0; border: 0; border-radius: 0; background: none; cursor: pointer }
|
||||
.swatch-input::-webkit-color-swatch-wrapper { padding: 0 }
|
||||
.swatch-input::-webkit-color-swatch { border: 0; border-radius: 0 }
|
||||
.swatch-input::-moz-color-swatch { border: 0 }
|
||||
.swatch-hex { position: absolute; left: 10px; bottom: 8px; padding: 2px 7px; border-radius: 5px; background: #ffffffe6; color: var(--ink); font-size: 11px; box-shadow: 0 1px 2px #0000001f }
|
||||
.swatch-fields { display: grid; padding: 0 12px 12px }
|
||||
.swatch-fields label { margin: 10px 0 4px; font-size: 11px }
|
||||
.swatch-fields input { min-height: 36px; padding: 6px 10px; font-size: 13px }
|
||||
.swatch-card .field-remove { position: absolute; top: 6px; right: 6px; width: 32px; height: 32px; background: #ffffffe6 }
|
||||
.list-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px }
|
||||
.list-actions .field-help { flex: 1 1 300px; margin: 0 }
|
||||
.font-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px }
|
||||
.font-card { position: relative; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.font-card-new { border-style: dashed }
|
||||
/* Die Probe steht in der Schrift selbst - das Skript setzt die Familie, die Datei liegt auf dem Server. */
|
||||
.font-sample { display: flex; align-items: center; gap: 18px; min-height: 88px; padding: 18px 60px 18px 22px; background: #fafbf8; border-bottom: 1px solid var(--line) }
|
||||
.font-sample-big { flex: none; font-size: 44px; line-height: 1; font-weight: 500 }
|
||||
.font-sample-line { display: grid; gap: 6px; min-width: 0 }
|
||||
.font-sample-text { font-size: 19px; line-height: 1.3; overflow-wrap: anywhere }
|
||||
.font-sample-weights { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted) }
|
||||
.font-weight-100 { font-weight: 100 } .font-weight-200 { font-weight: 200 } .font-weight-300 { font-weight: 300 }
|
||||
.font-weight-400 { font-weight: 400 } .font-weight-500 { font-weight: 500 } .font-weight-600 { font-weight: 600 }
|
||||
.font-weight-700 { font-weight: 700 } .font-weight-800 { font-weight: 800 } .font-weight-900 { font-weight: 900 }
|
||||
.font-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0 16px; padding: 0 22px 18px }
|
||||
.font-fields label { margin-top: 12px }
|
||||
.font-weights { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 0; padding: 0; border: 0 }
|
||||
.font-weights legend { float: left; margin-right: 8px; padding: 0; font-size: 12px; font-weight: 600 }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 5px 10px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); font-size: 12px; font-weight: 500; cursor: pointer }
|
||||
.chip input { width: 14px; min-height: 14px; margin: 0 }
|
||||
.chip:has(input:checked) { border-color: var(--accent); background: #edf4e8; color: #22553e }
|
||||
.font-card .field-remove { position: absolute; top: 10px; right: 10px }
|
||||
.type-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); gap: 16px 36px }
|
||||
.type-fields { min-width: 0 }
|
||||
.type-colors .color-control { margin-top: 0 }
|
||||
.type-preview-column { min-width: 0 }
|
||||
.type-preview { margin-top: 12px; padding: 26px 26px 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; overflow: hidden }
|
||||
.type-preview p { margin: 0 }
|
||||
.type-preview .type-preview-h1 { margin-bottom: 10px; font-size: 34px; font-weight: 700; line-height: 1.15; letter-spacing: -.5px }
|
||||
.type-preview .type-preview-h2 { margin-bottom: 14px; font-size: 26px; font-weight: 600; line-height: 1.2 }
|
||||
.type-preview-p { font-size: 17px; line-height: 1.6 }
|
||||
.type-preview-link { text-decoration: underline; text-underline-offset: 3px }
|
||||
.theme-save { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
@@ -35,7 +35,7 @@ const TOOLS = [
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop, onResize = () => {} }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
@@ -205,6 +205,7 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
placeResizers();
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) {
|
||||
@@ -213,6 +214,124 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Column widths. Umut: "am besten per Drag and Drop im Editor".
|
||||
//
|
||||
// The border between two columns of the selected row is a handle. Dragging it moves share from
|
||||
// one neighbour to the other; the row is restyled inline while the pointer moves, and the
|
||||
// result is written to the document once, when the pointer is released. The arrow keys do the
|
||||
// same in steps of five, so the widths are reachable without a mouse.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
const resizers = [];
|
||||
let focusResizer = null;
|
||||
|
||||
/** The row whose borders can be dragged: the selected row, or the row around the selected column. */
|
||||
function resizableRow() {
|
||||
const item = getSelected() ? locate(getDocument().blocks, getSelected()) : null;
|
||||
if (!item) return null;
|
||||
if (item.block.type === 'row') return item.block;
|
||||
return item.parent?.type === 'row' ? item.parent : null;
|
||||
}
|
||||
|
||||
/** The measured widths of the columns as shares of their sum - the truth a drag starts from. */
|
||||
function sharesOf(rowId) {
|
||||
const kids = childElements(rowId);
|
||||
const total = kids.reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
return total > 0 ? kids.map(kid => kid.box.width / total * 100) : null;
|
||||
}
|
||||
|
||||
/** Moves `delta` points of share from the right neighbour to the left one; neither may vanish. */
|
||||
function shift(shares, index, delta) {
|
||||
const pair = shares[index] + shares[index + 1];
|
||||
const least = Math.min(10, pair / 2);
|
||||
const left = Math.max(least, Math.min(pair - least, shares[index] + delta));
|
||||
const next = shares.slice();
|
||||
next[index] = left;
|
||||
next[index + 1] = pair - left;
|
||||
return next;
|
||||
}
|
||||
|
||||
const tenth = share => Math.round(share * 10) / 10;
|
||||
const formatShares = shares => shares.map(share => String(tenth(share))).join(' ');
|
||||
const tracksOf = shares => shares.map(share => `minmax(0,${tenth(share)}fr)`).join(' ');
|
||||
|
||||
function placeResizers() {
|
||||
const row = dragging ? null : resizableRow();
|
||||
const kids = row ? childElements(row.id) : [];
|
||||
// Stacked columns - a phone, or a single-column layout - have no border to drag.
|
||||
const sideBySide = kids.length > 1 && kids.every((kid, index) => index === 0 || kid.box.left >= kids[index - 1].box.right - 1);
|
||||
const wanted = sideBySide ? kids.length - 1 : 0;
|
||||
while (resizers.length > wanted) resizers.pop().remove();
|
||||
while (resizers.length < wanted) resizers.push(resizeButton());
|
||||
const width = 14 / getScale();
|
||||
resizers.forEach((button, index) => {
|
||||
const left = kids[index].box, right = kids[index + 1].box;
|
||||
button.dataset.row = row.id;
|
||||
button.dataset.index = String(index);
|
||||
const label = `Breite zwischen Spalte ${index + 1} und ${index + 2} ändern`;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.title = `${label}: ziehen oder Pfeiltasten`;
|
||||
button.style.left = `${(left.right + right.left) / 2 - width / 2}px`;
|
||||
button.style.top = `${Math.min(left.top, right.top)}px`;
|
||||
button.style.width = `${width}px`;
|
||||
button.style.height = `${Math.max(left.bottom, right.bottom) - Math.min(left.top, right.top)}px`;
|
||||
if (focusResizer && focusResizer.row === row.id && focusResizer.index === index) { button.focus(); focusResizer = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function resizeButton() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-resize';
|
||||
button.addEventListener('pointerdown', startResize);
|
||||
button.addEventListener('keydown', event => {
|
||||
const step = event.key === 'ArrowLeft' ? -5 : event.key === 'ArrowRight' ? 5 : 0;
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const shares = sharesOf(rowId);
|
||||
if (!shares) return;
|
||||
focusResizer = { row: rowId, index };
|
||||
onResize(rowId, formatShares(shift(shares, index, step)));
|
||||
});
|
||||
layer.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
if (event.button !== 0) return;
|
||||
const button = event.currentTarget;
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const start = sharesOf(rowId);
|
||||
const node = doc()?.getElementById(rowId);
|
||||
if (!start || !node) return;
|
||||
event.preventDefault();
|
||||
button.setPointerCapture(event.pointerId);
|
||||
layer.classList.add('is-resizing');
|
||||
// Pointer pixels are editor pixels; the shares live in the frame's scale, on the summed
|
||||
// width of the columns without the gaps between them.
|
||||
const content = childElements(rowId).reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
const startX = event.clientX;
|
||||
let current = start;
|
||||
const move = moved => {
|
||||
current = shift(start, index, (moved.clientX - startX) / getScale() / content * 100);
|
||||
node.style.gridTemplateColumns = tracksOf(current);
|
||||
sync();
|
||||
};
|
||||
const finish = () => {
|
||||
button.removeEventListener('pointermove', move);
|
||||
button.removeEventListener('pointerup', finish);
|
||||
button.removeEventListener('pointercancel', finish);
|
||||
layer.classList.remove('is-resizing');
|
||||
if (formatShares(current) !== formatShares(start)) onResize(rowId, formatShares(current));
|
||||
else sync();
|
||||
};
|
||||
button.addEventListener('pointermove', move);
|
||||
button.addEventListener('pointerup', finish);
|
||||
button.addEventListener('pointercancel', finish);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -248,6 +248,23 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
/**
|
||||
* The column shares of a row, "2 1" for two thirds and a third. Dragging the border between two
|
||||
* columns in the preview writes the same value; the field is for anyone who prefers numbers.
|
||||
*/
|
||||
const fractionsControl = (id, value, apply) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = id;
|
||||
input.value = value ?? '';
|
||||
input.placeholder = 'gleich breit';
|
||||
input.pattern = String.raw`\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}`;
|
||||
input.setAttribute('inputmode', 'decimal');
|
||||
input.title = 'Anteile je Spalte, durch Leerzeichen getrennt: 2 1 oder 25 50 25';
|
||||
input.addEventListener('change', () => { if (input.checkValidity()) apply(input.value.trim()); });
|
||||
return input;
|
||||
};
|
||||
|
||||
const numberControl = (id, value, property, apply) => {
|
||||
const wrap = element('div', 'number-control');
|
||||
const slider = document.createElement('input');
|
||||
@@ -531,6 +548,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
|
||||
else if (property.type === 'fractions') control = fractionsControl(id, own ? current.value : '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"iconcard",
|
||||
"stat",
|
||||
"quote",
|
||||
"cta"
|
||||
"cta",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"left": [
|
||||
|
||||
@@ -55,8 +55,12 @@ export const isContainer = block => ['section', 'row', 'column', 'gallery', 'log
|
||||
|
||||
/** Containers that take one kind of child and nothing else. */
|
||||
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
|
||||
/** Blocks that exist only inside one of these parents. */
|
||||
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* Blocks that exist only inside one of these parents. A column belongs to a row - and, as a
|
||||
* single one, directly to a section: that is how a button or a short text gets centred and
|
||||
* narrowed without a one-column row around it. Umut asked for exactly that.
|
||||
*/
|
||||
export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
|
||||
* hold one - but it is not a thing a document starts with. At the top level a page begins with
|
||||
@@ -574,6 +578,7 @@ export function relevantProperty(block, key, property) {
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (key === 'columnWidths') return block.type === 'row';
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
|
||||
return true;
|
||||
}
|
||||
@@ -291,8 +291,14 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
||||
/* The border between two columns of the selected row: a grab area with a short bar in it. */
|
||||
.ov-resize{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:0;background:transparent;cursor:col-resize;pointer-events:auto;touch-action:none}
|
||||
.ov-resize::before{content:"";width:4px;height:40px;max-height:60%;border-radius:2px;background:var(--ring,#b55b12);box-shadow:0 0 0 1px #fff8;opacity:.55;transition:opacity .12s}
|
||||
.ov-resize:hover::before,.ov-resize:focus-visible::before,.canvas-overlay.is-resizing .ov-resize::before{opacity:1}
|
||||
.ov-resize:focus-visible{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.canvas-overlay.is-resizing .ov-hover,.canvas-overlay.is-resizing .ov-toolbar,.canvas-overlay.is-resizing .ov-insert{display:none}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert,.canvas-overlay.is-dragging .ov-resize{display:none}
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* ---- narrow screens ---------------------------------------------------------------------- */
|
||||
|
||||
@@ -1347,6 +1347,13 @@ overlay = createCanvasOverlay({
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
},
|
||||
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
|
||||
// the whole row for the current device, one undo step per drag.
|
||||
onResize: (rowId, value) => {
|
||||
if (busy || !history) return;
|
||||
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
|
||||
changed();
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (payload.kind === 'add') {
|
||||
|
||||
@@ -19,6 +19,10 @@ Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dat
|
||||
von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und
|
||||
die Verwendung des reservierten Namens für eine geänderte Fassung.
|
||||
|
||||
**Cormorant Garamond** (Überschriften der Referenzwebsite „Ihr Malerservice Drews") steht ebenfalls
|
||||
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
|
||||
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -10,3 +10,7 @@
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Der Formulareditor in der Verwaltung.
|
||||
*
|
||||
* Ohne Skript ist die Seite vollständig bedienbar: Felder sind Karten mit Eingaben, ein Feld ohne
|
||||
* Beschriftung wird nicht gespeichert, die Vorschau zeigt den gespeicherten Stand. Das Skript
|
||||
* macht daraus einen Editor: Felder hinzufügen und entfernen, den technischen Namen aus der
|
||||
* Beschriftung ableiten, Optionen nur bei einer Auswahl zeigen, Farben aus der Palette wählen -
|
||||
* und eine Vorschau, die jeder Eingabe folgt, mit dem Standardbutton der Website.
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
|
||||
const root = document.querySelector('[data-form-schema]');
|
||||
if (root) {
|
||||
const schema = JSON.parse(root.dataset.formSchema);
|
||||
const defaults = {
|
||||
fieldBorderColor: schema.base.color,
|
||||
fieldBackgroundColor: '#ffffff',
|
||||
buttonColor: schema.buttons.backgroundColor,
|
||||
buttonInk: schema.buttons.color,
|
||||
};
|
||||
const hex = value => /^#[0-9a-f]{6}/i.test(value || '') ? value.slice(0, 7) : schema.colors.find(color => `@${color.key}` === value)?.value || '';
|
||||
// Dunkler beim Überfahren, so wie es die Seite selbst tut (82 % Farbe, Rest Schwarz).
|
||||
const darker = value => {
|
||||
const rgb = hex(value); if (!rgb) return value;
|
||||
return '#' + [1, 3, 5].map(index => Math.round(parseInt(rgb.slice(index, index + 2), 16) * 0.82).toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
const luminance = rgb => {
|
||||
const [r, g, b] = [1, 3, 5].map(index => parseInt(rgb.slice(index, index + 2), 16) / 255)
|
||||
.map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const contrast = (back, front) => {
|
||||
if (!back || !front) return null;
|
||||
const [light, dark] = [luminance(back), luminance(front)].sort((a, b) => b - a);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
};
|
||||
const slug = text => text.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 32);
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag); node.className = className;
|
||||
if (text !== undefined) node.textContent = text; return node;
|
||||
};
|
||||
|
||||
for (const form of root.querySelectorAll('[data-form-editor]')) {
|
||||
const list = form.querySelector('[data-field-list]'), template = form.querySelector('[data-field-template]');
|
||||
const preview = form.querySelector('[data-form-preview]'), add = form.querySelector('[data-add-field]');
|
||||
const buttonLabel = form.querySelector('[data-form-button-label]');
|
||||
const colorInputs = {};
|
||||
const cards = () => [...list.querySelectorAll('[data-field]')];
|
||||
let next = cards().length;
|
||||
|
||||
const renderPreview = () => {
|
||||
preview.replaceChildren();
|
||||
preview.style.color = hex(schema.base.color) || '#203b34';
|
||||
preview.style.background = hex(schema.base.backgroundColor) || '#ffffff';
|
||||
preview.style.setProperty('--form-border', hex(colorInputs.fieldBorderColor.value) || 'currentColor');
|
||||
preview.style.setProperty('--form-bg', hex(colorInputs.fieldBackgroundColor.value) || '#ffffff');
|
||||
let shown = 0;
|
||||
for (const card of cards()) {
|
||||
const label = card.querySelector('[data-field-label]').value.trim();
|
||||
if (!label) continue;
|
||||
shown++;
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const required = type === 'consent' || card.querySelector('[data-field-required]').checked;
|
||||
const field = el('div', 'form-preview-field');
|
||||
if (type === 'consent') {
|
||||
const check = el('span', 'form-preview-check');
|
||||
check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`));
|
||||
field.append(check);
|
||||
} else {
|
||||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||||
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}`, type === 'select' ? '— bitte wählen —' : ''));
|
||||
}
|
||||
const help = card.querySelector('[data-field-help]').value.trim();
|
||||
if (help) field.append(el('span', 'form-preview-help', help));
|
||||
preview.append(field);
|
||||
}
|
||||
if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.'));
|
||||
const values = { ...schema.buttons };
|
||||
const fill = colorInputs.buttonColor.value, ink = colorInputs.buttonInk.value;
|
||||
if (fill) { values.backgroundColor = fill; values.hoverBackgroundColor = darker(fill); }
|
||||
if (ink) { values.color = ink; values.hoverColor = ink; }
|
||||
preview.append(buttonSample(buttonLabel.value.trim() || 'Absenden', values, schema));
|
||||
// Der Kontrast der Buttonschrift auf der Buttonfarbe, nach WCAG: unter 4,5:1 ist der Text
|
||||
// fuer viele nicht mehr lesbar - das soll man sehen, bevor es auf der Website steht.
|
||||
const ratio = contrast(hex(values.backgroundColor), hex(values.color));
|
||||
note.hidden = ratio === null;
|
||||
if (ratio !== null) {
|
||||
note.className = `contrast-note ${ratio >= 4.5 ? 'contrast-ok' : ratio >= 3 ? 'contrast-warn' : 'contrast-bad'}`;
|
||||
note.textContent = `Buttonschrift auf Buttonfarbe: Kontrast ${ratio.toFixed(1).replace('.', ',')}:1 – ${ratio >= 4.5 ? 'gut lesbar.' : ratio >= 3 ? 'nur für große Schrift ausreichend, für den Button zu wenig.' : 'zu wenig, der Text ist kaum lesbar.'}`;
|
||||
}
|
||||
};
|
||||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||||
preview.after(note);
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
card.querySelector('[data-field-options]').hidden = type !== 'select';
|
||||
const remove = card.querySelector('[data-remove-field]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
|
||||
const required = card.querySelector('[data-field-required]');
|
||||
// Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken.
|
||||
if (type === 'consent') { required.checked = true; required.disabled = true; } else required.disabled = false;
|
||||
});
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
// Die leere Karte für ein neues Feld braucht es nur, solange es kein Feld gibt: mit Skript
|
||||
// kommt jedes weitere über den Knopf.
|
||||
const empty = cards().filter(card => card.classList.contains('field-card-new'));
|
||||
if (cards().length > empty.length) empty.forEach(card => card.remove());
|
||||
add.hidden = false;
|
||||
add.addEventListener('click', () => {
|
||||
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
|
||||
const card = list.lastElementChild; card.classList.remove('field-card-new');
|
||||
refresh(); card.querySelector('[data-field-label]').focus();
|
||||
});
|
||||
list.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-field]');
|
||||
if (!button) return;
|
||||
button.closest('[data-field]').remove(); refresh(); add.focus();
|
||||
});
|
||||
list.addEventListener('input', event => {
|
||||
const card = event.target.closest('[data-field]');
|
||||
if (event.target.matches('[data-field-label]')) {
|
||||
// Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt.
|
||||
const name = card.querySelector('[data-field-name]');
|
||||
if (!name.dataset.touched) name.value = slug(event.target.value);
|
||||
}
|
||||
if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : '';
|
||||
refresh();
|
||||
});
|
||||
list.addEventListener('change', refresh);
|
||||
for (const card of cards()) {
|
||||
const name = card.querySelector('[data-field-name]');
|
||||
if (name.value) name.dataset.touched = '1';
|
||||
}
|
||||
|
||||
for (const input of form.querySelectorAll('[data-form-color]')) {
|
||||
const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent;
|
||||
const id = input.id; input.type = 'hidden'; input.id += '-value';
|
||||
colorInputs[key] = input;
|
||||
const control = createColorControl({
|
||||
id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors,
|
||||
onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {},
|
||||
});
|
||||
control.querySelector('.color-inherit').textContent = 'Wie die Website';
|
||||
input.after(control);
|
||||
}
|
||||
buttonLabel.addEventListener('input', renderPreview);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -96,9 +96,16 @@ p{hyphens:auto}
|
||||
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
|
||||
.block-form{display:grid;gap:20px;max-width:640px}
|
||||
.block-form>.cms-button{justify-self:start}
|
||||
/* Ausrichtung eines Buttons (Design-Token „align"): ein inline-flex-Element zentriert sich nicht
|
||||
über margin:auto, darum hier als Block mit eigener Breite - in Spalten, Zeilen und Rastern. */
|
||||
.kant .cms-button.d-desktop-align-left{justify-self:start;margin-inline:0}
|
||||
.kant .cms-button.d-desktop-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}
|
||||
.kant .cms-button.d-desktop-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}
|
||||
@media(max-width:1024px){.kant .cms-button.d-tablet-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-tablet-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-tablet-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
|
||||
@media(max-width:650px){.kant .cms-button.d-mobile-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-mobile-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-mobile-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
|
||||
.form-field{display:grid;gap:7px}
|
||||
.form-field label{font-weight:600;font-size:14px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid currentColor;border-radius:8px;background:#fff;color:inherit;font:inherit;min-height:46px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid var(--form-border,currentColor);border-radius:8px;background:var(--form-bg,#fff);color:inherit;font:inherit;min-height:46px}
|
||||
.form-field textarea{min-height:140px;line-height:1.6;resize:vertical}
|
||||
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:#8b2f22}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
|
||||
@@ -376,9 +383,11 @@ body.consent-open{padding-bottom:320px}
|
||||
/* Section widths. "wide" only widens the content, "full" removes the container entirely: that is
|
||||
what a picture or a colour field from edge to edge needs. A full width section drops its own
|
||||
padding as well, otherwise "edge to edge" would still have 32 pixels of air on each side. */
|
||||
.width-wide>.section-inner{width:min(1600px,calc(100% - 64px))}
|
||||
.width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:650px){.width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
/* Mit ".kant" davor, weil das Design ".kant .shell{max-width:var(--k-container)}" schreibt: ohne
|
||||
den Zusatz gewann diese Regel und ein breiter oder voller Abschnitt blieb so schmal wie alle. */
|
||||
.kant .width-wide>.section-inner{width:min(1600px,calc(100% - 64px));max-width:none}
|
||||
.kant .width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:650px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
|
||||
/* A section that is a flex container centres its content box; the inner container must then be
|
||||
allowed to grow, or a full height section would show a strip in the middle. */
|
||||
@@ -409,6 +418,10 @@ body.consent-open{padding-bottom:320px}
|
||||
Inline geoeffnet stand es sonst in der schmalen Spalte neben der Marke und schob den
|
||||
ganzen Kopf auseinander - auf dem Telefon sah es aus, als fehle das Menue. */
|
||||
.site-header{position:relative}
|
||||
/* Gibt der Kopfbereich seine Flaeche selbst vor, malt der klebende Kopf keine zweite dahinter:
|
||||
sonst zeigte eine durchsichtige Sektion Weiss statt Seite, und die Schattenlinie liesse sich
|
||||
weder faerben noch abschalten. Linie und Schatten kommen dann aus Rahmen und Wirkung. */
|
||||
.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}
|
||||
.site-header .menu-burger[open]>summary{border-radius:8px}
|
||||
.site-header .menu-burger-body{position:absolute;left:0;right:0;top:100%;z-index:30;
|
||||
padding:15px max(16px,calc((100% - var(--k-container,1180px)) / 2 + 16px)) 25px;
|
||||
|
||||
@@ -7,7 +7,7 @@ if (root) {
|
||||
const read = () => Object.fromEntries([...root.querySelectorAll('[name^="buttons["]')].map(input => [input.name.slice(8, -1), input.value]));
|
||||
const update = () => {
|
||||
// Unsaved palette edits are reflected immediately in all samples.
|
||||
schema.colors = [...form.querySelectorAll('.theme-table tbody tr')].flatMap(row => {
|
||||
schema.colors = [...form.querySelectorAll('[data-color-card]')].flatMap(row => {
|
||||
const key = row.querySelector('[name$="[key]"]'), value = row.querySelector('[name$="[value]"]');
|
||||
return key?.value && value ? [{ key: key.value, value: value.value }] : [];
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Die Designseite: Palette, Schriften, Grundtypografie.
|
||||
*
|
||||
* Ohne Skript sind es Karten mit Eingaben; eine Farbe ohne Schlüssel und eine Schrift ohne
|
||||
* Schlüssel werden nicht gespeichert, so lassen sie sich auch dann entfernen. Das Skript macht
|
||||
* daraus die eigentliche Werkstatt: Karten hinzufügen und entfernen, fertige Paletten übernehmen,
|
||||
* die Grundfarben aus der Palette wählen statt tippen, jede Schrift in sich selbst zeigen und die
|
||||
* Grundtypografie als lesbare Probe.
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
const form = document.querySelector('[data-theme-editor]');
|
||||
if (form) {
|
||||
const palettes = JSON.parse(form.dataset.palettes), colorNames = JSON.parse(form.dataset.colorNames);
|
||||
const colorList = form.querySelector('[data-color-list]'), colorTemplate = form.querySelector('[data-color-template]');
|
||||
const fontList = form.querySelector('[data-font-list]'), fontTemplate = form.querySelector('[data-font-template]');
|
||||
const colorCards = () => [...colorList.querySelectorAll('[data-color-card]')];
|
||||
const fontCards = () => [...fontList.querySelectorAll('[data-font-card]')];
|
||||
let nextColor = colorCards().length, nextFont = fontCards().length;
|
||||
const value = (card, selector) => card.querySelector(selector).value.trim();
|
||||
|
||||
const palette = () => colorCards().flatMap(card => {
|
||||
const key = value(card, '[data-color-key]').toLowerCase();
|
||||
return key ? [{ key, name: value(card, '[data-color-name]') || key, value: card.querySelector('[data-color-value]').value }] : [];
|
||||
});
|
||||
const fonts = () => fontCards().flatMap(card => {
|
||||
const key = value(card, '[data-font-key]').toLowerCase();
|
||||
return key ? [{ key, name: value(card, '[data-font-name]') || key, family: value(card, '[data-font-family]'), fallback: card.querySelector('[data-font-fallback]').value }] : [];
|
||||
});
|
||||
const stack = font => font.family ? `"${font.family}", ${font.fallback}` : font.fallback;
|
||||
const hex = raw => /^#[0-9a-f]{6}/i.test(raw || '') ? raw.slice(0, 7) : palette().find(color => `@${color.key}` === raw)?.value || '';
|
||||
|
||||
/* ---- Palette ---------------------------------------------------------------------- */
|
||||
|
||||
const refreshColors = () => {
|
||||
colorCards().forEach((card, index) => {
|
||||
const name = value(card, '[data-color-name]') || value(card, '[data-color-key]') || `Farbe ${index + 1}`;
|
||||
card.querySelector('[data-color-hex]').textContent = card.querySelector('[data-color-value]').value;
|
||||
card.querySelector('[data-color-value]').setAttribute('aria-label', `Farbwert von ${name}`);
|
||||
const remove = card.querySelector('[data-remove-color]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
|
||||
});
|
||||
form.querySelector('[data-color-count]').textContent = String(palette().length);
|
||||
};
|
||||
const addColor = (values = {}) => {
|
||||
colorList.insertAdjacentHTML('beforeend', colorTemplate.innerHTML.replaceAll('__n__', String(nextColor++)));
|
||||
const card = colorList.lastElementChild; card.classList.remove('swatch-card-new');
|
||||
for (const [selector, field] of [['[data-color-key]', 'key'], ['[data-color-name]', 'name'], ['[data-color-value]', 'value']]) {
|
||||
if (values[field] !== undefined) card.querySelector(selector).value = values[field];
|
||||
}
|
||||
return card;
|
||||
};
|
||||
// Die leeren Karten braucht es nur ohne Skript: hier kommt jede Farbe über den Knopf.
|
||||
const emptyColors = colorCards().filter(card => card.classList.contains('swatch-card-new'));
|
||||
if (colorCards().length > emptyColors.length) emptyColors.forEach(card => card.remove());
|
||||
const addColorButton = form.querySelector('[data-add-color]'); addColorButton.hidden = false;
|
||||
addColorButton.addEventListener('click', () => {
|
||||
const card = addColor(); refreshColors(); mountBaseColors(); card.querySelector('[data-color-name]').focus();
|
||||
});
|
||||
colorList.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-color]');
|
||||
if (!button) return;
|
||||
button.closest('[data-color-card]').remove(); refreshColors(); mountBaseColors(); renderType(); addColorButton.focus();
|
||||
});
|
||||
colorList.addEventListener('input', () => { refreshColors(); renderType(); });
|
||||
colorList.addEventListener('change', () => { mountBaseColors(); renderType(); });
|
||||
|
||||
const presets = form.querySelector('[data-palette-presets]'), status = form.querySelector('[data-palette-status]');
|
||||
presets.hidden = false;
|
||||
presets.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-palette-preset]');
|
||||
if (!button) return;
|
||||
const preset = palettes[Number(button.dataset.palettePreset)];
|
||||
// Bekannte Schlüssel bekommen den neuen Wert, fehlende kommen dazu, eigene Farben bleiben.
|
||||
for (const [key, color] of Object.entries(preset.colors)) {
|
||||
const card = colorCards().find(candidate => value(candidate, '[data-color-key]').toLowerCase() === key);
|
||||
if (card) card.querySelector('[data-color-value]').value = color;
|
||||
else addColor({ key, name: colorNames[key] || key, value: color });
|
||||
}
|
||||
refreshColors(); mountBaseColors(); renderType();
|
||||
status.textContent = `Palette „${preset.name}“ übernommen – sie gilt, sobald das Design gespeichert ist.`;
|
||||
});
|
||||
|
||||
/* ---- Schriften -------------------------------------------------------------------- */
|
||||
|
||||
const applySample = card => {
|
||||
const family = value(card, '[data-font-family]'), fallback = card.querySelector('[data-font-fallback]').value;
|
||||
card.querySelector('[data-font-sample]').style.fontFamily = stack({ family, fallback });
|
||||
const chosen = new Set([...card.querySelectorAll('[data-font-weight]')].filter(input => input.checked).map(input => input.value));
|
||||
for (const weight of card.querySelectorAll('[data-font-weights] > span')) weight.hidden = !chosen.has(weight.className.replace('font-weight-', ''));
|
||||
};
|
||||
const syncFontSelects = () => {
|
||||
const list = fonts();
|
||||
for (const select of form.querySelectorAll('[data-font-select]')) {
|
||||
const current = select.value, keep = select.querySelector('option[value="inherit"]');
|
||||
select.replaceChildren(...(keep ? [keep] : []), ...list.map(font => new Option(font.name, font.key)));
|
||||
select.value = list.some(font => font.key === current) || (keep && current === 'inherit') ? current : (keep ? 'inherit' : list[0]?.key ?? '');
|
||||
}
|
||||
};
|
||||
const refreshFonts = () => {
|
||||
fontCards().forEach((card, index) => {
|
||||
applySample(card);
|
||||
const name = value(card, '[data-font-name]') || value(card, '[data-font-key]') || `Schrift ${index + 1}`;
|
||||
const remove = card.querySelector('[data-remove-font]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
|
||||
});
|
||||
form.querySelector('[data-font-count]').textContent = String(fonts().length);
|
||||
syncFontSelects();
|
||||
};
|
||||
const emptyFonts = fontCards().filter(card => card.classList.contains('font-card-new'));
|
||||
if (fontCards().length > emptyFonts.length) emptyFonts.forEach(card => card.remove());
|
||||
const addFontButton = form.querySelector('[data-add-font]'); addFontButton.hidden = false;
|
||||
addFontButton.addEventListener('click', () => {
|
||||
fontList.insertAdjacentHTML('beforeend', fontTemplate.innerHTML.replaceAll('__n__', String(nextFont++)));
|
||||
const card = fontList.lastElementChild; card.classList.remove('font-card-new');
|
||||
refreshFonts(); card.querySelector('[data-font-name]').focus();
|
||||
});
|
||||
fontList.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-font]');
|
||||
if (!button) return;
|
||||
button.closest('[data-font-card]').remove(); refreshFonts(); renderType(); addFontButton.focus();
|
||||
});
|
||||
fontList.addEventListener('input', () => { refreshFonts(); renderType(); });
|
||||
fontList.addEventListener('change', () => { refreshFonts(); renderType(); });
|
||||
|
||||
/* ---- Grundtypografie -------------------------------------------------------------- */
|
||||
|
||||
const preview = form.querySelector('[data-type-preview]');
|
||||
const baseInputs = {};
|
||||
const lengthPattern = /^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/;
|
||||
function renderType() {
|
||||
const list = fonts();
|
||||
const family = key => { const font = list.find(entry => entry.key === key); return font ? stack(font) : 'inherit'; };
|
||||
const raw = form.querySelector('#base-size').value.trim(), size = lengthPattern.test(raw) ? raw : '18px';
|
||||
const line = form.querySelector('#base-line').value || '1.6', scale = parseFloat(form.querySelector('#base-scale').value) || 1.25;
|
||||
preview.style.fontFamily = family(form.querySelector('#base-font').value);
|
||||
preview.style.fontSize = size; preview.style.lineHeight = line;
|
||||
const heading = family(form.querySelector('#base-heading-font').value);
|
||||
for (const [selector, step] of [['[data-type-h1]', 4], ['[data-type-h2]', 3]]) {
|
||||
const node = preview.querySelector(selector);
|
||||
node.style.fontFamily = heading; node.style.fontSize = `calc(${size} * ${(scale ** step).toFixed(4)})`;
|
||||
}
|
||||
preview.style.color = hex(baseInputs.color?.value) || '#203b34';
|
||||
preview.style.background = hex(baseInputs.backgroundColor?.value) || '#ffffff';
|
||||
const link = preview.querySelector('[data-type-link]');
|
||||
link.style.color = hex(baseInputs.linkColor?.value) || 'inherit';
|
||||
link.style.textDecoration = form.querySelector('[data-base-underline]').checked ? 'underline' : 'none';
|
||||
}
|
||||
// Die drei Grundfarben als Palettenwahl. Die Palette kann sich unter ihnen ändern, deshalb
|
||||
// werden die Bedienelemente bei jeder Änderung der Palette neu aufgebaut; der Wert bleibt.
|
||||
function mountBaseColors() {
|
||||
const colors = palette();
|
||||
for (const input of form.querySelectorAll('[data-base-color]')) {
|
||||
const key = input.name.slice(5, -1);
|
||||
if (!input.dataset.controlId) {
|
||||
input.dataset.controlId = input.id; input.type = 'hidden'; input.id += '-value'; baseInputs[key] = input;
|
||||
}
|
||||
const id = input.dataset.controlId, label = form.querySelector(`label[for="${id}"]`).textContent;
|
||||
input.nextElementSibling?.classList.contains('color-control') && input.nextElementSibling.remove();
|
||||
const control = createColorControl({
|
||||
id, label, value: input.value, colors,
|
||||
onChange: chosen => { input.value = chosen; renderType(); }, onComplete: () => {},
|
||||
});
|
||||
control.querySelector('.color-inherit').hidden = true;
|
||||
input.after(control);
|
||||
}
|
||||
}
|
||||
|
||||
refreshColors(); refreshFonts(); mountBaseColors(); renderType();
|
||||
form.addEventListener('input', renderType); form.addEventListener('change', renderType);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ try {
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('.field-table tbody tr').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
console.log('Public no-JavaScript baseline and visual captures passed.');
|
||||
} else {
|
||||
if (!process.argv.includes('--admin-only')) {
|
||||
|
||||
@@ -253,6 +253,39 @@ try {
|
||||
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
// Umut: "bei Rows die Breite ändern, am besten per Drag and Drop im Editor". The border
|
||||
// between the two columns is a handle; dragging it to the right widens the first column.
|
||||
await treeSelect(firstColumnId);
|
||||
await frame.locator(`#${firstColumnId}`).scrollIntoViewIfNeeded();
|
||||
const resizeHandle = page.locator('.ov-resize');
|
||||
await resizeHandle.waitFor();
|
||||
assert.equal(await resizeHandle.count(), 1, 'two columns have one border to drag');
|
||||
const columnTracks = () => frame.locator(`#${newSectionId} .block-row`).first().evaluate(el => getComputedStyle(el).gridTemplateColumns.split(' ').map(Number.parseFloat));
|
||||
const evenTracks = await columnTracks();
|
||||
assert.ok(Math.abs(evenTracks[0] - evenTracks[1]) < 2, `columns start out equal: ${evenTracks}`);
|
||||
const handleBox = await resizeHandle.boundingBox();
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleBox.x + handleBox.width / 2 + 120, handleBox.y + handleBox.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await previewReady();
|
||||
const widenedTracks = await columnTracks();
|
||||
assert.ok(widenedTracks[0] > widenedTracks[1] * 1.3, `dragging the border widened the first column: ${widenedTracks}`);
|
||||
await page.screenshot({ path: 'reports/editor/column-widths.png', fullPage: true });
|
||||
// The same value is visible as numbers in the design panel of the row.
|
||||
await treeSelect(await frame.locator(`#${newSectionId} .block-row`).first().getAttribute('id'));
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
const layoutGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: 'Layout' }) }).first();
|
||||
if (!(await layoutGroup.evaluate(element => element.open))) await layoutGroup.locator('summary').click();
|
||||
assert.match(await page.locator('#style-columnWidths').inputValue(), /^\d+(\.\d)? \d+(\.\d)?$/, 'shares are stored per column');
|
||||
// On a phone the columns stack, so there is no border to drag - and one undo restores equal columns.
|
||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||
await treeSelect(firstColumnId);
|
||||
await page.waitForFunction(() => document.querySelectorAll('.ov-resize').length === 0);
|
||||
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||
await page.locator('#undo').click(); await previewReady();
|
||||
const restoredTracks = await columnTracks();
|
||||
assert.ok(Math.abs(restoredTracks[0] - restoredTracks[1]) < 2, `undo restores equal columns: ${restoredTracks}`);
|
||||
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
|
||||
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
|
||||
const publicBefore = await (await fetch(origin)).text();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Wirft die Maler-Drews-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||
set -e
|
||||
root="C:/Django Project/KantSites"
|
||||
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||
export DATABASE_URL="sqlite:///$root/var/maler-drews.db"
|
||||
export MEDIA_DIRECTORY="$root/var/maler-drews-media"
|
||||
rm -f "$root/var/maler-drews.db"
|
||||
rm -rf "$root/var/maler-drews-media"
|
||||
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||
assets="${1:-$root/var/maler-drews-assets}"
|
||||
"$php" -d memory_limit=512M scripts/seed-maler-drews.php "$assets"
|
||||
# Lato und Cormorant Garamond vom eigenen Server. Der Abruf schreibt fonts.css nur fuer dieses
|
||||
# Design neu; die Datei teilen sich hier aber alle Installationen (Kant-IT und Elektro Bernhard
|
||||
# brauchen Lato 900). Deshalb nur holen, wenn die Cormorant-Dateien fehlen. Das PHP 8.4 hier kennt
|
||||
# keine Stammzertifikate; mit var/cacert.pem (https://curl.se/ca/cacert.pem) klappt der Abruf.
|
||||
if [ -f "$root/public/assets/fonts/cormorant-garamond-700-latin.woff2" ]; then
|
||||
echo "Schriften liegen bereits unter public/assets/fonts."
|
||||
elif [ -f "$root/var/cacert.pem" ]; then
|
||||
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||
else
|
||||
"$php" bin/console cms:fonts:fetch
|
||||
fi
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Inhalte der Website „Ihr Malerservice Drews“, am 15.09.2026 von maler-drews.com übernommen.
|
||||
* Erzeugt aus dem HTML der laufenden Website; Wortlaut unverändert, nur Auszeichnung bereinigt.
|
||||
* Wird von seed-maler-drews.php eingelesen.
|
||||
*/
|
||||
|
||||
return [
|
||||
'impressum' => '<p>Axel Drews<br>Ihr Malerservice Drews<br>Am Bachgarten 1<br>76593 Gernsbach</p> <p>Telefon: +49 7224 40821<br>Telefax: +49 7224 990503<br>E-Mail: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a><br></p> <p><strong>Umsatzsteuer-ID:</strong><br>Umsatzsteuer-Identifikationsnummer nach §27a Umsatzsteuergesetz: DE205838338</p> <p><strong>Hinweis gemäß Online-Streitbeilegungs-Verordnung</strong></p> <p>Nach geltendem Recht sind wir verpflichtet, Verbraucher auf die Existenz der Europäischen Online-Streitbeilegungs-Plattform hinzuweisen, die für die Beilegung von Streitigkeiten genutzt werden kann, ohne dass ein Gericht eingeschaltet werden muss. Für die Einrichtung der Plattform ist die Europäische Kommission zuständig. Die Europäische Online-Streitbeilegungs-Plattform ist hier zu finden: <a href="http://ec.europa.eu/odr">http://ec.europa.eu/odr</a>. Unsere E-Mail lautet: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a></p> <p>Wir weisen aber darauf hin, dass wir nicht bereit sind, uns am Streitbeilegungsverfahren im Rahmen der Europäischen Online-Streitbeilegungs-Plattform zu beteiligen. Nutzen Sie zur Kontaktaufnahme bitte unsere obige E-Mail und Telefonnummer.</p> <p><strong>Disclaimer – rechtliche Hinweise</strong></p> <p><strong>§ 1 Warnhinweis zu Inhalten</strong></p> <p>Die kostenlosen und frei zugänglichen Inhalte dieser Webseite wurden mit größtmöglicher Sorgfalt erstellt. Der Anbieter dieser Webseite übernimmt jedoch keine Gewähr für die Richtigkeit und Aktualität der bereitgestellten kostenlosen und frei zugänglichen journalistischen Ratgeber und Nachrichten. Namentlich gekennzeichnete Beiträge geben die Meinung des jeweiligen Autors und nicht immer die Meinung des Anbieters wieder. Allein durch den Aufruf der kostenlosen und frei zugänglichen Inhalte kommt keinerlei Vertragsverhältnis zwischen dem Nutzer und dem Anbieter zustande, insoweit fehlt es am Rechtsbindungswillen des Anbieters.</p> <p><strong>§ 2 Externe Links</strong></p> <p>Diese Website enthält Verknüpfungen zu Websites Dritter („externe Links“). Diese Websites unterliegen der Haftung der jeweiligen Betreiber. Der Anbieter hat bei der erstmaligen Verknüpfung der externen Links die fremden Inhalte daraufhin überprüft, ob etwaige Rechtsverstöße bestehen. Zu dem Zeitpunkt waren keine Rechtsverstöße ersichtlich. Der Anbieter hat keinerlei Einfluss auf die aktuelle und zukünftige Gestaltung und auf die Inhalte der verknüpften Seiten. Das Setzen von externen Links bedeutet nicht, dass sich der Anbieter die hinter dem Verweis oder Link liegenden Inhalte zu Eigen macht. Eine ständige Kontrolle der externen Links ist für den Anbieter ohne konkrete Hinweise auf Rechtsverstöße nicht zumutbar. Bei Kenntnis von Rechtsverstößen werden jedoch derartige externe Links unverzüglich gelöscht.<br><br><strong>§ 3 Urheber- und Leistungsschutzrechte<br></strong><br>Die auf dieser Website veröffentlichten Inhalte unterliegen dem deutschen Urheber- und Leistungsschutzrecht. Jede vom deutschen Urheber- und Leistungsschutzrecht nicht zugelassene Verwertung bedarf der vorherigen schriftlichen Zustimmung des Anbieters oder jeweiligen Rechteinhabers. Dies gilt insbesondere für Vervielfältigung, Bearbeitung, Übersetzung, Einspeicherung, Verarbeitung bzw. Wiedergabe von Inhalten in Datenbanken oder anderen elektronischen Medien und Systemen. Inhalte und Rechte Dritter sind dabei als solche gekennzeichnet. Die unerlaubte Vervielfältigung oder Weitergabe einzelner Inhalte oder kompletter Seiten ist nicht gestattet und strafbar. Lediglich die Herstellung von Kopien und Downloads für den persönlichen, privaten und nicht kommerziellen Gebrauch ist erlaubt. Die Darstellung dieser Website in fremden Frames ist nur mit schriftlicher Erlaubnis zulässig.<br><br><strong>§ 4 Besondere Nutzungsbedingungen<br></strong><br>Soweit besondere Bedingungen für einzelne Nutzungen dieser Website von den vorgenannten Paragraphen abweichen, wird an entsprechender Stelle ausdrücklich darauf hingewiesen. In diesem Falle gelten im jeweiligen Einzelfall die besonderen Nutzungsbedingungen. </p>',
|
||||
'datenschutz' => [
|
||||
'<p>Wir informieren Sie nachfolgend gemäß den gesetzlichen Vorgaben des Datenschutzrechts (insb. gemäß BDSG n.F. und der europäischen Datenschutz-Grundverordnung ‚DS-GVO‘) über die Art, den Umfang und Zweck der Verarbeitung personenbezogener Daten durch unser Unternehmen. Diese Datenschutzerklärung gilt auch für unsere Websites und Sozial-Media-Profile. Bezüglich der Definition von Begriffen wie etwa „personenbezogene Daten“ oder „Verarbeitung“ verweisen wir auf Art. 4 DS-GVO.</p> <strong>Name und Kontaktdaten des / der Verantwortlichen</strong><br> Unser/e Verantwortliche/r (nachfolgend „Verantwortlicher“) i.S.d. Art. 4 Zif. 7 DS-GVO ist:<br> <br>Ihr Malerservice Drews<br>Am Bachgarten 1<br>76593 Gernsbach<br>Geschäftsführer: Axel Drews <br>Tel: +49 7224 40821 <br>Fax: +49 7224 990503<br>E-Mail-Adresse: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a> <br><br> <strong>Datenarten, Zwecke der Verarbeitung und Kategorien betroffener Personen</strong><br> <p>Nachfolgend informieren wir Sie über Art, Umfang und Zweck der Erhebung, Verarbeitung und Nutzung personenbezogener Daten.</p> <strong>1. Arten der Daten, die wir verarbeiten</strong><br>Nutzungsdaten (Zugriffszeiten, besuchte Websites etc.), Kontaktdaten (Telefonnummer, E-Mail, Fax etc.), Kommunikationsdaten (IP-Adresse etc.), <br> <br> <strong>2. Zwecke der Verarbeitung nach Art. 13 Abs. 1 c) DS-GVO</strong><br>Website technisch und wirtschaftlich optimieren, Leichten Zugang zur Website ermöglichen, Erfüllung gesetzlicher Aufbewahrungspflichten, Optimierung und statistische Auswertung unserer Dienste, Vermeidung von SPAM und Missbrauch, Kontaktanfragen abwickeln, <br> <br> <strong>3. Kategorien der betroffenen Personen nach Art. 13 Abs. 1 e) DS-GVO</strong><br>Besucher/Nutzer der Website, Kunden, Bewerber, <br> <br> <p>Die betroffenen Personen werden zusammenfassend als „Nutzer“ bezeichnet.</p> <br> <strong>Rechtsgrundlagen der Verarbeitung personenbezogener Daten</strong> <p>Nachfolgend Informieren wir Sie über die Rechtsgrundlagen der Verarbeitung personenbezogener Daten:</p> <ol> <li>Wenn wir Ihre Einwilligung für die Verarbeitung personenbezogenen Daten eingeholt haben, ist Art. 6 Abs. 1 S. 1 lit. a) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Erfüllung eines Vertrags oder zur Durchführung vorvertraglicher Maßnahmen erforderlich, die auf Ihre Anfrage hin erfolgen, so ist Art. 6 Abs. 1 S. 1 lit. b) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Erfüllung einer rechtlichen Verpflichtung erforderlich, der wir unterliegen (z.B. gesetzliche Aufbewahrungspflichten), so ist Art. 6 Abs. 1 S. 1 lit. c) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung erforderlich, um lebenswichtige Interessen der betroffenen Person oder einer anderen natürlichen Person zu schützen, so ist Art. 6 Abs. 1 S. 1 lit. d) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Wahrung unserer oder der berechtigten Interessen eines Dritten erforderlich und überwiegen diesbezüglich Ihre Interessen oder Grundrechte und Grundfreiheiten nicht, so ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO Rechtsgrundlage.</li> </ol> <br> <strong>Weitergabe personenbezogener Daten an Dritte und Auftragsverarbeiter</strong> <p>Ohne Ihre Einwilligung geben wir grundsätzlich keine Daten an Dritte weiter. Sollte dies doch der Fall sein, dann erfolgt die Weitergabe auf der Grundlage der zuvor genannten Rechtsgrundlagen z.B. bei der Weitergabe von Daten an Online-Paymentanbieter zur Vertragserfüllung oder aufgrund gerichtlicher Anordnung oder wegen einer gesetzlichen Verpflichtung zur Herausgabe der Daten zum Zwecke der Strafverfolgung, zur Gefahrenabwehr oder zur Durchsetzung der Rechte am geistigen Eigentum.<br> Wir setzen zudem Auftragsverarbeiter (externe Dienstleister z.B. zum Webhosting unserer Websites und Datenbanken) zur Verarbeitung Ihrer Daten ein. Wenn im Rahmen einer Vereinbarung zur Auftragsverarbeitung an die Auftragsverarbeiter Daten weitergegeben werden, erfolgt dies immer nach Art. 28 DS-GVO. Wir wählen dabei unsere Auftragsverarbeiter sorgfältig aus, kontrollieren diese regelmäßig und haben uns ein Weisungsrecht hinsichtlich der Daten einräumen lassen. Zudem müssen die Auftragsverarbeiter geeignete technische und organisatorische Maßnahmen getroffen haben und die Datenschutzvorschriften gem. BDSG n.F. und DS-GVO einhalten</p> <br> <strong>Datenübermittlung in Drittstaaten</strong> <p>Durch die Verabschiedung der europäischen Datenschutz-Grundverordnung (DS-GVO) wurde eine einheitliche Grundlage für den Datenschutz in Europa geschaffen. Ihre Daten werden daher vorwiegend durch Unternehmen verarbeitet, für die DS-GVO Anwendung findet. Sollte doch die Verarbeitung durch Dienste Dritter außerhalb der Europäischen Union oder des Europäischen Wirtschaftsraums stattfinden, so müssen diese die besonderen Voraussetzungen der Art. 44 ff. DS-GVO erfüllen. Das bedeutet, die Verarbeitung erfolgtLine truncated
|
||||
'<strong>Kontaktaufnahme per Kontaktformular / E-Mail / Fax / Post</strong> <ol> <li>Bei der Kontaktaufnahme mit uns per Kontaktformular, Fax, Post oder E-Mail werden Ihre Angaben zum Zwecke der Abwicklung der Kontaktanfrage verarbeitet.<br><br></li> <li>Rechtsgrundlage für die Verarbeitung der Daten ist bei Vorliegen einer Einwilligung von Ihnen Art. 6 Abs. 1 S. 1 lit. a) DS-GVO. Rechtsgrundlage für die Verarbeitung der Daten, die im Zuge einer Kontaktanfrage oder E-Mail, eines Briefes oder Faxes übermittelt werden, ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO. Der Verantwortliche hat ein berechtigtes Interesse an der Verarbeitung und Speicherung der Daten, um Anfragen der Nutzer beantworten zu können, zur Beweissicherung aus Haftungsgründen und um ggf. seiner gesetzlichen Aufbewahrungspflichten bei Geschäftsbriefen nachkommen zu können. Zielt der Kontakt auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 S. 1 lit. b) DS-GVO.<br><br></li> <li>Wir können Ihre Angaben und Kontaktanfrage in unserem Customer-Relationship-Management System („CRM System“) oder einem vergleichbaren System speichern.<br><br></li> <li>Die Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind. Für die personenbezogenen Daten aus der Eingabemaske des Kontaktformulars und diejenigen, die per E-Mail übersandt wurden, ist dies dann der Fall, wenn die jeweilige Konversation mit Ihnen beendet ist. Beendet ist die Konversation dann, wenn sich aus den Umständen entnehmen lässt, dass der betroffene Sachverhalt abschließend geklärt ist. Anfragen von Nutzern, die über einen Account bzw. Vertrag mit uns verfügen, speichern wir bis zum Ablauf von zwei Jahren nach Vertragsbeendigung. Im Fall von gesetzlichen Archivierungspflichten erfolgt die Löschung nach deren Ablauf: Ende handelsrechtlicher (6 Jahre) und steuerrechtlicher (10 Jahre) Aufbewahrungspflicht.<br><br></li> <li>Sie haben jederzeit die Möglichkeit, die Einwilligung nach Art. 6 Abs. 1 S. 1 lit. a) DS-GVO zur Verarbeitung der personenbezogenen Daten zu widerrufen. Nehmen Sie per E-Mail Kontakt mit uns auf, so können Sie der Speicherung der personenbezogenen Daten jederzeit widersprechen.<br><br></li> </ol><br> <strong>Kontaktaufnahme per Telefon</strong> <ol> <li>Bei der Kontaktaufnahme mit uns per Telefon wird Ihre Telefonnummer zur Bearbeitung der Kontaktanfrage und deren Abwicklung verarbeitet und temporär im RAM / Cache des Telefongerätes / Displays gespeichert bzw. angezeigt. Die Speicherung erfolgt aus Haftungs- und Sicherheitsgründen, um den Beweis des Anrufs führen zu können sowie aus wirtschaftlichen Gründen, um einen Rückruf zu ermöglichen. Im Falle von unberechtigten Werbeanrufen, sperren wir die Rufnummern.<br><br></li> <li>Rechtsgrundlage für die Verarbeitung der Telefonnummer ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO. Zielt der Kontakt auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 lit. b) DS-GVO.<br><br></li> <li>Der Gerätecache speichert die Anrufe Tage und überschreibt bzw. löscht sukzessiv alte Daten, bei Entsorgung des Gerätes werden alle Daten gelöscht und der Speicher ggf. zerstört. Gesperrte Telefonnummer werden jährlich auf die Notwendigkeit der Sperrung geprüft.<br><br></li> <li>Sie können die Anzeige der Telefonnummer verhindern, indem Sie mit unterdrückter Telefonnummer anrufen.<br><br></li> </ol><br> <strong>Google Analytics</strong> <ol> <li>Wir haben das Webseitenanalyse-Tool „Google Analytics“ (<strong>Dienstanbieter:</strong> Google Ireland Limited, Registernr.: 368047, Gordon House, Barrow Street, Dublin 4, Irland) auf unserer Website integriert.<br><br></li> <li><strong>Datenkategorien und Beschreibung der Datenverarbeitung:</strong> User-ID, IP-Adresse (anonymisiert). Beim Besuch unserer Website setzt Google einen Cookie auf Ihren Computer, um die Benutzung unserer Website durch Sie analysieren zu können. Wir haben die IP-Anonymisierung „anonymizeIP“ aktiviert, wodurch die IP-Adressen nur gekürzt weiterverarbeitet werden. Auf dieser Webseite wird Ihre IP-Adresse von Google daher innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum zuvor gekürzt. Nur in Ausnahmefällen wird die volle IP-Adresse an einen Server von Google in den USA übertragen und dort gekürzt. Im Auftrag des Betreibers dieser Webseite wird Google diese Informationen benutzen, um Ihre Nutzung der Webseite auszuwerten, um Reports über die Webseitenaktivitäten zusammenzustellen und um weitere, mit der Websitenutzung und der Internetnutzung verbundene, Dienstleistungen gegenüber dem Verantwortlichen zu erbringen. Wir haben darüber hinaus die geräteübergreifende Analyse von Website-Besuchern aktiviert, die über eine sog. User-ID durchgeführt wird. Die im Rahmen von GoogleLine truncated
|
||||
'<strong>Google Maps</strong> <ol> <li>Wir haben auf unserer Website Karten von „Google Maps“ (<strong>Anbieter</strong>: Google Ireland Limited, Registernr.: 368047, Gordon House, Barrow Street, Dublin 4, Irland) integriert.<br><br></li> <li><strong>Datenkategorie und Beschreibung der Datenverarbeitung:</strong> Nutzungsdaten (z.B. IP, Standort, aufgerufene Seite). Mit Google Maps können wir den Standort von Adressen und eine Anfahrtsbeschreibung direkt auf unserer Website in interaktiven Karten anzeigen und Ihnen die Nutzung dieses Tools ermöglichen. Bei dem Abruf unserer Website, wo Google Maps integriert ist, wird eine Verbindung zu den Servern von Google in den USA aufgebaut. Hierbei können Ihre IP und Standort an Google übertragen werden. Zudem erhält Google die Information, dass Sie die entsprechende Seite aufgerufen haben. Dies erfolgt auch ohne Nutzerkonto bei Google. Sollten Sie in Ihren Google-Account eingeloggt sein, kann Google die obigen Daten Ihrem Account zuordnen. Wenn Sie dies nicht wünschen, müssen Sie sich bei Ihrem Google-Account ausloggen. Google erstellt aus solchen Daten Nutzerprofile und nutzt diese Daten zum Zwecke der Werbung, Marktforschung oder Optimierung seiner Websites.<br><br></li> <li><strong>Zweck der Verarbeitung:</strong> Bereitstellung einer nutzerfreundlichen, wirtschaftlichen und optimierten Webseite.<br><br></li> <li><strong>Rechtsgrundlagen:</strong> Haben Sie für Verarbeitung Ihrer personenbezogenen Daten mittels „Google Maps“ vom Drittanbieter Ihre Einwilligung erteilt („Opt-in“), dann ist Art. 6 Abs. 1 S. 1 lit. a) DS-GVO die Rechtsgrundlage. Rechtsgrundlage ist zudem unser in den obigen Zwecken liegendes berechtigtes Interesse an der Datenverarbeitung nach Art. 6 Abs. 1 S.1 lit. f) DS-GVO.<br><br></li> <li><strong>Datenübermittlung/Empfängerkategorie:</strong> Drittanbieter in den USA.<br><br></li> <li><strong>Speicherdauer:</strong> Cookies bis zu 6 Monate oder bis zur Löschung durch Sie. Ansonsten sobald sie nicht mehr für die Verarbeitungszwecke benötigt werden.<br><br></li> <li><strong>Widerspruchs- und Beseitigungsmöglichkeit:</strong> Sie haben gegenüber Google ein Widerspruchsrecht gegen die Bildung von Nutzerprofilen. Bitte richten Sie sich deswegen direkt an Google über die unten genannte Datenschutzerklärung. Ein Opt-Out-Widerspruch hinsichtlich der Werbe-Cookies können Sie hier in Ihrem Google-Account vornehmen:<br><a href="https://adssettings.google.com/authenticated">https://adssettings.google.com/authenticated</a>.<br><br></li> <li>In den Nutzungsbedingungen von Google Maps unter <a href="https://www.google.com/intl/de_de/help/terms_maps.html">https://www.google.com/intl/de_de/help/terms_maps.html</a> und in der Datenschutzerklärung für Werbung von Google unter <a href="https://policies.google.com/technologies/ads">https://policies.google.com/technologies/ads</a> finden Sie weitere Informationen zur Verwendung von Google Cookies und deren Werbetechnologien, Speicherdauer, Anonymisierung, Standortdaten, Funktionsweise und Ihre Rechte. Allgemeine Datenschutzerklärung von Google: <a href="https://policies.google.com/privacy">https://policies.google.com/privacy</a>.<br><br></li> </ol><br> <strong>Datenschutz bei Bewerbungen und im Bewerberverfahren</strong> <ol> <li>Bewerbungen, die auf elektronischem Wege oder per Post an den Verantwortlichen gesendet werden, werden zum Zwecke der Abwicklung des Bewerberverfahrens elektronisch oder manuell verarbeitet.<br><br></li> <li>Wir weisen ausdrücklich darauf hin, dass Bewerbungsunterlagen mit „besonderen Kategorien personenbezogener Daten“ nach Art. 9 DS-GVO (z.B. ein Foto, welches Rückschlüsse auf Ihre ethnische Herkunft, Religion oder Ihren Familienstand gibt), mit Ausnahme einer eventuellen Schwerbehinderung, welche Sie aus freier Entscheidung offenlegen möchten, unerwünscht sind. Sie sollen Ihre Bewerbung ohne diese Daten einreichen. Dies hat keine Auswirkungen auf Ihre Bewerberchancen.<br><br></li> <li>Rechtsgrundlagen für die Verarbeitung sind Art. 6 Abs. 1 S.1 lit. b) DS-GVO sowie § 26 BDSG n.F.<br><br></li> <li>Wird nach Abschluss des Bewerberverfahrens, ein Arbeitsverhältnis mit dem Bewerber / der Bewerberin eingegangen, werden die Bewerberdaten unter Beachtung einschlägiger Datenschutzvorschriften gespeichert. Wird Ihnen nach Abschluss des Bewerberverfahrens keine Stelle angeboten, so wird Ihr eingereichtes Bewerbungsschreiben samt Unterlagen 6 Monate nach Versand der Absage gelöscht, um etwaigen Ansprüchen und Nachweispflichten nach AGG genügen zu können.<br><br></li> </ol><br> <b>Rechte der betroffenen Person</b> <ol> <li><strong>Widerspruch oder Widerruf gegen die Verarbeitung Ihrer Daten<br><br> Soweit die Verarbeitung auf Ihrer Einwilligung gemäß Art. 6 Abs. 1 S. 1 lit. a), Art. 7 DS-GVO beruht, haben Sie das Recht, die Einwilligung jederzeit zu widerrufen. Die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten VLine truncated
|
||||
],
|
||||
'beitraege' => [
|
||||
[
|
||||
'slug' => '60-jahre-ihr-malerservice-drews-ein-jubilaeum-mit-tradition-und-zukunft',
|
||||
'kind' => 'news',
|
||||
'title' => '60 Jahre Ihr Malerservice Drews – Ein Jubiläum mit Tradition und Zukunft',
|
||||
'date' => '2025-10-17 09:36',
|
||||
'excerpt' => 'In diesem Jahr feiern wir ein ganz besonderes Ereignis: 60 Jahre Ihr Malerservice Drews!Seit unserer …',
|
||||
'description' => 'In diesem Jahr feiern wir ein ganz besonderes Ereignis: 60 Jahre Ihr Malerservice Drews!Seit unserer …',
|
||||
'content' => '<p>In diesem Jahr feiern wir ein ganz besonderes Ereignis: <strong>60 Jahre Ihr Malerservice Drews</strong>!<br>Seit unserer Gründung im Jahr 1965 steht unser Familienbetrieb für Qualität, Zuverlässigkeit und Kreativität rund um alle Maler- und Lackierarbeiten. Was einst im Loffenauer Milchhäusle begann, hat sich über sechs Jahrzehnte zu einem modernen Handwerksunternehmen mit einem starken Team und zufriedenen Kundinnen und Kunden in der gesamten Region entwickelt.</p> <p>Wir freuen uns sehr, dass unser Jubiläum auch in der regionalen Presse aufgegriffen wurde. Der Artikel gibt spannende Einblicke in unsere Geschichte, unsere Philosophie und die Leidenschaft, mit der wir täglich an die Arbeit gehen.</p> <p><strong>Ein herzliches Dankeschön</strong> gilt all unseren Kundinnen, Kunden, Geschäftspartnern und unserem großartigen Team – ohne Sie wäre dieser Erfolg nicht möglich gewesen.<br>Mit Stolz blicken wir auf 60 erfolgreiche Jahre zurück und mit Tatkraft und Freude in die Zukunft!</p>',
|
||||
'images' => ['bnn.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-2',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2020-10-19 14:44',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['img-0008-scaled.jpg', 'img-4658.jpg', 'img-0007-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-3',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2021-12-14 12:12',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2021-12-14-13-02-09.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-44',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade 44',
|
||||
'date' => '2020-08-03 12:29',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-31-21-2-1.jpg', 'photo-2020-08-03-11-31-21-2.jpg', 'photo-2020-08-03-11-31-21-3.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-fachwerkhaus',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Aussenfassade Fachwerkhaus',
|
||||
'date' => '2020-08-03 12:33',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-31-21.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade Sanierung',
|
||||
'date' => '2021-04-08 06:28',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['gse-buetthof-bilder.jpg', 'img-0103-scaled.jpg', 'img-0104-scaled.jpg', 'img-0118-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'aussenfassade',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Außenfassade',
|
||||
'date' => '2020-09-14 06:18',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-12-09-50-01-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'bad',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Bad',
|
||||
'date' => '2020-10-19 14:42',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['a934b725-d7dc-4d23-a967-a8ceab1a7268.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'betriebsferien-vom-11-08-bis-22-08',
|
||||
'kind' => 'news',
|
||||
'title' => 'Betriebsferien vom 11.08. bis 22.08.',
|
||||
'date' => '2025-07-23 05:25',
|
||||
'excerpt' => 'Liebe Kundinnen und Kunden,bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August …',
|
||||
'description' => 'Liebe Kundinnen und Kunden,bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August …',
|
||||
'content' => '<p>Liebe Kundinnen und Kunden,<br>bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August Betriebsferien machen. In dieser Zeit ist unser Betrieb geschlossen. Ab dem 25. August sind wir wieder wie gewohnt für Sie da.<br><br>Vielen Dank für Ihr Verständnis und eine schöne Sommerzeit!<br>Ihr Malerservice Drews<br><br>Danke!</p>',
|
||||
'images' => ['betriebsferien.jpg', 'betriebsferien-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'decke-zierleisten',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Rekonstruktion Decken Zierleiste',
|
||||
'date' => '2021-02-24 06:52',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2021-02-24-06-47-34-3.jpg', 'photo-2021-02-24-06-47-34.jpg', 'photo-2021-02-24-06-47-34-1.jpg', 'photo-2021-02-24-06-47-34-2.jpg', 'photo-2021-02-24-06-48-19.jpg', 'photo-2021-02-24-06-48-191.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'dekoration',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Dekoration',
|
||||
'date' => '2020-08-03 12:20',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-11-29-15.jpg', 'photo-2020-08-03-11-31-21-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'exklusiv-in-mittelbaden-wir-sind-offizieller-little-greene-partner',
|
||||
'kind' => 'news',
|
||||
'title' => 'Exklusiv in Mittelbaden: Wir sind offizieller Little Greene Partner',
|
||||
'date' => '2025-04-11 17:44',
|
||||
'excerpt' => 'Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner …',
|
||||
'description' => 'Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner …',
|
||||
'content' => '<p>Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner im Raum Mittelbaden für den traditionsreichen Farben- und Tapetenhersteller Little Greene ist.<br>Little Greene steht seit jeher für hochwertige sowie umweltfreundliche Farben mit beeindruckender Farbtiefe, historische Farbkollektionen mit britischem Flair und handverlesene Tapetendesigns mit Stil und Charakter.</p> <p>Als exklusiver Partner in der Region Mittelbaden bieten wir Ihnen nicht nur Zugang zu den Produkten von Little Greene, sondern auch eine umfassende Beratung und professionelle Verarbeitung direkt vom Fachbetrieb. Ob Neubau, Altbausanierung oder Innenraumgestaltung – wir setzen Ihre Wünsche mit hochwertigen Materialien und einem Sinn für Ästhetik um.<br>Auf unserem Instagram-Account erhalten Sie regelmäßig neue Inspiration für Ihre Raumgestaltung mit Farben und Tapeten von Little Greene.</p>',
|
||||
'images' => ['photo-2025-04-11-14-47-35.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fensterladen-verzierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fensterladen Verzierung',
|
||||
'date' => '2021-12-01 14:02',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['img-5893-scaled.jpg', 'img-5869.jpg', 'img-0136-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'freilegen-einer-backsteinwand',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Freilegen einer Backsteinwand',
|
||||
'date' => '2023-02-16 07:37',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['1676472540643-scaled.jpg', '1676472540620-scaled.jpg', '1676472540632-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'friedrichsbad-baden-baden',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Glanzvoll renoviert: Malerarbeiten im historischen Friedrichsbad, Baden‑Baden',
|
||||
'date' => '2025-07-30 09:48',
|
||||
'excerpt' => 'Im ehrwürdigen Friedrichsbad in Baden‑Baden, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, …',
|
||||
'description' => 'Im ehrwürdigen Friedrichsbad in Baden‑Baden, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, …',
|
||||
'content' => '<p>Im ehrwürdigen <strong>Friedrichsbad in Baden‑Baden</strong>, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, wurden die Wände und Decken behutsam restauriert und neu gestaltet. Mit fein abgestimmten Farbtönen und hochwertigen Techniken hat Malermeister Drews die prachtvollen Majolika-Kacheln, die eleganten Stuckelemente und die beeindruckende Kuppelarchitektur gekonnt in Szene gesetzt – ohne den historischen Charakter zu verfälschen.</p> <p>Das Ergebnis: ein stimmiger Mix aus eleganter Modernisierung und historischer Ästhetik, der das Bad nicht nur technisch aufwertet, sondern auch den Besuchern eine visuelle Reise durch vergangene Epochen ermöglicht. Gerade in Bereichen wie Eingangsbereich, Kuppelsaal und Umkleiden wurde mit viel Feingefühl gearbeitet, um die subtile Balance zwischen Restaurierung und zeitgemäßem Farbkonzept zu wahren.</p> <p>Ob renovierte Wandflächen, frische Texturen oder veredelte Holz- und Metallbereiche – das Team hat dem Friedrichsbad ein innenarchitektonisches Gesicht verliehen, das Geschichte atmet und gleichzeitig einladend wirkt.</p>',
|
||||
'images' => ['kopie-von-2024-06-24-2.jpg', 'kopie-von-2024-06-24-4.jpg', 'kopie-von-2024-06-24-3.jpg', 'kopie-von-2024-06-24-5.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'frohe-weihnachten-und-einen-guten-start-ins-neue-jahr',
|
||||
'kind' => 'news',
|
||||
'title' => 'Frohe Weihnachten und einen guten Start ins neue Jahr!',
|
||||
'date' => '2025-11-23 19:33',
|
||||
'excerpt' => 'Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr. Der …',
|
||||
'description' => 'Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr. Der …',
|
||||
'content' => '<p>Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr.</p> <p>Der gesamte Malerbetrieb wünscht Ihnen und Ihren Liebsten besinnliche Feiertage, erholsame Stunden und einen farbenfrohen Start in das Jahr 2026.</p> <p>**Betriebsferien**</p> <p>Unser Betrieb bleibt vom **22.12.2025 bis einschließlich 06.01.2026** geschlossen.</p> <p>Ab dem **07.01.2026** sind wir wie gewohnt wieder für Sie erreichbar und freuen uns darauf, Ihre Projekte mit frischen Ideen und handwerklicher Qualität umzusetzen.</p> <p>Wir wünschen Ihnen eine wundervolle Weihnachtszeit! 🎄</p>',
|
||||
'images' => ['image0.jpeg', 'image0-1.jpeg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fugenloses-bad-und-boden',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fugenloses Bad und Boden',
|
||||
'date' => '2020-08-04 08:32',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-08-03-16-36-12.jpg', 'photo-2020-08-03-16-36-12-2.jpg', 'photo-2020-08-03-16-36-12-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'fugenloses-bad',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Fugenloses Bad',
|
||||
'date' => '2020-12-01 22:14',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['44d46f3a-255b-441d-8d80-e4810a150368-e1606860739106-1.jpg', 'img-20201130-155129-resized-20201130-035353784.jpg', 'img-20201130-155118-resized-20201130-035352490.jpg', 'img-20201130-155109-resized-20201130-035353150.jpg', '44d46f3a-255b-441d-8d80-e4810a150368-e1606860739106.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'grund-und-hauptschule',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Grund- und Hauptschule',
|
||||
'date' => '2020-08-06 08:46',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['binarydata-big-256998.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'kirche',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Kirche',
|
||||
'date' => '2020-07-20 12:56',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['malerservice-drews-112-von-116.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'modernisierung-neugestaltung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Modernisierung & Neugestaltung',
|
||||
'date' => '2021-11-29 13:55',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['img-5922-scaled.jpg', 'img-5921-scaled.jpg', 'img-5923-scaled.jpg', 'img-5924-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sanierung-fachwerkhaus',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Sanierung Fachwerkhaus',
|
||||
'date' => '2020-09-08 04:54',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-07-10-06-38-5-1.jpg', 'photo-2020-09-07-10-06-49.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sanierung-wohnzimmer-mit-kaminofen',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Sanierung Wohnzimmer mit Kaminofen',
|
||||
'date' => '2020-11-20 13:06',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
|
||||
'images' => ['photo-2020-11-19-18-30-50.jpg', 'photo-2020-11-19-18-30-46-1.jpg', 'photo-2020-11-19-18-30-46.jpg', 'photo-2020-11-19-18-30-47.jpg', 'photo-2020-11-19-18-30-48-1.jpg', 'photo-2020-11-19-18-30-48.jpg', 'photo-2020-11-19-18-30-49-1.jpg', 'photo-2020-11-19-18-30-49.jpg', 'photo-2020-11-19-18-30-50-1.jpg', 'photo-2020-11-19-18-30-51-1.jpg', 'photo-2020-11-19-18-30-51.jpg', 'photo-2020-11-19-18-30-52.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'schulkindbetreuung-hilpertsau',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Unterwasserzauber – Malerarbeiten in der Schulkindbetreuung Hilpertsau',
|
||||
'date' => '2025-07-30 09:38',
|
||||
'excerpt' => 'Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll …',
|
||||
'description' => 'Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll …',
|
||||
'content' => '<p>Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll gestalteten Wandmalereien in eine fantasievolle Unterwasserwelt verwandelt. Seepferdchen, bunte Fische und wellenförmige Elemente erwecken die Wände und Decken zum Leben und laden Kinder zum Staunen und Entdecken ein.</p> <p>Die kreative Gestaltung stammt vom renommierten Malermeisterbetrieb Drews aus Gernsbach – sie schafft nicht nur optische Freude, sondern fördert auch die kindliche Vorstellungskraft und Spielmotivationen</p>',
|
||||
'images' => ['kopie-von-2024-04-19-4.jpg', 'kopie-von-2024-04-19-3.jpg', 'kopie-von-2024-04-19-5.jpg', 'kopie-von-2024-04-19-6.jpg', 'kopie-von-img-1398.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'sommerfest-2024',
|
||||
'kind' => 'news',
|
||||
'title' => 'Sommerfest 2024',
|
||||
'date' => '2024-09-28 15:37',
|
||||
'excerpt' => 'Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.Zu Beginn …',
|
||||
'description' => 'Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.Zu Beginn …',
|
||||
'content' => '<p>Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.<br>Zu Beginn des Festes richtete unser Geschäftsführer Axel Drews einige Worte an die Anwesenden. In seiner Rede bedankte er sich bei allen für ihren unermüdlichen Einsatz und die stetige Bereitschaft, das Unternehmen mit vollem Engagement zu unterstützen. Danach folgte der Höhepunkt des Tages: Die Ehrung langjähriger Mitarbeiter.</p> <p>Drei unserer geschätzten Kollegen wurden für ihre beeindruckende Betriebszugehörigkeit geehrt und mit einer Urkunde sowie einem liebevoll zusammengestellten Präsentkorb bedacht. Eine besonders bemerkenswerte Auszeichnung erhielt Dennis Bähring, der bereits seit unglaublichen 30 Jahren Teil unseres Unternehmens ist. Dennis begann seine Laufbahn bei uns mit seiner Ausbildung und ist seitdem ein fester Bestandteil unseres Teams. In seiner langen Zeit bei uns hat er zahlreiche Projekte und Veränderungen miterlebt. Sein Engagement, seine Loyalität und sein Fachwissen sind für uns von unschätzbarem Wert.</p> <p>Andreas Vetter und Jan Kassner wurden ebenfalls für ihre langjährige Treue zum Unternehmen gewürdigt. Beide blicken auf stolze 20 Jahre Betriebszugehörigkeit zurück. In dieser Zeit haben sie durch ihre tägliche Arbeit maßgeblich zum Erfolg des Betriebs beigetragen. Sie haben sich stets als verlässliche und engagierte Mitarbeiter erwiesen, und es war uns eine Ehre, ihnen für ihre Loyalität und ihren Einsatz zu danken.</p> <p>Das Sommerfest bot den idealen Rahmen, um diese besonderen Jubiläen zu feiern und den Teamgeist noch weiter zu stärken. Bei leckerem Essen, erfrischenden Getränken und geselligen Gesprächen genossen alle Anwesenden den restlichen Abend. Es war ein gelungenes Fest, das nicht nur als Dankeschön für die Mitarbeit der vergangenen Monate diente, sondern auch als Erinnerung daran, wie wichtig das gemeinsame Miteinander in unserem Unternehmen ist.</p> <p>Noch einmal herzlichen Glückwunsch an unsere Jubilare und ein großes Dankeschön an alle Mitarbeiterinnen und Mitarbeiter für euer tägliches Engagement und eure wertvolle Arbeit! Wir freuen uns darauf, gemeinsam weiterhin erfolgreich in die Zukunft zu gehen.</p>',
|
||||
'images' => ['photo-2024-09-27-15-22-38-1.jpg', 'photo-2024-09-27-15-22-38.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'treppenhaus-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Treppenhaus Sanierung',
|
||||
'date' => '2022-02-24 13:27',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['image-50379521-scaled.jpg', 'image-50438401-scaled.jpg', 'image-50424065-scaled.jpg', 'image-50386433-scaled.jpg', 'image-50382337-scaled.jpg', 'treppenhaus-axel-2-scaled.jpg', 'treppenhaus-axel-3-scaled.jpg', 'treppenhaus-axel-4-scaled.jpg', 'treppenhaus-axel-1-scaled.jpg', 'treppenhaus-axel-5-scaled.jpg', 'treppenhaus-axel-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'verkleidung-einer-fassade-aus-nf-brettern',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Verkleidung einer Fassade aus N+F Brettern',
|
||||
'date' => '2023-12-19 06:23',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['img-0594-scaled.jpg', 'screenshot-2023-12-18-084035.jpg', 'pfarramt-fassade.png', 'img-0597-scaled.jpg', 'img-0595-scaled.jpg', 'img-0596-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'von-abgenutzt-zu-atemberaubend-die-verwandlung-einer-tuer-durch-restauration-und-malerei',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Von Abgenutzt zu Atemberaubend: Die Verwandlung einer Tür durch Restauration und Malerei!',
|
||||
'date' => '2023-08-17 09:26',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '<p><a href="https://www.facebook.com/photo/?fbid=308837308169469&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837158169484&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837301502803&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837224836144&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837141502819&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p>',
|
||||
'images' => ['1692193874729-scaled.jpg', '1692193874743-scaled.jpg', 'img-9452-scaled.jpg', '1692193874974-scaled.jpg', '1692193874955-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wand-beton-optik',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wand Beton Optik',
|
||||
'date' => '2022-03-09 07:42',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['4e4c6616-c9bb-4660-a3f5-d3617f2f9ddb-1.jpg', '41bad44a-d3b5-4859-a6ae-447dd5de5fc6.jpg', '64c6dc8d-8425-441c-8838-fe9dda248c4e.jpg', 'img-1047-scaled.jpg', 'img-1045-scaled.jpg', 'img-1046-scaled.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wappen-sanierung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wappen Sanierung',
|
||||
'date' => '2020-10-01 11:11',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '</li><li></li></ul>',
|
||||
'images' => ['photo-2020-09-29-09-15-34-1.jpg'],
|
||||
],
|
||||
[
|
||||
'slug' => 'wohnzimmer-gestaltung',
|
||||
'kind' => 'projekt',
|
||||
'title' => 'Wohnzimmer Gestaltung',
|
||||
'date' => '2022-02-28 15:18',
|
||||
'excerpt' => '',
|
||||
'description' => '',
|
||||
'content' => '',
|
||||
'images' => ['wohnzimmergestaltung-2.jpg'],
|
||||
],
|
||||
],
|
||||
];
|
||||
File diff suppressed because it is too large.
Load diff
@@ -34,6 +34,12 @@ final class FontLibrary
|
||||
{
|
||||
}
|
||||
|
||||
/** Gibt es ein Stylesheet mit lokalen Schriften - auch wenn nicht jede Schrift des Themes dabei ist? */
|
||||
public function hasStylesheet(): bool
|
||||
{
|
||||
return is_file($this->directory.'/fonts.css');
|
||||
}
|
||||
|
||||
/** Liegen für dieses Theme fertige Dateien bereit? */
|
||||
public function hasLocal(array $theme): bool
|
||||
{
|
||||
|
||||
@@ -21,6 +21,13 @@ final class FormRepository
|
||||
'consent' => 'Einwilligung',
|
||||
];
|
||||
public const MAX_FIELDS = 25;
|
||||
/** Die gestaltbaren Farben eines Formulars, mit ihrer Bezeichnung in der Verwaltung. */
|
||||
public const COLORS = [
|
||||
'fieldBorderColor' => 'den Rahmen der Felder',
|
||||
'fieldBackgroundColor' => 'den Hintergrund der Felder',
|
||||
'buttonColor' => 'den Button',
|
||||
'buttonInk' => 'die Buttonschrift',
|
||||
];
|
||||
/** Submissions per sender per hour. Deliberately low: a contact form is not a chat. */
|
||||
public const RATE_LIMIT = 5;
|
||||
/** A human needs longer than this to fill in a form; anything faster is a script. */
|
||||
@@ -157,6 +164,11 @@ final class FormRepository
|
||||
if (false === filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidForm('Bitte gib eine gültige Empfängeradresse an.');
|
||||
}
|
||||
// Der Absender ist freiwillig: leer heisst, die Post kommt von der Empfaengeradresse.
|
||||
$sender = trim((string) ($settings['sender'] ?? ''));
|
||||
if ('' !== $sender && false === filter_var($sender, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidForm('Bitte gib eine gültige Absenderadresse an oder lass das Feld leer.');
|
||||
}
|
||||
$replyTo = trim((string) ($settings['replyToField'] ?? ''));
|
||||
if ('' !== $replyTo) {
|
||||
$match = array_filter($fields, static fn (array $f): bool => $f['name'] === $replyTo && 'email' === $f['type']);
|
||||
@@ -165,12 +177,67 @@ final class FormRepository
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
$clean = [
|
||||
'recipient' => $recipient,
|
||||
'sender' => $sender,
|
||||
'subject' => mb_substr(trim((string) ($settings['subject'] ?? '')), 0, 160) ?: 'Neue Formularnachricht',
|
||||
'success' => mb_substr(trim((string) ($settings['success'] ?? '')), 0, 300) ?: 'Vielen Dank. Deine Nachricht ist angekommen.',
|
||||
'replyToField' => $replyTo,
|
||||
'buttonLabel' => mb_substr(trim((string) ($settings['buttonLabel'] ?? '')), 0, 40) ?: 'Absenden',
|
||||
];
|
||||
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
|
||||
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
|
||||
foreach (self::COLORS as $field => $label) {
|
||||
$value = trim((string) ($settings[$field] ?? ''));
|
||||
if ('' !== $value && 1 !== preg_match(StyleSchema::COLOR, $value)) {
|
||||
throw new InvalidForm(sprintf('„%s“ ist kein Farbwert für %s.', $value, $label));
|
||||
}
|
||||
$clean[$field] = $value;
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Gestaltung eines Formulars als Regel fuer die Seite, die es zeigt.
|
||||
*
|
||||
* Das Formular bekommt seine Farben als Variablen: Rahmen und Grund der Felder direkt, die
|
||||
* Buttonfarben ueber dieselben Token, die auch der Standardbutton der Website benutzt - so
|
||||
* behaelt der Absendeknopf Form und Darstellung aus dem Design und wechselt nur die Farbe.
|
||||
* Nichts gesetzt, nichts geschrieben: dann gilt weiter die Website.
|
||||
*/
|
||||
public function css(array $form, array $theme): string
|
||||
{
|
||||
$values = [];
|
||||
$button = false;
|
||||
foreach (['fieldBorderColor' => '--form-border', 'fieldBackgroundColor' => '--form-bg', 'buttonColor' => '--kb-fill', 'buttonInk' => '--kb-ink'] as $field => $variable) {
|
||||
$value = Theme::cssColor((string) ($form['settings'][$field] ?? ''), $theme);
|
||||
if (null === $value) {
|
||||
continue;
|
||||
}
|
||||
$values[] = $variable.':'.$value;
|
||||
if ('buttonColor' === $field) {
|
||||
$values[] = '--kb-hover-fill:color-mix(in srgb,'.$value.' 82%,#000)';
|
||||
$button = true;
|
||||
}
|
||||
if ('buttonInk' === $field) {
|
||||
$values[] = '--kb-hover-ink:'.$value;
|
||||
$button = true;
|
||||
}
|
||||
}
|
||||
if ($button) {
|
||||
// Die Darstellung (gefuellt, Rahmen, dezent) leitet Flaeche und Schrift aus Fuellung
|
||||
// und Tinte ab - am Wurzelelement, mit den Werten von dort. Hier noch einmal, damit
|
||||
// die Ableitung die Farben dieses Formulars nimmt.
|
||||
foreach (ButtonDesign::appearance($theme['buttons']['appearance'] ?? 'solid') as $key => $value) {
|
||||
$values[] = '--kb-'.$key.':'.$value;
|
||||
}
|
||||
}
|
||||
if ([] === $values) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf('.block-form[data-form="%s"]{%s}', $form['slug'], implode(';', $values));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,12 +85,20 @@ final class Statistics
|
||||
/**
|
||||
* The numbers for a range of days, ending today.
|
||||
*
|
||||
* Neben den Reihen ein paar Summen, die eine Zahl einordnen: der gleich lange Zeitraum davor,
|
||||
* der staerkste Tag, die Wochentage im Schnitt, wie viel ueber Verweise kam und wie oft eine
|
||||
* Adresse ins Leere lief. Alles aus derselben Tabelle, nichts davon braucht ein Merkmal, das
|
||||
* eine Person wiedererkennbar macht.
|
||||
*
|
||||
* @return array{
|
||||
* days: list<array{day: string, count: int}>,
|
||||
* total: int, pages: list<array{path: string, count: int}>,
|
||||
* total: int, previousTotal: int, busiestDay: ?string,
|
||||
* pages: list<array{path: string, count: int}>,
|
||||
* referrers: list<array{host: string, count: int}>,
|
||||
* missing: list<array{path: string, count: int}>,
|
||||
* hours: list<array{hour: int, count: int}>,
|
||||
* weekdays: list<array{weekday: int, count: int, average: float}>,
|
||||
* missingTotal: int, referred: int, pageCount: int,
|
||||
* range: int, from: string, compactedBefore: string, busiest: int
|
||||
* }
|
||||
*/
|
||||
@@ -132,10 +140,40 @@ final class Statistics
|
||||
$hours[(int) $row['hour']] = (int) $row['total'];
|
||||
}
|
||||
|
||||
$sum = fn (string $extra): int => (int) $this->db->fetchOne(
|
||||
'SELECT COALESCE(SUM(count), 0) FROM stat_hit WHERE '.$where.' '.$extra,
|
||||
$params,
|
||||
);
|
||||
// Der gleich lange Zeitraum davor: erst der Vergleich macht aus einer Zahl eine Aussage.
|
||||
$previousTotal = (int) $this->db->fetchOne(
|
||||
'SELECT COALESCE(SUM(count), 0) FROM stat_hit WHERE website_id = ? AND day >= ? AND day < ? AND status = 200',
|
||||
['default', $now->modify(sprintf('-%d days', 2 * $range - 1))->format('Y-m-d'), $from],
|
||||
);
|
||||
$weekdays = array_fill(1, 7, ['count' => 0, 'days' => 0]);
|
||||
$busiestDay = null;
|
||||
foreach ($days as $entry) {
|
||||
$weekday = (int) (new \DateTimeImmutable($entry['day'], new \DateTimeZone('UTC')))->format('N');
|
||||
$weekdays[$weekday]['count'] += $entry['count'];
|
||||
++$weekdays[$weekday]['days'];
|
||||
if ($entry['count'] > 0 && (null === $busiestDay || $entry['count'] > $perDay[$busiestDay])) {
|
||||
$busiestDay = $entry['day'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'days' => $days,
|
||||
'total' => array_sum(array_column($days, 'count')),
|
||||
'previousTotal' => $previousTotal,
|
||||
'busiest' => max(1, max(array_column($days, 'count'))),
|
||||
'busiestDay' => $busiestDay,
|
||||
'weekdays' => array_map(static fn (int $weekday): array => [
|
||||
'weekday' => $weekday,
|
||||
'count' => $weekdays[$weekday]['count'],
|
||||
'average' => $weekdays[$weekday]['days'] > 0 ? round($weekdays[$weekday]['count'] / $weekdays[$weekday]['days'], 1) : 0.0,
|
||||
], range(1, 7)),
|
||||
'missingTotal' => $sum('AND status = 404'),
|
||||
'referred' => $sum("AND status = 200 AND referrer_host <> ''"),
|
||||
'pageCount' => (int) $this->db->fetchOne('SELECT COUNT(DISTINCT path) FROM stat_hit WHERE '.$where.' AND status = 200', $params),
|
||||
'pages' => $rows('path', 'AND status = 200', 'path'),
|
||||
'referrers' => $rows('referrer_host', "AND status = 200 AND referrer_host <> ''", 'referrer_host'),
|
||||
'missing' => $rows('path', 'AND status = 404', 'path'),
|
||||
|
||||
@@ -92,6 +92,11 @@ final class StyleCompiler
|
||||
sprintf('"%s" is outside its range.', $key),
|
||||
),
|
||||
'font' => $this->require(1 === preg_match('/^[a-z][a-z0-9-]{0,39}$/D', $text), sprintf('"%s" is not a font key.', $key)),
|
||||
// A share of nothing is a column that cannot be seen, so zero is refused with the rest.
|
||||
'fractions' => $this->require(
|
||||
1 === preg_match(StyleSchema::FRACTIONS, $text) && !in_array(0.0, array_map(floatval(...), explode(' ', $text)), true),
|
||||
sprintf('"%s" is not a list of column shares.', $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.'),
|
||||
};
|
||||
@@ -112,6 +117,9 @@ final class StyleCompiler
|
||||
if ('image' === ($block['type'] ?? null)) {
|
||||
$css .= $this->imageAlignment((string) $block['id'], $block['style'], $parent);
|
||||
}
|
||||
if ('row' === ($block['type'] ?? null)) {
|
||||
$css .= $this->columnTracks((string) $block['id'], $block['style'], is_array($block['layout'] ?? null) ? $block['layout'] : [], count($block['children'] ?? []));
|
||||
}
|
||||
}
|
||||
$walk($block['children'] ?? [], $block);
|
||||
}
|
||||
@@ -461,6 +469,55 @@ final class StyleCompiler
|
||||
return $chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The column shares of a row: "2 1" becomes two tracks of 2fr and 1fr. Shares inherit from the
|
||||
* wider device like every other style. A device whose layout stacks the columns - "one" from
|
||||
* the classic tokens or a single grid column - gets one track instead: the desktop rule carries
|
||||
* an id and would otherwise outrank the class that stacks the columns on a phone.
|
||||
*/
|
||||
private function columnTracks(string $id, array $style, array $layout, int $count): string
|
||||
{
|
||||
$css = $previous = '';
|
||||
$widths = null;
|
||||
$stacked = false;
|
||||
foreach (['desktop', 'tablet', 'mobile'] as $device) {
|
||||
$values = is_array($style[$device] ?? null) ? $style[$device] : [];
|
||||
$widths = $values['columnWidths'] ?? $widths;
|
||||
if (isset($values['gridColumns'])) {
|
||||
$stacked = 1 === (int) $values['gridColumns'];
|
||||
} elseif (isset($layout['design'][$device]['columns'])) {
|
||||
$stacked = 'one' === $layout['design'][$device]['columns'];
|
||||
}
|
||||
if (null === $widths || $count < 2) {
|
||||
continue;
|
||||
}
|
||||
$rule = sprintf('#%s{grid-template-columns:%s}', $id, $stacked ? 'minmax(0,1fr)' : $this->tracks((string) $widths, $count));
|
||||
if ($rule === $previous) {
|
||||
continue;
|
||||
}
|
||||
$css .= isset(self::BREAKPOINTS[$device])
|
||||
? sprintf('@media (max-width:%dpx){%s}', self::BREAKPOINTS[$device], $rule)
|
||||
: $rule;
|
||||
$previous = $rule;
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
/** One track per column: a column added later gets the average share, surplus shares are dropped. */
|
||||
private function tracks(string $widths, int $count): string
|
||||
{
|
||||
$shares = array_values(array_filter(array_map(floatval(...), explode(' ', $widths)), static fn (float $share): bool => $share > 0));
|
||||
$shares = array_slice($shares, 0, $count);
|
||||
$average = [] === $shares ? 1.0 : array_sum($shares) / count($shares);
|
||||
while (count($shares) < $count) {
|
||||
$shares[] = $average;
|
||||
}
|
||||
$format = static fn (float $share): string => rtrim(rtrim(number_format($share, 1, '.', ''), '0'), '.');
|
||||
|
||||
return implode(' ', array_map(static fn (float $share): string => sprintf('minmax(0,%sfr)', $format($share)), $shares));
|
||||
}
|
||||
|
||||
private function grid(array $values): string
|
||||
{
|
||||
return isset($values['gridColumns'])
|
||||
|
||||
@@ -27,6 +27,8 @@ final class StyleSchema
|
||||
*/
|
||||
public const LENGTH = '/^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D';
|
||||
public const LENGTH_AUTO = '/^(auto|none|0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw))$/D';
|
||||
/** Column shares of a row, up to twelve numbers separated by blanks: "2 1" or "66.7 33.3". */
|
||||
public const FRACTIONS = '/^\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}$/D';
|
||||
|
||||
/**
|
||||
* group – where the property appears in the editor
|
||||
@@ -152,6 +154,9 @@ final class StyleSchema
|
||||
'stretch' => 'Dehnen', 'flex-start' => 'Anfang', 'center' => 'Mitte', 'flex-end' => 'Ende', 'baseline' => 'Grundlinie',
|
||||
]],
|
||||
'gridColumns' => ['group' => 'layout', 'type' => 'number', 'css' => null, 'label' => 'Spalten', 'min' => 1, 'max' => 12, 'step' => 1],
|
||||
// Die Anteile der Spalten einer Zeile: "2 1" ist zwei Drittel zu einem Drittel. Gesetzt wird
|
||||
// das vor allem durch Ziehen der Spaltengrenze in der Vorschau des Editors.
|
||||
'columnWidths' => ['group' => 'layout', 'type' => 'fractions', 'css' => null, 'label' => 'Spaltenbreiten'],
|
||||
'gap' => ['group' => 'layout', 'type' => 'length', 'css' => 'gap', 'label' => 'Abstand zwischen Elementen'],
|
||||
'order' => ['group' => 'layout', 'type' => 'number', 'css' => 'order', 'label' => 'Reihenfolge', 'min' => -20, 'max' => 20, 'step' => 1],
|
||||
'rowGap' => ['group' => 'layout', 'type' => 'length', 'css' => 'row-gap', 'label' => 'Abstand zwischen Zeilen'],
|
||||
|
||||
+19
-1
@@ -25,6 +25,25 @@ final class Theme
|
||||
'system-ui' => 'Systemschrift',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fertige Paletten zum Übernehmen. Dieselben fünf Schlüssel wie die Voreinstellung, damit
|
||||
* Grundfarben und Blöcke, die auf die Schlüssel ink oder link zeigen, stimmen nach dem Wechsel weiter.
|
||||
* Die Linkfarbe erreicht auf dem Papierton jeweils mindestens 4,5:1 - eine Palette, die
|
||||
* ihre eigenen Links unlesbar macht, wäre keine Hilfe.
|
||||
*/
|
||||
public const PALETTES = [
|
||||
['name' => 'Wald', 'colors' => ['paper' => '#f6f7f4', 'ink' => '#203b34', 'mint' => '#e4efb9', 'accent' => '#b55b12', 'link' => '#a4510f']],
|
||||
['name' => 'Ozean', 'colors' => ['paper' => '#f5f8fa', 'ink' => '#12283a', 'mint' => '#d6ecf3', 'accent' => '#0e7490', 'link' => '#0b5f78']],
|
||||
['name' => 'Sand', 'colors' => ['paper' => '#faf6ef', 'ink' => '#3b2f24', 'mint' => '#f1e4cf', 'accent' => '#b8672a', 'link' => '#9a5320']],
|
||||
['name' => 'Koralle', 'colors' => ['paper' => '#fffaf8', 'ink' => '#2b2320', 'mint' => '#ffe4dc', 'accent' => '#d9483b', 'link' => '#b83a2f']],
|
||||
['name' => 'Schiefer', 'colors' => ['paper' => '#f4f5f7', 'ink' => '#1f2937', 'mint' => '#e2e8f0', 'accent' => '#4f46e5', 'link' => '#4338ca']],
|
||||
['name' => 'Olive', 'colors' => ['paper' => '#f7f7f2', 'ink' => '#2a2f23', 'mint' => '#e6ead3', 'accent' => '#6b7a2e', 'link' => '#55621f']],
|
||||
['name' => 'Nacht', 'colors' => ['paper' => '#14181d', 'ink' => '#eef1f4', 'mint' => '#262e38', 'accent' => '#7cc4ff', 'link' => '#9ad2ff']],
|
||||
];
|
||||
|
||||
/** Die Namen der Palettenfarben, wie sie in einer neuen Palette heißen. */
|
||||
public const COLOR_NAMES = ['paper' => 'Papier', 'ink' => 'Tinte', 'mint' => 'Mint', 'accent' => 'Akzent', 'link' => 'Links'];
|
||||
|
||||
public const DEFAULTS = [
|
||||
'colors' => [
|
||||
['key' => 'paper', 'name' => 'Papier', 'value' => '#f6f7f4'],
|
||||
@@ -351,5 +370,4 @@ final class Theme
|
||||
$headings,
|
||||
).ButtonDesign::css($theme['buttons'] ?? ButtonDesign::defaults($base));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -12,17 +14,23 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class FormAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private FormRepository $forms)
|
||||
public function __construct(private FormRepository $forms, private Theme $theme)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'forms' => $this->forms->all(),
|
||||
'types' => FormRepository::TYPES,
|
||||
'colors' => FormRepository::COLORS,
|
||||
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
|
||||
// Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und
|
||||
// kennt die Palette, damit eine Farbe gewaehlt statt getippt wird.
|
||||
'schema' => ['colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttons' => $theme['buttons'] ?? ButtonDesign::defaults($theme['base']), 'base' => $theme['base']],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -54,9 +62,12 @@ final class FormAdminController extends AbstractController
|
||||
$fields,
|
||||
[
|
||||
'recipient' => $request->request->getString('recipient'),
|
||||
'sender' => $request->request->getString('sender'),
|
||||
'subject' => $request->request->getString('subject'),
|
||||
'success' => $request->request->getString('success'),
|
||||
'replyToField' => $request->request->getString('replyToField'),
|
||||
'buttonLabel' => $request->request->getString('buttonLabel'),
|
||||
...array_map(fn (string $field): string => $request->request->getString($field), array_combine(array_keys(FormRepository::COLORS), array_keys(FormRepository::COLORS))),
|
||||
],
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
);
|
||||
|
||||
@@ -90,8 +90,10 @@ final class FormController extends AbstractController
|
||||
foreach ($form['fields'] as $field) {
|
||||
$lines[] = $field['label'].': '.($values[$field['name']] ?? '');
|
||||
}
|
||||
// Der Absender ist die eigene Adresse des Formulars, sonst der Empfaenger. Viele Hoster
|
||||
// liefern nur aus, was von einer Adresse ihrer eigenen Domain kommt.
|
||||
$email = (new Email())
|
||||
->from($form['settings']['recipient'])
|
||||
->from(($form['settings']['sender'] ?? '') ?: $form['settings']['recipient'])
|
||||
->to($form['settings']['recipient'])
|
||||
->subject($form['settings']['subject'])
|
||||
->text(implode("\n", $lines)."\n\n-- \nGesendet über ".rtrim($this->siteOrigin, '/'));
|
||||
|
||||
@@ -86,6 +86,15 @@ final class PageController extends AbstractController
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
}
|
||||
// Die Farben jedes Formulars auf der Seite - nach dem Theme, damit sie es uebersteuern.
|
||||
$forms = $this->formStates($page, $request->query->getString('gesendet'), $request);
|
||||
$styled = [];
|
||||
foreach ($forms as $state) {
|
||||
if (null !== $state['form'] && !isset($styled[$state['form']['slug']])) {
|
||||
$styled[$state['form']['slug']] = true;
|
||||
$styles .= $this->forms->css($state['form'], $theme);
|
||||
}
|
||||
}
|
||||
// Schriften kommen ausschliesslich vom eigenen Server. Fehlen die Dateien, zeigt die
|
||||
// Website die Ersatzschrift des Themes - nie ein Stylesheet von fonts.googleapis.com:
|
||||
// das gaebe die IP-Adresse jedes Besuchers an einen Dritten weiter und haengt den
|
||||
@@ -114,7 +123,7 @@ final class PageController extends AbstractController
|
||||
// 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),
|
||||
'forms' => $forms,
|
||||
'breadcrumbs' => $status >= 400 ? [] : $this->breadcrumbs($page),
|
||||
'socialImage' => $this->socialImage($page),
|
||||
'structuredData' => $status >= 400 ? [] : $this->structuredData($page),
|
||||
|
||||
@@ -30,6 +30,10 @@ final class ThemeController extends AbstractController
|
||||
'buttonPresets' => ButtonDesign::PRESETS,
|
||||
'fontsLocal' => $this->fonts->hasLocal($theme),
|
||||
'fontsMissing' => $this->fonts->missing($theme),
|
||||
// Die Vorschau der Schriften nutzt die Dateien, die schon hier liegen.
|
||||
'fontsStylesheet' => $this->fonts->hasStylesheet(),
|
||||
'palettes' => Theme::PALETTES,
|
||||
'colorNames' => Theme::COLOR_NAMES,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Ob der Kopfbereich seine Fläche selbst gestaltet.
|
||||
*
|
||||
* Der klebende Kopf malt eine weiße Fläche mit Schattenlinie, damit ein Kopf ohne eigene
|
||||
* Gestaltung beim Scrollen nicht über dem Inhalt durchscheint. Gibt eine Sektion des Kopfbereichs
|
||||
* aber selbst einen Hintergrund vor - normal, beim Überfahren oder beim Scrollen -, lag diese
|
||||
* Fläche dahinter: eine durchsichtige Sektion zeigte Weiß statt Seite, und die Linie ließ sich
|
||||
* weder färben noch abschalten. Dann trägt die Sektion die Fläche allein.
|
||||
*
|
||||
* Eine Funktion statt einer Variablen, weil vier Wege den Kopf ausgeben - Seite, Archiv,
|
||||
* Editor-Vorschau und Bereichs-Vorschau - und keiner davon vergessen werden darf.
|
||||
*/
|
||||
final class HeaderExtension extends AbstractExtension
|
||||
{
|
||||
/** Die Eigenschaften, die eine Fläche ausmachen. Eine Textfarbe allein ist keine. */
|
||||
private const SURFACE = ['backgroundColor', 'gradientFrom', 'gradientTo', 'backgroundImage'];
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [new TwigFunction('kant_header_surface', self::ownSurface(...))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur Sektionen der obersten Ebene zählen: eine Spalte mit weißem Kasten um das Logo macht
|
||||
* noch keinen Kopf, und ohne Fläche dahinter schiene der Rest über dem Inhalt durch.
|
||||
*/
|
||||
public static function ownSurface(?array $document): bool
|
||||
{
|
||||
foreach ($document['blocks'] ?? [] as $block) {
|
||||
if ('section' !== ($block['type'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($block['style'] ?? [] as $values) {
|
||||
if (!is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
foreach ([$values, $values['hover'] ?? [], $values['scrolled'] ?? []] as $state) {
|
||||
foreach (self::SURFACE as $key) {
|
||||
if (is_array($state) && '' !== (string) ($state[$key] ?? '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+163
-59
@@ -1,77 +1,181 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'forms' %}
|
||||
{# Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website - dafuer braucht die
|
||||
Seite dessen Stylesheet, genau wie die Designseite. #}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Formulare{% endblock %}
|
||||
{% block heading %}Formulare{% endblock %}
|
||||
{% block lead %}Felder, Empfänger und Einwilligungstext werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
|
||||
{% block lead %}Felder, Empfänger, Einwilligungstext und Aussehen werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %}
|
||||
{% block actions %}<a class="primary" href="#neues-formular">Neues Formular</a>{% endblock %}
|
||||
|
||||
{# Eine Feldkarte. Der Index steht in den Feldnamen; die Vorlage fuer neue Felder traegt an seiner
|
||||
Stelle den Platzhalter __n__, den das Skript beim Einfuegen ersetzt. #}
|
||||
{% macro field(f, n, row, types) %}
|
||||
{% set id = 'f' ~ f ~ '-' ~ n %}
|
||||
<li class="field-card{{ row.label == '' ? ' field-card-new' : '' }}" data-field>
|
||||
<div class="field-card-head">
|
||||
<span class="field-card-index" aria-hidden="true" data-field-index></span>
|
||||
<div class="field-card-title">
|
||||
<label for="{{ id }}-label">Beschriftung</label>
|
||||
<input id="{{ id }}-label" name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" placeholder="{{ row.label == '' ? 'Neues Feld – Beschriftung eingeben' : '' }}" data-field-label>
|
||||
</div>
|
||||
<div class="field-card-type">
|
||||
<label for="{{ id }}-type">Typ</label>
|
||||
<select id="{{ id }}-type" name="fields[{{ n }}][type]" data-field-type>
|
||||
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="field-remove" data-remove-field hidden aria-label="Feld entfernen" title="Feld entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</div>
|
||||
<div class="field-card-body">
|
||||
<div>
|
||||
<label for="{{ id }}-name">Technischer Name</label>
|
||||
<input id="{{ id }}-name" name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" placeholder="vorname" data-field-name>
|
||||
<p class="field-help">Kleinbuchstaben, Ziffern, Unterstrich. Steht in der E-Mail vor der Antwort.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="{{ id }}-help">Hilfetext</label>
|
||||
<input id="{{ id }}-help" name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" placeholder="Steht unter dem Feld" data-field-help>
|
||||
</div>
|
||||
<div data-field-options>
|
||||
<label for="{{ id }}-options">Optionen der Auswahl</label>
|
||||
<input id="{{ id }}-options" name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" placeholder="Beratung, Angebot, Sonstiges" data-field-options-input>
|
||||
<p class="field-help">Durch Komma trennen, mindestens zwei.</p>
|
||||
</div>
|
||||
<div class="field-card-flags">
|
||||
<label class="check-label" for="{{ id }}-required"><input id="{{ id }}-required" type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} data-field-required> Pflichtfeld</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as karte %}
|
||||
<div class="form-screens" data-form-schema="{{ schema|json_encode|e('html_attr') }}">
|
||||
{% for form in forms|merge([null]) %}
|
||||
{% set new = form is null %}
|
||||
{% set f = loop.index %}
|
||||
{% set rows = new ? [] : form.fields %}
|
||||
<section class="stack-section">
|
||||
<div class="section-head">
|
||||
<h2>{{ new ? 'Neues Formular' : form.name }}{% if not new %} <span class="count">{{ form.fields|length }} Felder</span>{% endif %}</h2>
|
||||
{% if not new %}<span class="muted">Block-Adresse <code>{{ form.slug }}</code></span>{% endif %}
|
||||
</div>
|
||||
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def card card-body">
|
||||
{% set settings = new ? {} : form.settings %}
|
||||
{% if new %}
|
||||
<details class="card form-new" id="neues-formular"{{ forms is empty ? ' open' : '' }}>
|
||||
<summary>Neues Formular anlegen</summary>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
{% endif %}
|
||||
<form action="{{ path('admin_form_save', {slug: new ? 'neu' : form.slug}) }}" method="post" class="form-def" data-form-editor>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('forms') }}">
|
||||
<input type="hidden" name="expectedVersion" value="{{ new ? 0 : form.version }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="name-{{ loop.index }}">Name</label>
|
||||
<input id="name-{{ loop.index }}" name="name" maxlength="120" required value="{{ new ? '' : form.name }}">
|
||||
</div>
|
||||
{% if new %}
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label for="recipient-{{ loop.index }}">Empfänger</label>
|
||||
<input id="recipient-{{ loop.index }}" name="recipient" type="email" required value="{{ new ? '' : form.settings.recipient }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ loop.index }}">Betreff</label>
|
||||
<input id="subject-{{ loop.index }}" name="subject" maxlength="160" value="{{ new ? '' : form.settings.subject }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="reply-{{ loop.index }}">Antwortadresse aus Feld</label>
|
||||
<input id="reply-{{ loop.index }}" name="replyToField" maxlength="32" value="{{ new ? '' : form.settings.replyToField }}" placeholder="email">
|
||||
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<label for="success-{{ loop.index }}">Meldung nach dem Absenden</label>
|
||||
<textarea id="success-{{ loop.index }}" name="success" maxlength="300" rows="3" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ new ? '' : form.settings.success }}</textarea>
|
||||
</div>
|
||||
{% if new %}
|
||||
{# Die Zusammenfassung des Details ist keine Ueberschrift; ohne diese springt die
|
||||
Gliederung von der Seitenueberschrift direkt zu den Abschnittstiteln. #}
|
||||
<h2 class="visually-hidden">Neues Formular</h2>
|
||||
{% else %}
|
||||
<div class="card-head">
|
||||
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
|
||||
<span class="muted">Im Editor als Formularblock mit der Adresse <code>{{ form.slug }}</code></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body form-editor">
|
||||
<div class="form-editor-main">
|
||||
<h3 class="form-part">Zustellung</h3>
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="name-{{ f }}">Name</label>
|
||||
<input id="name-{{ f }}" name="name" maxlength="120" required value="{{ new ? '' : form.name }}" placeholder="Kontakt">
|
||||
</div>
|
||||
{% if new %}
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen. Kleinbuchstaben, Ziffern, Bindestrich.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label for="recipient-{{ f }}">Empfänger</label>
|
||||
<input id="recipient-{{ f }}" name="recipient" type="email" required value="{{ settings.recipient|default('') }}" placeholder="info@beispiel.de">
|
||||
</div>
|
||||
<div>
|
||||
<label for="sender-{{ f }}">Absender</label>
|
||||
<input id="sender-{{ f }}" name="sender" type="email" value="{{ settings.sender|default('') }}" placeholder="formular@beispiel.de">
|
||||
<p class="field-help">Leer: die Empfängeradresse. Viele Anbieter stellen nur zu, was von einer Adresse der eigenen Domain kommt.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject-{{ f }}">Betreff der E-Mail</label>
|
||||
<input id="subject-{{ f }}" name="subject" maxlength="160" value="{{ settings.subject|default('') }}" placeholder="Neue Formularnachricht">
|
||||
</div>
|
||||
<div>
|
||||
<label for="reply-{{ f }}">Antwortadresse aus Feld</label>
|
||||
<input id="reply-{{ f }}" name="replyToField" maxlength="32" value="{{ settings.replyToField|default('') }}" placeholder="email">
|
||||
<p class="field-help">Technischer Name eines E-Mail-Feldes. Dann lässt sich direkt antworten.</p>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<label for="success-{{ f }}">Meldung nach dem Absenden</label>
|
||||
<textarea id="success-{{ f }}" name="success" maxlength="300" rows="2" placeholder="Vielen Dank. Wir melden uns in Kürze bei dir.">{{ settings.success|default('') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table field-table">
|
||||
<caption class="visually-hidden">Felder des Formulars. Eine Beschriftung zu leeren entfernt das Feld.</caption>
|
||||
<thead><tr><th scope="col">Technischer Name</th><th scope="col">Typ</th><th scope="col">Beschriftung</th><th scope="col">Pflicht</th><th scope="col">Hilfetext</th><th scope="col">Optionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows|merge([{name: '', type: 'text', label: '', required: false, help: '', options: []}, {name: '', type: 'text', label: '', required: false, help: '', options: []}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.label == '' %} class="table-new"{% endif %}>
|
||||
<td data-label="Technischer Name"><input name="fields[{{ n }}][name]" maxlength="32" value="{{ row.name }}" pattern="[a-z][a-z0-9_]*" aria-label="Technischer Name von Feld {{ n + 1 }}" placeholder="vorname"></td>
|
||||
<td data-label="Typ">
|
||||
<select name="fields[{{ n }}][type]" aria-label="Typ von Feld {{ n + 1 }}">
|
||||
{% for value, label in types %}<option value="{{ value }}"{{ row.type == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td data-label="Beschriftung"><input name="fields[{{ n }}][label]" maxlength="120" value="{{ row.label }}" aria-label="Beschriftung von Feld {{ n + 1 }}"></td>
|
||||
<td class="check-cell" data-label="Pflichtfeld"><input type="checkbox" name="fields[{{ n }}][required]" value="1"{{ row.required ? ' checked' : '' }} aria-label="Feld {{ n + 1 }} ist ein Pflichtfeld"></td>
|
||||
<td data-label="Hilfetext"><input name="fields[{{ n }}][help]" maxlength="200" value="{{ row.help }}" aria-label="Hilfetext von Feld {{ n + 1 }}"></td>
|
||||
<td data-label="Optionen"><input name="fields[{{ n }}][options]" maxlength="500" value="{{ row.options|join(', ') }}" aria-label="Optionen von Feld {{ n + 1 }}" placeholder="nur bei Auswahl"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Optionen einer Auswahl durch Komma trennen. Die letzten beiden Zeilen sind für neue Felder.</p>
|
||||
<button class="primary" type="submit">{{ new ? 'Formular anlegen' : 'Formular speichern' }}</button>
|
||||
<div class="form-fields-head">
|
||||
<h3 class="form-part">Felder</h3>
|
||||
<button type="button" class="secondary" data-add-field hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Feld hinzufügen</button>
|
||||
</div>
|
||||
<ol class="field-list" data-field-list>
|
||||
{% for row in rows %}{{ karte.field(f, loop.index0, row, types) }}{% endfor %}
|
||||
{{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}
|
||||
</ol>
|
||||
<template data-field-template>{{ karte.field(f, '__n__', {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }}</template>
|
||||
<p class="field-help" data-fields-help>Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert.</p>
|
||||
|
||||
<h3 class="form-part">Aussehen</h3>
|
||||
<div class="fields form-look">
|
||||
<div>
|
||||
<label for="buttonLabel-{{ f }}">Beschriftung des Buttons</label>
|
||||
<input id="buttonLabel-{{ f }}" name="buttonLabel" maxlength="40" value="{{ settings.buttonLabel|default('') }}" placeholder="Absenden" data-form-button-label>
|
||||
</div>
|
||||
{% for key, label in {fieldBorderColor: 'Rahmen der Felder', fieldBackgroundColor: 'Hintergrund der Felder', buttonColor: 'Button', buttonInk: 'Buttonschrift'} %}
|
||||
<div>
|
||||
<label for="{{ key }}-{{ f }}">{{ label }}</label>
|
||||
<input id="{{ key }}-{{ f }}" name="{{ key }}" value="{{ settings[key]|default('') }}" placeholder="Wie die Website" data-form-color="{{ key }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field-help">Leer heißt: wie die Website. Sonst eine Farbe aus der Palette oder ein Wert wie <code>#1a2b3c</code>. Form und Größe des Buttons kommen aus dem Design.</p>
|
||||
</div>
|
||||
|
||||
{# Die Vorschau: dieselben Felder, dieselben Farben, der Standardbutton der Website.
|
||||
Ohne Skript steht sie mit den gespeicherten Feldern da; mit Skript folgt sie jeder
|
||||
Eingabe. #}
|
||||
<aside class="form-editor-preview" aria-label="Vorschau des Formulars">
|
||||
<h3 class="form-part">Vorschau</h3>
|
||||
<div class="form-preview" data-form-preview>
|
||||
{% for row in rows %}
|
||||
<div class="form-preview-field">
|
||||
{% if row.type == 'consent' %}
|
||||
<span class="form-preview-check"><span class="form-preview-box" aria-hidden="true"></span>{{ row.label }} *</span>
|
||||
{% else %}
|
||||
<span class="form-preview-label">{{ row.label }}{% if row.required %} *{% endif %}</span>
|
||||
<span class="form-preview-input{{ row.type == 'textarea' ? ' form-preview-area' : '' }}{{ row.type == 'select' ? ' form-preview-select' : '' }}" aria-hidden="true">{{ row.type == 'select' ? '— bitte wählen —' : '' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<span class="cms-button b-desktop-shape-{{ schema.buttons.shape }} b-desktop-appearance-{{ schema.buttons.appearance }} b-desktop-size-{{ schema.buttons.size }}" data-preview-button>{{ settings.buttonLabel|default('Absenden') }}</span>
|
||||
</div>
|
||||
<p class="field-help">Schrift, Abstände und Buttonform kommen aus dem Design der Website.</p>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="card-foot form-editor-foot">
|
||||
<button class="primary" type="submit">{{ new ? 'Formular anlegen' : 'Formular speichern' }}</button>
|
||||
{% if not new %}<span class="muted">Änderungen gelten sofort auf jeder Seite, die dieses Formular zeigt.</span>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% if new %}
|
||||
</details>
|
||||
{% else %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="section-head"><h2>Einsendungen <span class="count">{{ submissions.total }}</span></h2><span class="muted">Neueste zuerst.</span></div>
|
||||
{% if submissions.items is empty %}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
|
||||
tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2',
|
||||
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
|
||||
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5'
|
||||
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||
logout: 'M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h5m5-12 4 4-4 4m4-4H9',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
remove: 'M5 7h14M9 7V4h6v3m-7 0v13h8V7'
|
||||
} %}
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name]|default(paths.pages) }}"/></svg>
|
||||
@@ -53,13 +53,25 @@
|
||||
</ul>
|
||||
</nav>
|
||||
</details>
|
||||
{# Der Fuss der Leiste: eine Kontokarte mit Namen und Rolle, daneben der Abmeldeknopf als
|
||||
Symbol mit Beschriftung fuer Vorlesesoftware, darunter die Fassung. Vorher standen drei
|
||||
Zeilen lose untereinander - Fassung, Name, ein grauer Knopf; jetzt ist es eine Einheit. #}
|
||||
<div class="shell-foot">
|
||||
<p class="shell-version">{% if is_granted('ROLE_ADMIN') %}<a href="{{ path('admin_system') }}">Fassung {{ kant_version() }}</a>{% set updateVersion = kant_update_version() %}{% if updateVersion %}<a class="version-update" href="{{ path('admin_system', {pruefen: 1}) }}" aria-label="Update {{ updateVersion }} verfügbar. Aktualisierung öffnen.">Update {{ updateVersion }} verfügbar</a>{% endif %}{% else %}Fassung {{ kant_version() }}{% endif %}</p>
|
||||
<a class="shell-user" href="{{ path('admin_account') }}" aria-label="Mein Konto: {{ app.user.userIdentifier }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
<button class="subtle" type="submit">Abmelden</button>
|
||||
</form>
|
||||
<div class="shell-account">
|
||||
<a class="shell-user" href="{{ path('admin_account') }}" aria-label="Mein Konto: {{ app.user.userIdentifier }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}>
|
||||
<span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span>
|
||||
<span class="shell-user-text"><strong>{{ app.user.userIdentifier }}</strong><span>{{ is_granted('ROLE_ADMIN') ? 'Administrator' : (is_granted('ROLE_PUBLISHER') ? 'Veröffentlicher' : 'Redakteur') }}</span></span>
|
||||
</a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
<button class="shell-logout" type="submit" title="Abmelden">{% include 'admin/icon.html.twig' with {name: 'logout'} only %}<span class="visually-hidden">Abmelden</span></button>
|
||||
</form>
|
||||
</div>
|
||||
{% set updateVersion = is_granted('ROLE_ADMIN') ? kant_update_version() : null %}
|
||||
<p class="shell-version">
|
||||
{% if is_granted('ROLE_ADMIN') %}<a href="{{ path('admin_system') }}">KantSites {{ kant_version() }}</a>{% else %}<span>KantSites {{ kant_version() }}</span>{% endif %}
|
||||
{% if updateVersion %}<a class="version-update" href="{{ path('admin_system', {pruefen: 1}) }}" aria-label="Update {{ updateVersion }} verfügbar. Aktualisierung öffnen.">Update {{ updateVersion }} verfügbar</a>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<main id="admin-main" class="shell-main" tabindex="-1">
|
||||
|
||||
@@ -4,20 +4,61 @@
|
||||
{% block heading %}Statistik{% endblock %}
|
||||
{% block lead %}Gezählt wird auf dem Server, ohne Cookie und ohne IP-Adresse – deshalb braucht diese Messung keine Einwilligung. Zeiten in UTC.{% endblock %}
|
||||
{% block actions %}
|
||||
<nav class="range-switch" aria-label="Zeitraum">
|
||||
{% for days, label in ranges %}
|
||||
<a href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<a class="secondary" href="{{ path('admin_statistics_csv', {tage: range}) }}">Als CSV</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% set perDay = stats.total / stats.range %}
|
||||
{% set delta = stats.previousTotal > 0 ? ((stats.total - stats.previousTotal) / stats.previousTotal * 100)|round : null %}
|
||||
{% set weekdayNames = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'] %}
|
||||
{% set weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
|
||||
|
||||
<nav class="range-switch" aria-label="Zeitraum">
|
||||
{% for days, label in ranges %}
|
||||
<a class="{{ days == range ? 'primary' : 'secondary' }}" href="{{ path('admin_statistics', {tage: days}) }}"{{ days == range ? ' aria-current="page"' : '' }}>{{ label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{# Die Kennzahlen zuerst: was der Zeitraum gebracht hat, und woran man es misst. #}
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile stat-tile-main">
|
||||
<span class="stat-label">Seitenaufrufe</span>
|
||||
<strong class="stat-value">{{ stats.total|number_format(0, ',', '.') }}</strong>
|
||||
{% if delta is null %}
|
||||
<span class="stat-note">{{ stats.total > 0 ? 'im Zeitraum davor keine Aufrufe' : 'noch nichts gezählt' }}</span>
|
||||
{% else %}
|
||||
<span class="stat-note stat-delta {{ delta > 0 ? 'stat-up' : (delta < 0 ? 'stat-down' : '') }}">{{ delta > 0 ? '+' : '' }}{{ delta|number_format(0, ',', '.') }} % gegenüber {{ ranges[range]|lower }} davor ({{ stats.previousTotal|number_format(0, ',', '.') }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Im Schnitt je Tag</span>
|
||||
<strong class="stat-value">{{ perDay|number_format(perDay < 10 ? 1 : 0, ',', '.') }}</strong>
|
||||
<span class="stat-note">seit {{ stats.from|date('d.m.Y') }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Stärkster Tag</span>
|
||||
<strong class="stat-value">{{ stats.busiestDay ? stats.busiest|number_format(0, ',', '.') : '–' }}</strong>
|
||||
<span class="stat-note">{{ stats.busiestDay ? stats.busiestDay|date('d.m.Y') ~ ', ' ~ weekdayNames[stats.busiestDay|date('N') - 1] : 'kein Aufruf im Zeitraum' }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Aufgerufene Seiten</span>
|
||||
<strong class="stat-value">{{ stats.pageCount|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">unterschiedliche Adressen</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Über Verweise</span>
|
||||
<strong class="stat-value">{{ stats.referred|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">{{ stats.total > 0 ? (stats.referred / stats.total * 100)|round ~ ' % kamen von einer anderen Website' : 'von einer anderen Website' }}</span>
|
||||
</div>
|
||||
<div class="stat-tile">
|
||||
<span class="stat-label">Nicht gefunden</span>
|
||||
<strong class="stat-value">{{ stats.missingTotal|number_format(0, ',', '.') }}</strong>
|
||||
<span class="stat-note">Aufrufe einer Adresse ohne Seite</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Seitenaufrufe <span class="count">{{ stats.total }}</span></h2>
|
||||
<span class="muted">ab {{ stats.from|date('d.m.Y') }}{% if stats.total > 0 %} · im Schnitt {{ (stats.total / stats.range)|number_format(stats.total / stats.range < 10 ? 1 : 0, ',', '.') }} am Tag{% endif %}</span>
|
||||
<h2>Verlauf <span class="count">{{ stats.total }}</span></h2>
|
||||
<span class="muted">Aufrufe je Tag, {{ stats.from|date('d.m.') }} bis heute</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.total == 0 %}
|
||||
@@ -25,26 +66,30 @@
|
||||
{% else %}
|
||||
{# Drawn by the server as an SVG: no chart library, no script, and it prints. The bars are
|
||||
coloured by class, not by a style attribute, because inline styles are forbidden here.
|
||||
The numbers themselves are in the table below, which is what a screen reader reads. #}
|
||||
<svg class="chart" viewBox="0 0 {{ stats.days|length * 12 }} 116" role="img"
|
||||
The numbers themselves are in the table below, which is what a screen reader reads.
|
||||
Jeder Balken traegt seinen Tag und seine Zahl als Titel - der Browser zeigt sie beim
|
||||
Ueberfahren, ohne dass ein Skript etwas nachrechnen muss. #}
|
||||
{% set width = stats.days|length * 12 %}
|
||||
<svg class="chart chart-days" viewBox="0 0 {{ width }} 116" role="img"
|
||||
aria-label="{{ stats.total }} Aufrufe zwischen {{ stats.from|date('d.m.Y') }} und heute, höchster Tageswert {{ stats.busiest }}" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="{{ width }}" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.days %}
|
||||
{% set height = entry.count > 0 ? (entry.count / stats.busiest * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||||
<rect class="chart-bar{{ entry.day == stats.busiestDay ? ' chart-bar-peak' : '' }}" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ entry.day|date('d.m.Y') }}: {{ entry.count }} {{ entry.count == 1 ? 'Aufruf' : 'Aufrufe' }}</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="{{ stats.days|length * 12 }}" y2="100.5"></line>
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="{{ width }}" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>höchster Tag: {{ stats.busiest }}</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
|
||||
<p class="chart-legend"><span>{{ stats.days|first.day|date('d.m.') }}</span><span>Skala bis {{ stats.busiest }} · stärkster Tag hervorgehoben</span><span>{{ stats.days|last.day|date('d.m.') }}</span></p>
|
||||
|
||||
<details class="chart-table">
|
||||
<summary>Zahlen je Tag</summary>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<caption class="visually-hidden">Aufrufe je Tag im gewählten Zeitraum</caption>
|
||||
<thead><tr><th scope="col">Tag</th><th scope="col">Aufrufe</th></tr></thead>
|
||||
<thead><tr><th scope="col">Tag</th><th scope="col">Wochentag</th><th scope="col">Aufrufe</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in stats.days|reverse %}
|
||||
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ entry.count }}</td></tr>
|
||||
<tr><td>{{ entry.day|date('d.m.Y') }}</td><td>{{ weekdayShort[entry.day|date('N') - 1] }}</td><td>{{ entry.count }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -55,19 +100,38 @@
|
||||
</div>
|
||||
|
||||
{% if stats.total > 0 %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde, nur die letzten {{ compactAfter }} Tage sind stundengenau</span></div>
|
||||
<div class="card-body">
|
||||
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
|
||||
<div class="stats-columns stats-columns-2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Tageszeit</h2><span class="muted">Summe je Stunde</span></div>
|
||||
<div class="card-body">
|
||||
{% set busiestHour = max(stats.hours|map(entry => entry.count)|filter(count => count > 0)|default([1])) %}
|
||||
{% for entry in stats.hours %}
|
||||
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>0 Uhr</span><span>12 Uhr</span><span>23 Uhr</span></p>
|
||||
<p class="field-help">Ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
|
||||
<svg class="chart chart-hours" viewBox="0 0 288 116" role="img" aria-label="Verteilung der Aufrufe über die Stunden des Tages" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="288" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.hours %}
|
||||
{% set height = entry.count > 0 ? (entry.count / busiestHour * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar{{ entry.count == busiestHour and entry.count > 0 ? ' chart-bar-peak' : '' }}" x="{{ entry.hour * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ entry.hour }} bis {{ entry.hour + 1 }} Uhr: {{ entry.count }} {{ entry.count == 1 ? 'Aufruf' : 'Aufrufe' }}</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="288" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend"><span>0 Uhr</span><span>6</span><span>12 Uhr</span><span>18</span><span>23 Uhr</span></p>
|
||||
<p class="field-help">Nur die letzten {{ compactAfter }} Tage sind stundengenau; ältere Tage sind zu einem Tageswert zusammengefasst und erscheinen hier nicht. Das hält die Tabelle klein, kostet aber die Stunde – eine Entscheidung, keine Lücke.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Wochentag</h2><span class="muted">Im Schnitt je Tag</span></div>
|
||||
<div class="card-body">
|
||||
{% set busiestWeekday = max(stats.weekdays|map(entry => entry.average)|filter(average => average > 0)|default([1])) %}
|
||||
<svg class="chart chart-weekdays" viewBox="0 0 84 116" role="img" aria-label="Aufrufe je Wochentag im Schnitt" preserveAspectRatio="none">
|
||||
{% for line in [25, 50, 75] %}<line class="chart-grid" x1="0" y1="{{ line }}.5" x2="84" y2="{{ line }}.5"></line>{% endfor %}
|
||||
{% for entry in stats.weekdays %}
|
||||
{% set height = entry.average > 0 ? (entry.average / busiestWeekday * 100)|round(1) : 0 %}
|
||||
<rect class="chart-bar{{ entry.average == busiestWeekday and entry.average > 0 ? ' chart-bar-peak' : '' }}" x="{{ loop.index0 * 12 + 1 }}" y="{{ 100 - height }}" width="10" height="{{ height > 0 ? height : 0.6 }}" rx="1"><title>{{ weekdayNames[entry.weekday - 1] }}: im Schnitt {{ entry.average|number_format(1, ',', '.') }} Aufrufe ({{ entry.count }} gesamt)</title></rect>
|
||||
{% endfor %}
|
||||
<line class="chart-axis" x1="0" y1="100.5" x2="84" y2="100.5"></line>
|
||||
</svg>
|
||||
<p class="chart-legend chart-legend-even">{% for name in weekdayShort %}<span>{{ name }}</span>{% endfor %}</p>
|
||||
<p class="field-help">Der Schnitt gleicht aus, dass ein Zeitraum nicht jeden Wochentag gleich oft enthält.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -75,7 +139,7 @@
|
||||
<div class="stats-columns">
|
||||
{% for group in [
|
||||
{title: 'Seiten', hint: 'Meistaufgerufen', rows: stats.pages, key: 'path', link: true},
|
||||
{title: 'Verweise von außen', hint: 'Domain, die verlinkt hat', rows: stats.referrers, key: 'referrer_host', link: false},
|
||||
{title: 'Verweise von außen', hint: 'Websites, die verlinkt haben', rows: stats.referrers, key: 'referrer_host', link: false},
|
||||
{title: 'Nicht gefundene Adressen', hint: 'Kandidaten für eine Weiterleitung', rows: stats.missing, key: 'path', link: false}
|
||||
] %}
|
||||
<div class="card">
|
||||
@@ -84,11 +148,14 @@
|
||||
{% if group.rows is empty %}
|
||||
<p class="muted">Keine Daten im Zeitraum.</p>
|
||||
{% else %}
|
||||
{% set top = (group.rows|first).count %}
|
||||
<ol class="stats-list">
|
||||
{% for row in group.rows %}
|
||||
<li>
|
||||
<span class="stats-rank" aria-hidden="true">{{ loop.index }}</span>
|
||||
{% if group.link %}<a href="{{ row[group.key] }}">{{ row[group.key] }}</a>{% else %}<span>{{ row[group.key] }}</span>{% endif %}
|
||||
<strong>{{ row.count }}</strong>
|
||||
<svg class="stats-share" viewBox="0 0 100 6" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="stats-share-bar" x="0" y="0" width="{{ (row.count / top * 100)|round(1) }}" height="6" rx="3"></rect></svg>
|
||||
<strong>{{ row.count|number_format(0, ',', '.') }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
@@ -98,14 +165,14 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Was diese Zahlen nicht sagen</h2><span class="muted">Ehrlich statt vollständig</span></div>
|
||||
<details class="card">
|
||||
<summary>Was diese Zahlen nicht sagen</summary>
|
||||
<div class="card-body">
|
||||
<p class="muted">Gezählt werden <strong>Aufrufe</strong>, nicht Besucher: ohne Wiedererkennung lässt sich nicht sagen, ob zehn Aufrufe von zehn Personen oder von einer kommen. Es gibt deshalb auch keine Verweildauer, keine Absprungrate, keinen Klickweg durch die Website und kein Land. All das braucht ein Merkmal, das eine Person wiedererkennbar macht – und damit eine Einwilligung.</p>
|
||||
<p class="field-help">Erkennbare Suchmaschinen und Überwachungsdienste werden anhand ihrer Kennung ausgeschlossen. Das ist eine Heuristik: wer sich als Browser ausgibt, wird mitgezählt. Die Zahl ist also „Aufrufe, die sich nicht als Maschine gemeldet haben“, nicht „Menschen“. Erfundene Adressen von Crawlern laufen gesammelt unter <code>(sonstige)</code>, damit die Tabelle nicht mit tausenden Zeilen wächst.</p>
|
||||
<p class="field-help">Aufbewahrt wird {{ keepDays }} Tage, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. Danach wird gelöscht.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
+198
-103
@@ -1,122 +1,214 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'theme' %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/theme-buttons.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{# Die lokalen Schriftdateien, sofern es sie gibt: dann zeigt die Vorschau jede Google-Schrift so,
|
||||
wie die Website sie ausliefert. Von Google selbst wird hier nichts geladen. #}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% if fontsStylesheet %}<link rel="stylesheet" href="/assets/fonts/fonts.css?v={{ kant_version() }}">{% endif %}{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/theme-panel.js?v={{ kant_version() }}"></script><script type="module" src="/assets/theme-buttons.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Design{% endblock %}
|
||||
{% block heading %}Farben, Schriften, Grundtypografie{% endblock %}
|
||||
{% block lead %}Das ist die Grundlage für die ganze Website. Jeder Block erbt von hier und kann jeden Wert einzeln überschreiben – je Gerät und beim Überfahren.{% endblock %}
|
||||
{% block content %}
|
||||
<form action="{{ path('admin_theme_save') }}" method="post" class="card card-body theme-form">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
|
||||
<div class="section-head"><h2>Farbpalette <span class="count">{{ theme.colors|length }}</span></h2><span class="muted">Beliebig viele eigene Farben</span></div>
|
||||
<table class="table theme-table">
|
||||
<caption class="visually-hidden">Farben der Website. Einen Schlüssel zu leeren entfernt die Farbe.</caption>
|
||||
<thead><tr><th scope="col">Schlüssel</th><th scope="col">Name</th><th scope="col">Farbe</th><th scope="col">Wert</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in theme.colors|merge([{key: '', name: '', value: '#000000'}, {key: '', name: '', value: '#000000'}, {key: '', name: '', value: '#000000'}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.key == '' %} class="table-new"{% endif %}>
|
||||
<td><input name="colors[{{ n }}][key]" maxlength="32" value="{{ row.key }}" pattern="[a-z][a-z0-9-]*" aria-label="Schlüssel von Farbe {{ n + 1 }}" placeholder="akzent"></td>
|
||||
<td><input name="colors[{{ n }}][name]" maxlength="60" value="{{ row.name }}" aria-label="Name von Farbe {{ n + 1 }}" placeholder="Akzent"></td>
|
||||
<td><input type="color" name="colors[{{ n }}][value]" value="{{ row.value }}" aria-label="Farbwähler für Farbe {{ n + 1 }}" class="theme-swatch"></td>
|
||||
<td><code>{{ row.value }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Der Schlüssel wird im Editor als <code>@schlüssel</code> verwendet und bleibt bei Farbänderungen gleich – eine Farbe ändern wirkt überall, wo sie benutzt wird.</p>
|
||||
|
||||
<div class="section-head"><h2>Schriften <span class="count">{{ theme.fonts|length }}</span></h2><span class="muted">Systemschrift oder Google Fonts</span></div>
|
||||
<table class="table theme-table">
|
||||
<caption class="visually-hidden">Schriften der Website. Einen Schlüssel zu leeren entfernt die Schrift.</caption>
|
||||
<thead><tr><th scope="col">Schlüssel</th><th scope="col">Name</th><th scope="col">Quelle</th><th scope="col">Schriftname</th><th scope="col">Ersatz</th><th scope="col">Schnitte</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in theme.fonts|merge([{key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]}]) %}
|
||||
{% set n = loop.index0 %}
|
||||
<tr{% if row.key == '' %} class="table-new"{% endif %}>
|
||||
<td><input name="fonts[{{ n }}][key]" maxlength="40" value="{{ row.key }}" pattern="[a-z][a-z0-9-]*" aria-label="Schlüssel von Schrift {{ n + 1 }}" placeholder="heading"></td>
|
||||
<td><input name="fonts[{{ n }}][name]" maxlength="60" value="{{ row.name }}" aria-label="Name von Schrift {{ n + 1 }}" placeholder="Überschriften"></td>
|
||||
<td>
|
||||
<select name="fonts[{{ n }}][source]" aria-label="Quelle von Schrift {{ n + 1 }}">
|
||||
<option value="system"{{ row.source == 'system' ? ' selected' : '' }}>Systemschrift</option>
|
||||
<option value="google"{{ row.source == 'google' ? ' selected' : '' }}>Google Fonts</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="fonts[{{ n }}][family]" maxlength="48" value="{{ row.family }}" aria-label="Schriftname von Schrift {{ n + 1 }}" placeholder="Inter"></td>
|
||||
<td>
|
||||
<select name="fonts[{{ n }}][fallback]" aria-label="Ersatzschrift von Schrift {{ n + 1 }}">
|
||||
{% for value, label in fallbacks %}<option value="{{ value }}"{{ row.fallback == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="weights-cell">
|
||||
{% for weight in weights %}
|
||||
<label><input type="checkbox" name="fonts[{{ n }}][weights][]" value="{{ weight }}"{{ weight in row.weights ? ' checked' : '' }}> {{ weight }}</label>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="field-help">Bei Google Fonts den Namen genau wie dort schreiben, zum Beispiel <code>Inter</code> oder <code>Playfair Display</code>. Je Schnitt wird eine Datei geladen – nur ankreuzen, was wirklich gebraucht wird. <strong>Google Fonts bedeutet eine Anfrage an Google bei jedem Seitenaufruf.</strong></p>
|
||||
|
||||
<div class="section-head"><h2>Grundtypografie</h2><span class="muted">Gilt überall, solange ein Block nichts anderes sagt</span></div>
|
||||
<div class="fields">
|
||||
{# Eine Farbe der Palette als Karte: der Farbwähler ist die Fläche selbst. #}
|
||||
{% macro color(n, row) %}
|
||||
<li class="swatch-card{{ row.key == '' ? ' swatch-card-new' : '' }}" data-color-card>
|
||||
<label class="swatch-well" for="color-{{ n }}-value">
|
||||
<input type="color" id="color-{{ n }}-value" name="colors[{{ n }}][value]" value="{{ row.value }}" class="swatch-input" aria-label="Farbwert von Farbe {{ n }}" data-color-value>
|
||||
<code class="swatch-hex" data-color-hex>{{ row.value }}</code>
|
||||
</label>
|
||||
<div class="swatch-fields">
|
||||
<div>
|
||||
<label for="base-font">Schrift für Fließtext</label>
|
||||
<select id="base-font" name="base[fontFamily]">
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<label for="color-{{ n }}-name">Name</label>
|
||||
<input id="color-{{ n }}-name" name="colors[{{ n }}][name]" maxlength="60" value="{{ row.name }}" placeholder="Akzent" data-color-name>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-heading-font">Schrift für Überschriften</label>
|
||||
<select id="base-heading-font" name="base[headingFontFamily]">
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.headingFontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-size">Grundgröße</label>
|
||||
<input id="base-size" name="base[fontSize]" value="{{ theme.base.fontSize }}" placeholder="18px">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-line">Zeilenhöhe</label>
|
||||
<input id="base-line" name="base[lineHeight]" type="number" step="0.05" min="0.8" max="3" value="{{ theme.base.lineHeight }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-scale">Überschriftenskala</label>
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="check-label" for="base-underline"><input id="base-underline" name="base[linkUnderline]" type="checkbox" value="1"{{ (theme.base.linkUnderline ?? true) ? ' checked' : '' }}> Links im Fließtext unterstreichen</label>
|
||||
<p class="field-help">Aus sieht ruhiger aus, aber dann erkennt man einen Link im Text nur an der Farbe – für Menschen mit Farbsehschwäche also gar nicht. Beim Überfahren erscheint der Strich in beiden Fällen. Menüs, Karten und Schaltflächen sind davon nicht betroffen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
<p class="field-help">Die Breite des Inhalts auf allen Seiten. Abschnitte mit der Inhaltsbreite „Breit“ oder „Randlos“ halten sich nicht daran – das ist der Zweck dieser beiden.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-color">Textfarbe</label>
|
||||
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-bg">Hintergrundfarbe</label>
|
||||
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-link">Linkfarbe</label>
|
||||
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
|
||||
<label for="color-{{ n }}-key">Schlüssel</label>
|
||||
<input id="color-{{ n }}-key" name="colors[{{ n }}][key]" maxlength="32" value="{{ row.key }}" pattern="[a-z][a-z0-9\-]*" placeholder="akzent" data-color-key>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
|
||||
<button type="button" class="field-remove" data-remove-color hidden aria-label="Farbe entfernen" title="Farbe entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{# Eine Schrift als Karte: oben die Probe in der Schrift selbst, darunter die Einstellungen. #}
|
||||
{% macro font(n, row, fallbacks, weights) %}
|
||||
<li class="font-card{{ row.key == '' ? ' font-card-new' : '' }}" data-font-card>
|
||||
<div class="font-sample" data-font-sample>
|
||||
<span class="font-sample-big" aria-hidden="true">Aa</span>
|
||||
<span class="font-sample-line">
|
||||
<span class="font-sample-text">Zwölf Boxkämpfer jagen Viktor quer über den großen Sylter Deich.</span>
|
||||
<span class="font-sample-weights" data-font-weights>{% for weight in weights %}<span class="font-weight-{{ weight }}"{{ weight in row.weights ? '' : ' hidden' }}>{{ weight }}</span>{% endfor %}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-fields">
|
||||
<div>
|
||||
<label for="font-{{ n }}-name">Name</label>
|
||||
<input id="font-{{ n }}-name" name="fonts[{{ n }}][name]" maxlength="60" value="{{ row.name }}" placeholder="Überschriften" data-font-name>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-key">Schlüssel</label>
|
||||
<input id="font-{{ n }}-key" name="fonts[{{ n }}][key]" maxlength="40" value="{{ row.key }}" pattern="[a-z][a-z0-9\-]*" placeholder="heading" data-font-key>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-source">Quelle</label>
|
||||
<select id="font-{{ n }}-source" name="fonts[{{ n }}][source]" data-font-source>
|
||||
<option value="system"{{ row.source == 'system' ? ' selected' : '' }}>Systemschrift</option>
|
||||
<option value="google"{{ row.source == 'google' ? ' selected' : '' }}>Google Fonts</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-family">Schriftname</label>
|
||||
<input id="font-{{ n }}-family" name="fonts[{{ n }}][family]" maxlength="48" value="{{ row.family }}" placeholder="Inter" data-font-family>
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-{{ n }}-fallback">Ersatzschrift</label>
|
||||
<select id="font-{{ n }}-fallback" name="fonts[{{ n }}][fallback]" data-font-fallback>
|
||||
{% for value, label in fallbacks %}<option value="{{ value }}"{{ row.fallback == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<fieldset class="font-weights">
|
||||
<legend>Schnitte</legend>
|
||||
{% for weight in weights %}
|
||||
<label class="chip"><input type="checkbox" name="fonts[{{ n }}][weights][]" value="{{ weight }}"{{ weight in row.weights ? ' checked' : '' }} data-font-weight> {{ weight }}</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<button type="button" class="field-remove" data-remove-font hidden aria-label="Schrift entfernen" title="Schrift entfernen">{% include 'admin/icon.html.twig' with {name: 'remove'} only %}</button>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% import _self as karte %}
|
||||
{% set emptyColor = {key: '', name: '', value: '#4a7c59'} %}
|
||||
{% set emptyFont = {key: '', name: '', family: '', source: 'google', fallback: 'sans-serif', weights: [400, 700]} %}
|
||||
<form action="{{ path('admin_theme_save') }}" method="post" class="theme-form stack" data-theme-editor data-palettes="{{ palettes|json_encode|e('html_attr') }}" data-color-names="{{ colorNames|json_encode|e('html_attr') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('theme') }}">
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Farbpalette <span class="count" data-color-count>{{ theme.colors|length }}</span></h2>
|
||||
<span class="muted">Jede Farbe hat einen Schlüssel – im Editor als <code>@schlüssel</code>, und eine Änderung hier wirkt überall</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="palette-presets" role="group" aria-label="Fertige Paletten" hidden data-palette-presets>
|
||||
<span class="palette-presets-label">Fertige Palette übernehmen</span>
|
||||
<div class="palette-preset-row">
|
||||
{% for preset in palettes %}
|
||||
<button type="button" class="palette-preset" data-palette-preset="{{ loop.index0 }}" aria-label="Palette {{ preset.name }} übernehmen">
|
||||
<svg viewBox="0 0 50 14" aria-hidden="true" focusable="false">{% for key, value in preset.colors %}<rect x="{{ loop.index0 * 10 }}" y="0" width="10" height="14" fill="{{ value }}"></rect>{% endfor %}</svg>
|
||||
<span>{{ preset.name }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field-help" data-palette-status role="status">Ersetzt die fünf Grundfarben; eigene Farben bleiben. Gilt erst nach dem Speichern.</p>
|
||||
</div>
|
||||
<ul class="swatch-list" data-color-list>
|
||||
{% for row in theme.colors|merge([emptyColor, emptyColor]) %}{{ karte.color(loop.index0, row) }}{% endfor %}
|
||||
</ul>
|
||||
<template data-color-template>{{ karte.color('__n__', emptyColor) }}</template>
|
||||
<div class="list-actions">
|
||||
<button type="button" class="secondary" data-add-color hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Farbe hinzufügen</button>
|
||||
<p class="field-help">Eine Farbe ohne Schlüssel wird nicht gespeichert – so lässt sich eine Farbe auch ohne Skript entfernen. Die beiden leeren Karten sind für neue Farben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Schriften <span class="count" data-font-count>{{ theme.fonts|length }}</span></h2>
|
||||
<span class="muted">Systemschrift oder Google Fonts – ausgeliefert vom eigenen Server</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="font-list" data-font-list>
|
||||
{% for row in theme.fonts|merge([emptyFont]) %}{{ karte.font(loop.index0, row, fallbacks, weights) }}{% endfor %}
|
||||
</ul>
|
||||
<template data-font-template>{{ karte.font('__n__', emptyFont, fallbacks, weights) }}</template>
|
||||
<div class="list-actions">
|
||||
<button type="button" class="secondary" data-add-font hidden>{% include 'admin/icon.html.twig' with {name: 'plus'} only %}Schrift hinzufügen</button>
|
||||
<p class="field-help">Bei Google Fonts den Namen genau wie dort schreiben, zum Beispiel <code>Inter</code> oder <code>Playfair Display</code>. Je Schnitt wird eine Datei geladen – nur ankreuzen, was wirklich gebraucht wird. {% if fontsStylesheet %}Die Probe nutzt die Schriftdateien auf diesem Server; eine neue Schrift erscheint darin erst, wenn sie unten geholt wurde.{% else %}Die Probe zeigt eine Google-Schrift erst, wenn sie unten auf diesen Server geholt wurde.{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Grundtypografie</h2>
|
||||
<span class="muted">Gilt überall, solange ein Block nichts anderes sagt</span>
|
||||
</div>
|
||||
<div class="card-body type-layout">
|
||||
<div class="type-fields">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="base-font">Schrift für Fließtext</label>
|
||||
<select id="base-font" name="base[fontFamily]" data-font-select>
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-heading-font">Schrift für Überschriften</label>
|
||||
<select id="base-heading-font" name="base[headingFontFamily]" data-font-select>
|
||||
{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.base.headingFontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-size">Grundgröße</label>
|
||||
<input id="base-size" name="base[fontSize]" value="{{ theme.base.fontSize }}" placeholder="18px">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-line">Zeilenhöhe</label>
|
||||
<input id="base-line" name="base[lineHeight]" type="number" step="0.05" min="0.8" max="3" value="{{ theme.base.lineHeight }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-scale">Überschriftenskala</label>
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
<p class="field-help">Abschnitte mit der Inhaltsbreite „Breit“ oder „Randlos“ halten sich nicht daran – das ist der Zweck dieser beiden.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fields type-colors">
|
||||
<div>
|
||||
<label for="base-color">Textfarbe</label>
|
||||
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink" data-base-color>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-bg">Seitenfarbe</label>
|
||||
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper" data-base-color>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-link">Linkfarbe</label>
|
||||
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent" data-base-color>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Aus der Palette wählen oder eine eigene Farbe eintragen. Eine Palettenfarbe folgt späteren Änderungen an der Palette, eine eigene bleibt, wie sie ist.</p>
|
||||
<div class="field-wide">
|
||||
<label class="check-label" for="base-underline"><input id="base-underline" name="base[linkUnderline]" type="checkbox" value="1"{{ (theme.base.linkUnderline ?? true) ? ' checked' : '' }} data-base-underline> Links im Fließtext unterstreichen</label>
|
||||
<p class="field-help">Aus sieht ruhiger aus, aber dann erkennt man einen Link im Text nur an der Farbe – für Menschen mit Farbsehschwäche also gar nicht. Beim Überfahren erscheint der Strich in beiden Fällen. Menüs, Karten und Schaltflächen sind davon nicht betroffen.</p>
|
||||
</div>
|
||||
</div>
|
||||
{# Die Probe: so liest sich die Website mit diesen Werten. Ohne Skript in der Schrift
|
||||
der Verwaltung, mit Skript in Schrift, Größe, Zeilenhöhe und Farben von hier. #}
|
||||
<aside class="type-preview-column" aria-label="Vorschau der Grundtypografie">
|
||||
<h3 class="form-part">Vorschau</h3>
|
||||
<div class="type-preview" data-type-preview>
|
||||
<p class="type-preview-h1" data-type-h1>Überschrift der Seite</p>
|
||||
<p class="type-preview-h2" data-type-h2>Ein Abschnitt darunter</p>
|
||||
<p class="type-preview-p" data-type-p>Fließtext in der Grundgröße, mit <span class="type-preview-link" data-type-link>einem Link im Text</span> und genug Zeilen, um die Zeilenhöhe zu sehen. Gute Typografie fällt nicht auf – sie liest sich einfach leicht.</p>
|
||||
</div>
|
||||
<p class="field-help">Überschriften folgen der Skala: die erste Ebene ist vier Stufen über der Grundgröße, die zweite drei.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<fieldset class="button-defaults" id="button-defaults" data-schema="{{ {colors: theme.colors, fonts: theme.fonts, buttonPresets: buttonPresets}|json_encode|e('html_attr') }}">
|
||||
<legend>Standardbutton</legend>
|
||||
<p class="field-help">Die Grundlage für alle Button-Elemente. Eigene Varianten und Gestaltung im Editor haben Vorrang.</p>
|
||||
<div class="button-defaults-layout">
|
||||
<div class="fields">
|
||||
{% for key, options in buttonOptions %}<div><label for="buttons-{{ key }}">{{ {shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe'}[key] }}</label><select id="buttons-{{ key }}" name="buttons[{{ key }}]">{% for value, label in options %}<option value="{{ value }}"{{ theme.buttons[key] == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>{% endfor %}
|
||||
<div><label for="buttons-fontFamily">Button-Schrift</label><select id="buttons-fontFamily" name="buttons[fontFamily]"><option value="inherit">Schrift der Umgebung übernehmen</option>{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.buttons.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}</select></div>
|
||||
<div><label for="buttons-fontFamily">Button-Schrift</label><select id="buttons-fontFamily" name="buttons[fontFamily]" data-font-select><option value="inherit">Schrift der Umgebung übernehmen</option>{% for font in theme.fonts %}<option value="{{ font.key }}"{{ theme.buttons.fontFamily == font.key ? ' selected' : '' }}>{{ font.name }}</option>{% endfor %}</select></div>
|
||||
<div><label for="buttons-fontSize">Button-Schriftgröße (px)</label><input id="buttons-fontSize" type="number" min="10" max="72" step="0.5" name="buttons[fontSize]" value="{{ theme.buttons.fontSize }}"></div>
|
||||
<div><label for="buttons-fontWeight">Button-Schriftstärke</label><select id="buttons-fontWeight" name="buttons[fontWeight]">{% for weight in weights %}<option value="{{ weight }}"{{ theme.buttons.fontWeight == weight ? ' selected' : '' }}>{{ weight }}</option>{% endfor %}</select></div>
|
||||
<div><label for="buttons-letterSpacing">Buchstabenabstand (px)</label><input id="buttons-letterSpacing" type="number" min="-1" max="8" step="0.1" name="buttons[letterSpacing]" value="{{ theme.buttons.letterSpacing }}"></div>
|
||||
@@ -126,7 +218,10 @@
|
||||
<div class="button-preview-column"><h3>Live-Vorschau</h3><div class="button-state-previews" aria-label="Standardbutton-Vorschau"></div><div class="button-preset-grid" role="group" aria-label="Standardbutton-Vorlagen">{% for preset in buttonPresets %}<button type="button" class="button-preset" data-button-preset aria-label="Standardbutton {{ preset.name }}"><span class="button-preset-label">{{ preset.name }}</span></button>{% endfor %}</div></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<button class="primary" type="submit">Design speichern</button>
|
||||
<div class="theme-save">
|
||||
<button class="primary" type="submit">Design speichern</button>
|
||||
<span class="muted">Gilt sofort auf allen Seiten.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{% if areas.header|default(null) %}
|
||||
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}">
|
||||
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}{{ kant_header_surface(areas.header) ? ' has-own-surface' : '' }}">
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% elseif sent %}
|
||||
<p class="form-success" role="status">{{ form.settings.success }}</p>
|
||||
{% else %}
|
||||
<form class="block-form" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
||||
<form class="block-form" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate>
|
||||
<input type="hidden" name="page" value="{{ page.path }}">
|
||||
<input type="hidden" name="block" value="{{ blockId }}">
|
||||
<input type="hidden" name="issued" value="{{ issued }}">
|
||||
@@ -58,6 +58,6 @@
|
||||
<noscript><p class="field-error">Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.</p></noscript>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
<button class="button cms-button" type="submit">Absenden</button>
|
||||
<button class="button cms-button" type="submit">{{ form.settings.buttonLabel|default('Absenden') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -77,7 +77,7 @@
|
||||
{# Header and footer are block documents like any page, so they are built from elements
|
||||
instead of being written into this template. #}
|
||||
{% if areas.header|default(null) %}
|
||||
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}">
|
||||
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}{{ kant_header_surface(areas.header) ? ' has-own-surface' : '' }}">
|
||||
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null), inHeader: true} only %}{% endfor %}
|
||||
</header>
|
||||
{% endif %}
|
||||
@@ -104,8 +104,10 @@
|
||||
Ankerpositionierung auf ihre Oberkante, und ein Anker muss im Dokument davor stehen. #}
|
||||
{% set oben = siteSettings.load() %}
|
||||
{% if oben.scrollTop|default(false) and not editing|default(false) %}
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #}
|
||||
<a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><span aria-hidden="true">↑</span></a>
|
||||
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
|
||||
gibt ihm eine Landmarke: ein Verweis außerhalb jeder Landmarke ist für Vorlesesoftware
|
||||
Inhalt ohne Ort, und axe meldet ihn als Verstoß gegen „region“. #}
|
||||
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben') }}"><span aria-hidden="true">↑</span></a></nav>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -810,6 +810,29 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('class="site-header is-sticky"', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kopfbereich mit eigener Fläche bekommt keine zweite dahinter.
|
||||
*
|
||||
* Beim Nachbau der Tagespflege war die Sektion beim Scrollen auf #ffffff00 gestellt und blieb
|
||||
* trotzdem weiß: der klebende Kopf malte seine eigene Fläche und Schattenlinie darunter.
|
||||
*/
|
||||
public function testAHeaderThatPaintsItsOwnSurfaceGetsNoSecondOne(): void
|
||||
{
|
||||
$settings = $this->site->load();
|
||||
$this->site->save([...$settings, 'stickyHeader' => true, 'sameAs' => '', 'consentGroups' => $settings['consentGroups']]);
|
||||
self::assertStringContainsString('class="site-header is-sticky"', $this->request('/')->getContent(), 'Ohne eigene Fläche bleibt die weiße des Kopfes.');
|
||||
|
||||
self::assertTrue(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['desktop' => ['scrolled' => ['backgroundColor' => '#ffffff00']]]]]]));
|
||||
self::assertTrue(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['mobile' => ['backgroundColor' => '#294655']]]]]));
|
||||
// Eine Textfarbe ist keine Fläche, und ein Kasten in einer Spalte macht noch keinen Kopf.
|
||||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'style' => ['desktop' => ['color' => '#294655']]]]]));
|
||||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(['blocks' => [['id' => 's', 'type' => 'section', 'children' => [['id' => 'c', 'type' => 'column', 'style' => ['desktop' => ['backgroundColor' => '#ffffff']]]]]]]));
|
||||
self::assertFalse(\App\Twig\HeaderExtension::ownSurface(null));
|
||||
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||
self::assertStringContainsString('.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}', $css);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, bool}> */
|
||||
public static function imageLinks(): iterable
|
||||
{
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Domain\InvalidDocument;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Umut: "bei Rows die Breite ändern, am besten per Drag and Drop im Editor". The editor writes
|
||||
* the shares of a row's columns; this is what the server makes of them.
|
||||
*/
|
||||
final class ColumnWidthsTest extends TestCase
|
||||
{
|
||||
public function testSharesBecomeTracksAndStackWhereTheLayoutStacks(): void
|
||||
{
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'zeile', 'type' => 'row',
|
||||
'layout' => ['design' => ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']]],
|
||||
'style' => ['desktop' => ['columnWidths' => '66.7 33.3']],
|
||||
'children' => [['id' => 'a', 'type' => 'column'], ['id' => 'b', 'type' => 'column']],
|
||||
]]]);
|
||||
// The desktop rule carries an id and would win over the class that stacks a phone, so
|
||||
// the phone gets a single track of its own.
|
||||
self::assertSame(
|
||||
'#zeile{grid-template-columns:minmax(0,66.7fr) minmax(0,33.3fr)}@media (max-width:767px){#zeile{grid-template-columns:minmax(0,1fr)}}',
|
||||
$css,
|
||||
);
|
||||
}
|
||||
|
||||
public function testSharesArePaddedOrCutToTheColumnsAndCanDifferPerDevice(): void
|
||||
{
|
||||
$css = (new StyleCompiler())->compileDocument(['blocks' => [[
|
||||
'id' => 'zeile', 'type' => 'row',
|
||||
'style' => [
|
||||
'desktop' => ['columnWidths' => '2 1'],
|
||||
'tablet' => ['columnWidths' => '1 1 1 1'],
|
||||
'mobile' => ['gridColumns' => 1],
|
||||
],
|
||||
'children' => [['id' => 'a', 'type' => 'column'], ['id' => 'b', 'type' => 'column'], ['id' => 'c', 'type' => 'column']],
|
||||
]]]);
|
||||
// A column added after the drag gets the average share; a surplus share is dropped.
|
||||
self::assertStringContainsString('#zeile{grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,1.5fr)}', $css);
|
||||
self::assertStringContainsString('@media (max-width:1024px){#zeile{grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,1fr)}}', $css);
|
||||
self::assertStringContainsString('@media (max-width:767px){#zeile{grid-template-columns:minmax(0,1fr)}}', $css);
|
||||
// A single column has nothing to share, and only rows have columns.
|
||||
self::assertSame('', (new StyleCompiler())->compileDocument(['blocks' => [
|
||||
['id' => 'einzeln', 'type' => 'row', 'style' => ['desktop' => ['columnWidths' => '2 1']], 'children' => [['id' => 'a', 'type' => 'column']]],
|
||||
]]));
|
||||
}
|
||||
|
||||
public function testOnlyListsOfPositiveSharesAreAccepted(): void
|
||||
{
|
||||
$compiler = new StyleCompiler();
|
||||
foreach (['2 1', '66.7 33.3', '25 50 25', '1'] as $good) {
|
||||
$compiler->validate(['desktop' => ['columnWidths' => $good]]);
|
||||
}
|
||||
foreach (['2 0', '2,1', '2 1', ' 2 1', '2fr 1fr', '1 1 1 1 1 1 1 1 1 1 1 1 1', '1000 1', '1.25 1'] as $bad) {
|
||||
try {
|
||||
$compiler->validate(['desktop' => ['columnWidths' => $bad]]);
|
||||
self::fail(sprintf('"%s" darf keine Spaltenaufteilung sein.', $bad));
|
||||
} catch (InvalidDocument $e) {
|
||||
self::assertStringContainsString('column shares', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Security\UserProvider;
|
||||
@@ -321,7 +323,8 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame('Neu', $this->db->fetchOne('SELECT name FROM menu WHERE slot = ?', ['header']));
|
||||
}
|
||||
|
||||
private function contactForm(): FormRepository
|
||||
/** @param array<string, string> $settings overrides for the mail settings */
|
||||
private function contactForm(array $settings = []): FormRepository
|
||||
{
|
||||
$forms = new FormRepository($this->db);
|
||||
$forms->save('kontakt', 'Kontakt', [
|
||||
@@ -330,7 +333,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
['name' => 'thema', 'type' => 'select', 'label' => 'Thema', 'required' => true, 'options' => ['Beratung', 'Support']],
|
||||
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Nachricht', 'required' => false],
|
||||
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich willige ein.'],
|
||||
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.'], 0);
|
||||
], ['recipient' => 'info@kant-it.de', 'subject' => 'Anfrage', 'replyToField' => 'email', 'success' => 'Danke.', ...$settings], 0);
|
||||
|
||||
return $forms;
|
||||
}
|
||||
@@ -379,6 +382,12 @@ final class EditorSecurityTest extends WebTestCase
|
||||
// Post/Redirect/Get, so reloading the result page sends nothing twice.
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertStringContainsString('?gesendet=kontakt#formblock', (string) $client->getResponse()->headers->get('Location'));
|
||||
// Ohne eigene Absenderadresse geht die Post vom Empfaenger an sich selbst.
|
||||
$mail = self::getMailerMessage();
|
||||
self::assertNotNull($mail);
|
||||
self::assertEmailAddressContains($mail, 'From', 'info@kant-it.de');
|
||||
self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de');
|
||||
self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com');
|
||||
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM form_submission');
|
||||
self::assertNotFalse($row);
|
||||
@@ -395,6 +404,33 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertStringContainsString('Danke.', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testAFormMaySendFromItsOwnAddress(): void
|
||||
{
|
||||
// Umut: die Versandadresse soll sich festlegen lassen - bisher war sie immer der
|
||||
// Empfaenger, und manche Hoster nehmen fremde Absender gar nicht erst an.
|
||||
$forms = $this->contactForm(['sender' => 'formular@kant-it.de']);
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
$mail = self::getMailerMessage();
|
||||
self::assertNotNull($mail);
|
||||
self::assertEmailAddressContains($mail, 'From', 'formular@kant-it.de');
|
||||
self::assertEmailAddressContains($mail, 'To', 'info@kant-it.de');
|
||||
self::assertEmailAddressContains($mail, 'Reply-To', 'umut@example.com');
|
||||
|
||||
// Eine kaputte Absenderadresse wird abgewiesen, eine leere bedeutet: wie der Empfaenger.
|
||||
try {
|
||||
$forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => 'kein-postfach'], 1);
|
||||
self::fail('Eine ungueltige Absenderadresse darf nicht gespeichert werden.');
|
||||
} catch (InvalidForm $e) {
|
||||
self::assertStringContainsString('Absenderadresse', $e->getMessage());
|
||||
}
|
||||
$forms->save('kontakt', 'Kontakt', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail']], ['recipient' => 'info@kant-it.de', 'sender' => ' '], 1);
|
||||
self::assertSame('', $forms->findBySlug('kontakt')['settings']['sender']);
|
||||
}
|
||||
|
||||
public function testInvalidSubmissionKeepsEveryEntryAndNamesTheOffendingFields(): void
|
||||
{
|
||||
$this->contactForm();
|
||||
@@ -474,6 +510,46 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
|
||||
}
|
||||
|
||||
public function testAFormCarriesItsOwnColoursOntoThePageAndTheAdministrationRefusesBadOnes(): void
|
||||
{
|
||||
$forms = $this->contactForm();
|
||||
$form = $forms->findBySlug('kontakt');
|
||||
// Ohne Gestaltung keine Regel: die Website gilt.
|
||||
self::assertSame('', $forms->css($form, (new Theme($this->db))->load()));
|
||||
$forms->save('kontakt', 'Kontakt', $form['fields'], [...$form['settings'], 'buttonLabel' => 'Anfrage senden', 'fieldBorderColor' => '@ink', 'fieldBackgroundColor' => '#fffdf7', 'buttonColor' => '#124b36'], $form['version']);
|
||||
$this->pageWithForm();
|
||||
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/');
|
||||
self::assertResponseIsSuccessful();
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
self::assertSame('kontakt', $crawler->filter('.block-form')->attr('data-form'));
|
||||
self::assertSame('Anfrage senden', $crawler->filter('.block-form button[type="submit"]')->text());
|
||||
// Rahmen und Grund der Felder direkt, der Button ueber die Token des Standardbuttons - und
|
||||
// die Ableitung der Darstellung noch einmal, damit sie diese Fuellung nimmt, nicht die vom
|
||||
// Wurzelelement.
|
||||
preg_match('/\.block-form\[data-form="kontakt"\]\{([^}]*)\}/', $html, $rule);
|
||||
self::assertStringContainsString('--form-border:var(--k-ink)', $rule[1] ?? '');
|
||||
self::assertStringContainsString('--form-bg:#fffdf7', $rule[1] ?? '');
|
||||
self::assertStringContainsString('--kb-fill:#124b36', $rule[1] ?? '');
|
||||
self::assertStringContainsString('--kb-bg:var(--kb-fill)', $rule[1] ?? '');
|
||||
self::assertStringNotContainsString('--kb-ink:', $rule[1] ?? '', 'Nur gesetzte Farben werden geschrieben.');
|
||||
// Die Regel steht im gehashten Stylesblock, den die Richtlinie erlaubt.
|
||||
preg_match('#<style>(.*?)</style>#s', $html, $style);
|
||||
self::assertStringContainsString("'sha256-".base64_encode(hash('sha256', $style[1], true))."'", (string) $client->getResponse()->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Eine Farbe, die keine ist, wird mit Meldung abgelehnt und nichts geaendert.
|
||||
$crawler = $client->request('GET', '/admin/forms');
|
||||
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
|
||||
self::assertCount(5, $crawler->filter('.form-def')->first()->filter('.field-card:not(.field-card-new)'), 'Jedes Feld ist eine Karte.');
|
||||
self::assertSame('Anfrage senden', $crawler->filter('.form-def')->first()->filter('[data-preview-button]')->text());
|
||||
$client->request('POST', '/admin/forms/kontakt', ['_csrf_token' => $token, 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 2, 'buttonColor' => 'rot',
|
||||
'fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]]);
|
||||
$client->followRedirect();
|
||||
self::assertSelectorTextContains('.notice.error', 'kein Farbwert für den Button');
|
||||
self::assertSame('#124b36', $forms->findBySlug('kontakt')['settings']['buttonColor']);
|
||||
}
|
||||
|
||||
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
@@ -770,9 +846,13 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$crawler = $client->request('GET', '/admin/statistik');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('2', $crawler->filter('.card-head .count')->first()->text());
|
||||
// Both charts - days and hours - are drawn by the server, so they are in the markup and
|
||||
// need no script.
|
||||
self::assertCount(2, $crawler->filter('svg.chart[role="img"]'));
|
||||
// All three charts - days, hours and weekdays - are drawn by the server, so they are in
|
||||
// the markup and need no script.
|
||||
self::assertCount(3, $crawler->filter('svg.chart[role="img"]'));
|
||||
// Jeder Balken sagt beim Ueberfahren, wofuer er steht - auch das ohne Skript.
|
||||
self::assertStringContainsString('2 Aufrufe', $crawler->filter('svg.chart-days rect title')->last()->text());
|
||||
self::assertSame('2', trim($crawler->filter('.stat-tile-main .stat-value')->text()), 'Die Hauptkachel traegt die Summe.');
|
||||
self::assertSame('1', trim($crawler->filter('.stat-tile')->last()->filter('.stat-value')->text()), 'Die letzte Kachel zaehlt die nicht gefundene Adresse.');
|
||||
self::assertCount(0, $crawler->filter('svg [style]'), 'Inline styles are forbidden by the policy.');
|
||||
self::assertStringContainsString('/gibt-es-nicht', $crawler->filter('.stats-list')->last()->text());
|
||||
// And it says what it cannot answer.
|
||||
|
||||
@@ -156,6 +156,40 @@ final class StatisticsTest extends TestCase
|
||||
// The hours are summed over the whole range, so both days contribute to 14:00.
|
||||
self::assertSame(5, $overview['hours'][14]['count']);
|
||||
self::assertCount(24, $overview['hours']);
|
||||
|
||||
// Die Summen, die eine Zahl einordnen: nichts davor, der staerkste Tag ist heute, eine
|
||||
// Adresse lief ins Leere, vier Aufrufe kamen ueber einen Verweis, zwei Seiten wurden gesehen.
|
||||
self::assertSame(0, $overview['previousTotal']);
|
||||
self::assertSame('2026-09-12', $overview['busiestDay']);
|
||||
self::assertSame(1, $overview['missingTotal']);
|
||||
self::assertSame(4, $overview['referred']);
|
||||
self::assertSame(2, $overview['pageCount']);
|
||||
// Der 12.09.2026 ist ein Samstag, drei Tage davor ein Mittwoch; jeder Wochentag kommt in
|
||||
// sieben Tagen genau einmal vor, der Schnitt ist also die Summe.
|
||||
self::assertCount(7, $overview['weekdays']);
|
||||
self::assertSame(['weekday' => 6, 'count' => 4, 'average' => 4.0], $overview['weekdays'][5]);
|
||||
self::assertSame(['weekday' => 3, 'count' => 1, 'average' => 1.0], $overview['weekdays'][2]);
|
||||
self::assertSame(0.0, $overview['weekdays'][0]['average']);
|
||||
}
|
||||
|
||||
public function testTheOverviewComparesWithTheSameLengthOfTimeBefore(): void
|
||||
{
|
||||
// Three views eight to ten days ago lie in the seven days before a seven-day range.
|
||||
foreach ([8, 9, 10] as $daysAgo) {
|
||||
$this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify(sprintf('-%d days', $daysAgo)));
|
||||
}
|
||||
// And one 14 days ago is already outside that comparison window.
|
||||
$this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify('-14 days'));
|
||||
$this->stats->record('/', 200, '', 'kant-it.de', $this->now);
|
||||
|
||||
$overview = $this->stats->overview(7, $this->now);
|
||||
self::assertSame(1, $overview['total']);
|
||||
self::assertSame(3, $overview['previousTotal']);
|
||||
// A 404 in the period before does not count as a view there either.
|
||||
$this->stats->record('/weg', 404, '', 'kant-it.de', $this->now->modify('-9 days'));
|
||||
self::assertSame(3, $this->stats->overview(7, $this->now)['previousTotal']);
|
||||
// Without a single view there is no busiest day to point at.
|
||||
self::assertNull($this->stats->overview(3, $this->now->modify('-30 days'))['busiestDay']);
|
||||
}
|
||||
|
||||
public function testOlderDaysAreCompactedAndTheOldestAreRemoved(): void
|
||||
|
||||
@@ -25,6 +25,10 @@ test('design fields follow block type without hiding existing stored values', ()
|
||||
assert.equal(relevantProperty(image, 'objectFit', { group: 'image' }), true);
|
||||
assert.equal(relevantProperty(text, 'fontSize', { group: 'text' }), true);
|
||||
assert.equal(relevantProperty(text, 'gridColumns', { group: 'layout' }), false);
|
||||
// Column shares belong to a row and to nothing else - not even to the column itself.
|
||||
assert.equal(relevantProperty(createBlock('row'), 'columnWidths', { group: 'layout' }), true);
|
||||
assert.equal(relevantProperty(createBlock('column'), 'columnWidths', { group: 'layout' }), false);
|
||||
assert.equal(relevantProperty(text, 'columnWidths', { group: 'layout' }), false);
|
||||
image.style = { mobile: { fontSize: '14px' } };
|
||||
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), true);
|
||||
});
|
||||
@@ -504,7 +508,7 @@ test('the top level takes everything the validator takes there, and nothing else
|
||||
// Die Struktur, die der Editor in seinem eigenen Hinweistext beschreibt, gilt jetzt auch fuer
|
||||
// die Schaltflaechen: eine Zeile nahm bisher jedes Kind, eine Spalte stand ueberall.
|
||||
|
||||
test('a row takes columns and nothing else, a column only lives in a row', () => {
|
||||
test('a row takes columns and nothing else, a column lives in a row or alone in a section', () => {
|
||||
const document = fixture();
|
||||
const rowId = insertRow(document, 3, 's2');
|
||||
const row = locate(document.blocks, rowId).block;
|
||||
@@ -514,8 +518,10 @@ test('a row takes columns and nothing else, a column only lives in a row', () =>
|
||||
assert.equal(insertBlockAt(document, 'heading', rowId, 0), null);
|
||||
assert.equal(row.children.length, 3);
|
||||
assert.ok(insertBlockAt(document, 'heading', row.children[0].id, 0));
|
||||
// Eine Spalte gehoert nicht in eine Sektion.
|
||||
assert.equal(insertBlockAt(document, 'column', 's2', 0), null);
|
||||
// Eine einzelne Spalte darf direkt in einer Sektion stehen - so wird ein Button zentriert -,
|
||||
// aber nicht in einer Spalte oder lose im Dokument.
|
||||
assert.ok(insertBlockAt(document, 'column', 's2', 0));
|
||||
assert.equal(insertBlockAt(document, 'column', row.children[0].id, 0), null);
|
||||
assert.ok(insertBlockAt(document, 'column', rowId, 3));
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user