diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index 13d7dbb..48e2147 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -1,5 +1,5 @@ -export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' }; -export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' }; +export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'}; +export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' }; // A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the // document, stay selectable and movable, but the editor never edits or fills them. export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/; @@ -38,6 +38,7 @@ export function createBlock(type) { if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' }; if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' }; if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' }; + if (type === 'legal') block.props = { label: 'Rechtliches' }; if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' }; if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' }; if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 }; @@ -63,6 +64,7 @@ export function insertBlock(document, type, selected) { if (type === 'gallery') upgradeDocument(document, 3); if (type === 'postlist') upgradeDocument(document, 5); if (type === 'postnav') upgradeDocument(document, 18); + if (type === 'legal') upgradeDocument(document, 19); if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6); if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7); if (type === 'form') upgradeDocument(document, 8); @@ -114,7 +116,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' } // blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument // liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt // nirgends. Eine Zahl, an einer Stelle. -export const SCHEMA_VERSION = 18; +export const SCHEMA_VERSION = 19; const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION; export function upgradeDocument(document, version = 2) { diff --git a/public/assets/editor.js b/public/assets/editor.js index 34e3598..e2f67a7 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -170,6 +170,7 @@ function renderProperties() { } else current[key] = next; if (key === 'caption') upgradeDocument(doc, 3); + if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19); if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; } }, `${selected || 'page'}:${key}`); if (key === 'decorative') renderProperties(); @@ -281,6 +282,9 @@ function renderProperties() { field('Titel', 'title', block.props.title, { maxLength: 160 }); field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true }); } + if (block.type === 'legal') { + field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Steht er im Fußbereich, entfällt die eingebaute Zeile am Seitenende.' }); + } if (block.type === 'postnav') { field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 }); field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' }); @@ -297,7 +301,8 @@ function renderProperties() { } if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' }); if (block.type === 'image') { - field('Linkziel', 'href', block.props.href || '', { help: 'Leer lassen, wenn das Bild nicht verlinkt. Für ein Logo im Kopfbereich meist /.' }); + field('Beim Klick', 'click', block.props?.click || (block.props?.href ? 'link' : 'keine'), { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, help: 'Vergrößern öffnet das Bild über der Seite. Ohne JavaScript öffnet derselbe Klick die große Bilddatei – die Seite bleibt also in jedem Fall benutzbar.' }); + if ((block.props?.click || (block.props?.href ? 'link' : 'keine')) === 'link') field('Linkziel', 'href', block.props.href || '', { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' }); const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen'; picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker); field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' }); @@ -308,6 +313,7 @@ function renderProperties() { field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 }); } if (block.type === 'gallery') { + field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' }); const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`; const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen'; picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker); diff --git a/public/assets/lightbox.js b/public/assets/lightbox.js new file mode 100644 index 0000000..f55f8f2 --- /dev/null +++ b/public/assets/lightbox.js @@ -0,0 +1,101 @@ +/* + * Der Bildbetrachter. + * + * Ohne dieses Skript ist ein vergrößerbares Bild schlicht ein Verweis auf die große Fassung: ein + * Klick öffnet sie, zurück führt die Zurück-Taste. Das ist die Grundlage, und sie funktioniert + * immer. Dieses Skript fängt den Klick ab und zeigt das Bild stattdessen über der Seite - mit + * Blättern, wenn es zu einer Galerie gehört. + * + * Benutzt wird : der Browser bringt damit das Fangen des Fokus, das Schließen mit Escape + * und die Auszeichnung als Dialog schon mit. Eine nachgebaute Fassung aus
und Tastatur- + * abfragen wäre mehr Code und weniger zugänglich. + */ +(() => { + const zoomable = [...document.querySelectorAll('a[data-zoom]')]; + if (0 === zoomable.length) { + return; + } + + const dialog = document.createElement('dialog'); + dialog.className = 'zoom-dialog'; + dialog.innerHTML = ` +
+ +
+
+ + + +

+ `; + document.body.append(dialog); + + const bild = dialog.querySelector('.zoom-image'); + const beschriftung = dialog.querySelector('.zoom-caption'); + const zaehler = dialog.querySelector('.zoom-counter'); + const zurueck = dialog.querySelector('.zoom-prev'); + const vor = dialog.querySelector('.zoom-next'); + + /** Die Bilder, zwischen denen geblättert wird: die der Galerie, sonst nur dieses eine. */ + const gruppeVon = (link) => { + const galerie = link.closest('[data-zoom-group]'); + + return galerie ? [...galerie.querySelectorAll('a[data-zoom]')] : [link]; + }; + + let gruppe = []; + let stelle = 0; + + const zeige = (index) => { + stelle = (index + gruppe.length) % gruppe.length; + const link = gruppe[stelle]; + const inneres = link.querySelector('img'); + bild.src = link.href; + bild.alt = inneres ? inneres.alt : ''; + const text = link.dataset.caption || ''; + beschriftung.textContent = text; + beschriftung.hidden = '' === text; + const mehrere = gruppe.length > 1; + zurueck.hidden = !mehrere; + vor.hidden = !mehrere; + zaehler.hidden = !mehrere; + zaehler.textContent = mehrere ? `${stelle + 1} von ${gruppe.length}` : ''; + }; + + for (const link of zoomable) { + link.addEventListener('click', event => { + // Mit gedrückter Zusatztaste oder mittlerer Maustaste bleibt es ein normaler Verweis: + // wer ein Bild in einem neuen Reiter öffnen will, soll das können. + if (event.metaKey || event.ctrlKey || event.shiftKey || 1 === event.button) { + return; + } + event.preventDefault(); + gruppe = gruppeVon(link); + zeige(gruppe.indexOf(link)); + dialog.showModal(); + }); + } + + zurueck.addEventListener('click', () => zeige(stelle - 1)); + vor.addEventListener('click', () => zeige(stelle + 1)); + dialog.querySelector('.zoom-close').addEventListener('click', () => dialog.close()); + // Ein Klick neben das Bild schließt - erwartet, und der Dialog selbst ist die Fläche daneben. + dialog.addEventListener('click', event => { + if (event.target === dialog) { + dialog.close(); + } + }); + dialog.addEventListener('keydown', event => { + if (1 === gruppe.length) { + return; + } + if ('ArrowLeft' === event.key) { + event.preventDefault(); + zeige(stelle - 1); + } + if ('ArrowRight' === event.key) { + event.preventDefault(); + zeige(stelle + 1); + } + }); +})(); diff --git a/public/assets/site.css b/public/assets/site.css index 075baa8..1873a74 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -113,8 +113,7 @@ p{hyphens:auto} for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come later, so the dark tone wins where it has to. */ -.tone-ink .block-column .block-text, -.tone-ink +.tone-ink .block-column .block-text{color:#dae3da} /* The consent question. Fixed to the bottom, but it never covers the whole page: the content stays readable while the decision is open, because a wall would make "accept" the only way @@ -163,10 +162,6 @@ body.consent-open{padding-bottom:260px} .site-footer .block-column{border:0;border-radius:0;padding:0} .site-header .block-text, .site-footer .block-text{margin:0} -/* Size and weight only, never colour: the tone of the area decides that, and a colour here won - against .tone-ink and put 2.24:1 grey text on the dark footer. The browser check caught it. */ -.site-header -.site-footer /* One landmark for the three links that must be on every page. Loose text outside a landmark is also what axe objects to, and rightly: a screen reader then has no way to skip to it. */ .legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))} @@ -338,3 +333,44 @@ body.consent-open{padding-bottom:260px} .menu-burger[open]>summary{border-radius:8px 8px 0 0} .menu-burger-body{padding:14px 0 4px} .menu-burger-body .menu-list{flex-direction:column;align-items:flex-start;gap:8px} +/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das + kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll- + Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch + Safari), steht er von Anfang an da: sichtbar und nutzbar ist besser als unsichtbar. + Aussehen, Größe, Form, Seite und Schwelle kommen aus den Einstellungen. */ +.scroll-top{position:fixed;bottom:22px;right:22px;z-index:40;display:grid;place-items:center;width:var(--kant-top-size,48px);height:var(--kant-top-size,48px);border-radius:var(--kant-top-radius,999px);background:var(--kant-top-bg,var(--accent));color:var(--kant-top-ink,#fff);text-decoration:none;box-shadow:0 8px 24px color-mix(in srgb,var(--ink) 28%,transparent);font-size:calc(var(--kant-top-size,48px) / 2.4);line-height:1} +.scroll-top-links{right:auto;left:22px} +.scroll-top:hover{filter:brightness(1.08)} +.scroll-top:focus-visible{outline:3px solid var(--accent);outline-offset:3px} +@media (max-width:767px){.scroll-top{bottom:16px;right:16px}.scroll-top-links{left:16px;right:auto}} +/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */ +body.consent-open .scroll-top{display:none} +@supports (animation-timeline: scroll()){ + @media (prefers-reduced-motion: no-preference){ + .scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)} + @keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}} + } +} +/* Der Bildbetrachter. Die Fläche daneben schließt, das Bild bleibt in seinen Grenzen, und die + Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann + öffnet der Verweis einfach das große Bild. */ +.block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px} +.block-legal a{color:inherit;text-decoration:none} +.block-legal a:hover{text-decoration:underline} +.image-zoom{cursor:zoom-in} +.zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible} +.zoom-dialog::backdrop{background:rgba(8,12,20,.88)} +.zoom-figure{margin:0;display:grid;gap:12px;justify-items:center} +.zoom-image{max-width:100%;max-height:82vh;width:auto;height:auto;border-radius:10px;background:#0c1220} +.zoom-caption{margin:0;font-size:14px;text-align:center;max-width:70ch;color:#e7ecf3} +.zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer} +.zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)} +.zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px} +.zoom-close{top:18px;right:18px} +.zoom-prev{left:18px;top:50%;transform:translateY(-50%)} +.zoom-next{right:18px;top:50%;transform:translateY(-50%)} +.zoom-counter{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);margin:0;font-size:13px;color:#e7ecf3} +@media (max-width:767px){ + .zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px} + .zoom-prev{left:8px}.zoom-next{right:8px} +} diff --git a/scripts/browser-check.mjs b/scripts/browser-check.mjs index de5a478..3f0bd34 100644 --- a/scripts/browser-check.mjs +++ b/scripts/browser-check.mjs @@ -149,6 +149,13 @@ try { await page.screenshot({ path: `reports/${width}-${path === '/' ? 'home' : path.slice(1)}.png`, fullPage: true }); } await page.goto(origin); + // Steht der Einwilligungshinweis offen, liegt er über dem Inhalt - ein Besucher entscheidet + // sich zuerst, und danach ist die Seite frei. Genau so geht dieser Test auch vor. + const hinweis = page.locator('.consent-bar button[value="none"]'); + if (await hinweis.count()) { + await hinweis.click(); + await page.waitForLoadState('domcontentloaded'); + } await page.keyboard.press('Tab'); assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen'); await page.keyboard.press('Enter'); @@ -168,6 +175,11 @@ try { // keines, aber das soll gemessen und nicht geglaubt werden. const noJs = await browser.newPage({ javaScriptEnabled: false }); await noJs.goto(origin); + const hinweisOhneJs = noJs.locator('.consent-bar button[value="none"]'); + if (await hinweisOhneJs.count()) { + await hinweisOhneJs.click(); + await noJs.waitForLoadState('domcontentloaded'); + } assert.equal(await noJs.locator('h1').count(), 1); const ohneJs = noJs.locator('main a[href^="/"]').first(); const zielOhneJs = await ohneJs.getAttribute('href'); diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index ed2af93..4c469de 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -29,6 +29,8 @@ final class DocumentValidator public const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240]; /** Schema v18: die Navigation zwischen Beiträgen, vom Renderer gefüllt. */ public const V18_TYPES = ['postnav']; + /** Die Rechtszeile als Baustein: gestaltbar und dort, wo sie hingehoert. */ + public const V19_TYPES = ['legal']; /** Schema v15: a single icon, a progress bar and a slider that works without a script. */ public const V15_TYPES = ['icon', 'progress', 'slider']; @@ -40,7 +42,7 @@ final class DocumentValidator public function validate(array $document, bool $requireHeading = true): void { $schemaVersion = $document['schemaVersion'] ?? null; - $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], true), 'Unsupported document schema.'); + $this->require(in_array($schemaVersion, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], true), 'Unsupported document schema.'); $this->text($document['title'] ?? null, 160); $this->text($document['description'] ?? null, 320); $this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.'); @@ -68,7 +70,7 @@ final class DocumentValidator // page: a deactivated plugin must never make stored content unreadable. Nothing from // props is rendered for such a block, so unvalidated values never reach the output. $plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type); - $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); + $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); // An accordion entry only ever renders inside an accordion; at the top level or anywhere // else it would produce a details element without its group. $this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.'); @@ -292,6 +294,10 @@ final class DocumentValidator // so a stored document can never redirect a submission or change what is asked. $this->require(is_string($props['form'] ?? null) && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $props['form']) && strlen($props['form']) <= 80, 'Invalid form address.'); } + if ('gallery' === $type && isset($props['lightbox'])) { + $this->require($schemaVersion >= 19, 'Der Bildbetrachter braucht Schema v19.'); + $this->require(is_bool($props['lightbox']), 'Der Bildbetrachter ist an oder aus.'); + } if ('image' === $type) { $this->url($props['src'] ?? null); // Ein Bild darf verlinken. Klingt selbstverstaendlich, fehlte aber: ein Logo im @@ -300,6 +306,12 @@ final class DocumentValidator $this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.'); $this->url($props['href']); } + // Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher. + if (isset($props['click'])) { + $this->require($schemaVersion >= 19, 'Die Klickwirkung eines Bildes braucht Schema v19.'); + $this->require(in_array($props['click'], ['keine', 'vergroessern', 'link'], true), 'Für den Klick sind „keine", „vergroessern" und „link" vorgesehen.'); + $this->require('link' !== $props['click'] || '' !== ($props['href'] ?? ''), 'Ein Bild, das auf eine Adresse zeigen soll, braucht eine.'); + } $this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.'); $this->text($props['alt'] ?? null, 300, $props['decorative']); $this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.'); diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php index 10291d9..5142fc9 100644 --- a/src/Content/SiteSettings.php +++ b/src/Content/SiteSettings.php @@ -34,6 +34,17 @@ final class SiteSettings // Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim // Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen. 'faviconMediaId' => '', + // Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist + // einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung + // passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript. + 'scrollTop' => false, + 'scrollTopPosition' => 'rechts', + 'scrollTopShape' => 'rund', + 'scrollTopSize' => 48, + 'scrollTopColor' => '', + 'scrollTopInk' => '', + 'scrollTopLabel' => 'Nach oben', + 'scrollTopAfter' => 400, // Cloudflare Turnstile fuer Formulare. Aus, weil eine Website ohne Spamwelle keinen // Dritten einbinden soll - und weil die Pruefung JavaScript verlangt. 'turnstile' => false, @@ -138,6 +149,20 @@ final class SiteSettings } $values[$key] = $wert; } + $values['scrollTop'] = (bool) ($input['scrollTop'] ?? false); + $values['scrollTopPosition'] = in_array($input['scrollTopPosition'] ?? '', ['rechts', 'links'], true) ? $input['scrollTopPosition'] : 'rechts'; + $values['scrollTopShape'] = in_array($input['scrollTopShape'] ?? '', ['rund', 'weich', 'eckig'], true) ? $input['scrollTopShape'] : 'rund'; + $values['scrollTopSize'] = max(36, min(72, (int) ($input['scrollTopSize'] ?? 48))); + // Ab wie vielen Pixeln er erscheint. Null heisst: sofort. + $values['scrollTopAfter'] = max(0, min(4000, (int) ($input['scrollTopAfter'] ?? 400))); + $values['scrollTopLabel'] = mb_substr(trim((string) ($input['scrollTopLabel'] ?? '')), 0, 60) ?: 'Nach oben'; + foreach (['scrollTopColor', 'scrollTopInk'] as $feld) { + $farbe = trim((string) ($input[$feld] ?? '')); + if ('' !== $farbe && 1 !== preg_match(StyleSchema::COLOR, $farbe)) { + throw new InvalidPage('Die Farben des Knopfes „nach oben" werden als #1a2b3c oder als @schlüssel aus der Palette angegeben.'); + } + $values[$feld] = $farbe; + } $values['turnstile'] = (bool) ($input['turnstile'] ?? false); if ($values['turnstile'] && ('' === $values['turnstileSiteKey'] || '' === $values['turnstileSecret'])) { // Eingeschaltet ohne Schluessel hiesse: jedes Formular lehnt jede Absendung ab. diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 2ca3bf6..1e13a74 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -54,7 +54,7 @@ final class PageController extends AbstractController $theme = $this->theme->load(); // Theme first, block styles after, so a block always wins over the site defaults. $sections = $this->sections($page['document'], $areas); - $styles = $this->theme->css($theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']); + $styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']); foreach ([...array_values($areas), ...array_values($sections)] as $extra) { $styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra); } @@ -89,6 +89,9 @@ final class PageController extends AbstractController // Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der // dafür ein Skript braucht - nach demselben Muster wie die Tabs. 'needsSlider' => $this->hasAutoplaySlider($page['document']), + // Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die + // grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat. + 'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)), // Own scripts arrive as one file per page, never inline, so the policy can stay // hash based and no page without a script loses its strict setting. 'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js', @@ -107,13 +110,18 @@ final class PageController extends AbstractController 'googleFonts' => $googleFonts, 'localFonts' => $localFonts, 'styleBundle' => $this->bundle->url([] !== $localFonts), + // Steht die Rechtszeile als Baustein im Dokument oder in einem Bereich, entfaellt + // die eingebaute Leiste am Seitenende - zweimal dasselbe waere schlechter als gar + // nichts, und die Pflicht ist mit dem Baustein erfuellt. + 'hasLegalBlock' => $this->hasType($page['document'], 'legal') + || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasType($area, 'legal')), ]); } // Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur // dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website. $mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form'); - $wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || [] !== $this->code->scriptsOf($page['document'])); + $wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document'])); $frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections)); if ($mitTurnstile) { $frames[] = 'challenges.cloudflare.com'; @@ -249,6 +257,50 @@ final class PageController extends AbstractController * * @return list */ + /** + * Die Gestaltungswerte des Knopfes „nach oben", als Variablen für das Stylesheet. + * + * Sie stehen in den Einstellungen und nicht im Theme, weil sie zur Website gehören und nicht + * zum Dokument - aber sie müssen in denselben Stilblock, dessen Prüfsumme in der + * Sicherheitsrichtlinie steht. Ein zweiter Block wäre ein zweiter Hash. + */ + private function scrollTopStyles(array $settings, array $theme): string + { + if (!($settings['scrollTop'] ?? false)) { + return ''; + } + $farbe = static function (string $wert) use ($theme): ?string { + if ('' === $wert) { + return null; + } + if (!str_starts_with($wert, '@')) { + return $wert; + } + foreach ($theme['colors'] as $eintrag) { + if ($eintrag['key'] === substr($wert, 1)) { + return sprintf('var(--k-%s)', $eintrag['key']); + } + } + + return null; + }; + $werte = [sprintf('--kant-top-size:%dpx', (int) ($settings['scrollTopSize'] ?? 48))]; + $werte[] = sprintf('--kant-top-radius:%s', match ($settings['scrollTopShape'] ?? 'rund') { + 'eckig' => '0', + 'weich' => '12px', + default => '999px', + }); + $werte[] = sprintf('--kant-top-after:%dpx', (int) ($settings['scrollTopAfter'] ?? 400)); + foreach (['scrollTopColor' => '--kant-top-bg', 'scrollTopInk' => '--kant-top-ink'] as $feld => $variable) { + $wert = $farbe((string) ($settings[$feld] ?? '')); + if (null !== $wert) { + $werte[] = $variable.':'.$wert; + } + } + + return ':root{'.implode(';', $werte).'}'; + } + /** * Das Bild, das beim Teilen erscheint - und im strukturierten Datensatz. * @@ -323,6 +375,25 @@ final class PageController extends AbstractController } /** True when the stored document contains at least one block of that type. */ + /** Enthaelt das Dokument ein Bild oder eine Galerie, die sich vergroessern laesst? */ + private function hasZoom(array $document): bool + { + $gefunden = false; + $walk = function (array $blocks) use (&$walk, &$gefunden): void { + foreach ($blocks as $block) { + if ('vergroessern' === ($block['props']['click'] ?? null) || true === ($block['props']['lightbox'] ?? null)) { + $gefunden = true; + + return; + } + $walk($block['children'] ?? []); + } + }; + $walk($document['blocks'] ?? []); + + return $gefunden; + } + /** True when a slider on this page asked to advance by itself. */ private function hasAutoplaySlider(array $document): bool { diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index 1587e72..11ab229 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -27,7 +27,7 @@ {% endfor %}
- {% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', postnav:'Beitragsnavigation', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}{% endfor %} + {% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', postnav:'Beitragsnavigation', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', legal:'Rechtszeile', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}{% endfor %}

Fertige Abschnitte

Wird nach dem aktuellen Seitenabschnitt eingefügt.

diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 90417bc..254acf8 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -78,6 +78,49 @@

Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.

+

Knopf „nach oben"

Erscheint beim Scrollen, in der Ecke
+ +

Springt an den Seitenanfang. Er braucht kein JavaScript: In Browsern mit Scroll-Zeitachse blendet er sich beim Scrollen ein, in den übrigen steht er von Anfang an da.

+
+
+ + +
+
+ + +
+
+ + +

36 bis 72 Pixel. Unter 44 wird er auf dem Telefon schwer zu treffen.

+
+
+ + +

Pixel Scrollweg. 0 heißt: sofort.

+
+
+ + +
+
+ + +
+
+ + +
+
+

Farben entweder als @schlüssel aus der Palette oder als Wert wie #1a2b3c. Leer bedeutet: Akzentfarbe des Designs auf Weiß.

Spamschutz für Formulare

Optional, von Cloudflare

Eingebaut sind bereits drei Prüfungen ohne Dritte: ein verstecktes Feld, ein signierter Zeitstempel und höchstens fünf Absendungen je Absender und Stunde. Das hält automatisch ausgefüllte Formulare ab. Gegen jemanden, der einen echten Browser fernsteuert, hilft Cloudflare Turnstile – die Prüfung ohne Bilderrätsel. Der Preis: das Widget braucht JavaScript (ohne Skript ist das Formular dann nicht absendbar), und die IP-Adresse des Absenders geht an Cloudflare, was in die Datenschutzerklärung gehört. Schlüssel gibt es kostenlos im Cloudflare-Konto unter „Turnstile“.

diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index 2d66f1a..5664093 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -37,7 +37,16 @@ für Browser, die „auto“ noch nicht kennen (heute Safari). #} {% set annahme = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px' %} {% set imageSizes = festeBreite ends with 'px' ? festeBreite : (eager or inHeader|default(false) ? annahme : 'auto, ' ~ annahme) %} -
{% if props.href|default('') %}{% endif %}{{ props.alt }}{% if props.href|default('') %}{% endif %}{% if props.caption|default('') %}
{{ props.caption }}
{% endif %}
+ {% set klick = props.click|default(props.href|default('') ? 'link' : 'keine') %} + {% set grossesBild = props.sources|default([]) ? props.sources|last.src : props.src %} +
+ {%- if klick == 'vergroessern' -%} + {# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der + Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #} + + {%- elseif props.href|default('') -%} + + {%- endif -%}{{ props.alt }}{% if klick == 'vergroessern' or props.href|default('') %}{% endif %}{% if props.caption|default('') %}
{{ props.caption }}
{% endif %}
{% elseif type == 'iconcard' %}
{% include 'blocks/icon.html.twig' with {name: props.icon} only %} @@ -213,8 +222,18 @@
{% include 'blocks/post-nav.html.twig' with {previous: neighbours.previous|default(null), next: neighbours.next|default(null), previousLabel: props.previousLabel|default('Vorheriger Beitrag'), nextLabel: props.nextLabel|default('Nächster Beitrag')} only %}
+{% elseif type == 'legal' %} + {# Impressum, Datenschutz und - sofern schon gefragt wurde - der Weg zurück zur Einwilligung. + Als Baustein, damit die Zeile dort steht, wo sie hingehört, und die Farben ihres Bereichs + erbt statt als heller Streifen unter einem dunklen Fuß zu kleben. #} + {% set rechtliches = consentGate.legalLinks() %} + {% set widerruflich = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %} + {% elseif type == 'gallery' %} -
+
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
{% else %} diff --git a/templates/page.html.twig b/templates/page.html.twig index 6228ffb..b64b455 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -43,6 +43,7 @@ {% endif %} {% if needsTabs|default(false) %}{% endif %} {% if needsSlider|default(false) %}{% endif %} + {% if needsZoom|default(false) %}{% endif %} {# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must show the layout, not run code that could change the page under the editor's hands. #} {% if pageScript|default(null) and not editing|default(false) %}{% endif %} @@ -59,6 +60,7 @@ {% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %} + {# Header and footer are block documents like any page, so they are built from elements instead of being written into this template. #} @@ -88,12 +90,18 @@ which someone could delete. One labelled landmark, so they are not loose text either. #} {% set legal = consentGate.legalLinks() %} {% set revocable = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %} - {% if legal is not empty or revocable %} + {# Nur, wenn die Seite die Zeile nicht selbst enthaelt: sonst stuende sie zweimal da. #} + {% if (legal is not empty or revocable) and not hasLegalBlock|default(false) %} {% endif %} + {% set oben = siteSettings.load() %} + {% if oben.scrollTop|default(false) and not editing|default(false) %} + {# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. #} + + {% endif %} {% if not editing|default(false) and consentGate.needsBanner(app.request) %} {% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %} {% endif %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 27e66ef..88ed8fe 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -924,6 +924,117 @@ final class CmsTest extends TestCase self::assertStringContainsString('frame-src https://challenges.cloudflare.com', $csp); } + public function testAPictureCanBeEnlargedAndWorksWithoutScripting(): void + { + // Umut: „Bilder vergrößern Funktion, ein Popup-Viewer - gerne auch auswählbar wie bei + // WordPress." Auswählbar heißt: je Bild entscheidet sich, was ein Klick tut. Und ohne + // Skript bleibt es benutzbar: dann öffnet der Verweis schlicht die große Fassung. + $media = new MediaLibrary($this->db, $this->path.'-media'); + $file = tempnam(sys_get_temp_dir(), 'kant-zoom-'); + $image = imagecreatetruecolor(1600, 900); + imagepng($image, $file); + imagedestroy($image); + $item = $media->upload(new UploadedFile($file, 'motiv.png', test: true)); + @unlink($file); + $quellen = array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $item['variants']); + $gross = end($quellen)['src']; + + $bild = static fn (string $id, array $props): array => ['id' => $id, 'type' => 'image', 'props' => [ + 'src' => $quellen[0]['src'], 'alt' => 'Ein Motiv', 'decorative' => false, + 'width' => 1600, 'height' => 900, 'sources' => $quellen, ...$props, + ]]; + + $document = DemoContent::starter('Startseite'); + $document['schemaVersion'] = 19; + $document['blocks'][] = ['id' => 'galerie', 'type' => 'gallery', 'props' => ['lightbox' => true], 'children' => [ + $bild('g1', ['click' => 'vergroessern', 'caption' => 'Erstes Bild']), + $bild('g2', ['click' => 'vergroessern']), + ]]; + $document['blocks'][] = $bild('einzeln', ['click' => 'vergroessern']); + $document['blocks'][] = $bild('ruhig', []); + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + $antwort = $this->request('/'); + $html = $antwort->getContent(); + // Der Verweis zeigt auf die größte Fassung - ohne Skript ist genau das die Vergrößerung. + self::assertStringContainsString('headers->get('Content-Security-Policy')); + + // Eine Seite ohne vergrößerbares Bild lädt es nicht und bleibt ohne Skript. + $ohne = DemoContent::starter('Startseite'); + $ohne['schemaVersion'] = 19; + $ohne['blocks'][] = $bild('ruhig', []); + $version = $this->pages->findForEditor('page-1')['version']; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $ohne, $version), $version + 1); + $zweite = $this->request('/'); + self::assertStringNotContainsString('lightbox.js', $zweite->getContent()); + self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy')); + } + + public function testTheLegalLineCanLiveInsideTheFooterInsteadOfUnderneathIt(): void + { + // Umut: „ganz unten Einwilligung ändern, Datenschutz usw. ist links, komplett bitte besser + // lösen." Die eingebaute Leiste klebte als heller Streifen unter einem dunklen Fuß. Als + // Baustein steht sie dort, wo sie hingehört, und erbt die Farben ihres Bereichs. + // Zwei verschiedene Seiten: die Zeile fuehrt zu Datenschutz und Impressum. + $datenschutz = $this->pages->createPage('datenschutz', null, 'page', DemoContent::starter('Datenschutz')); + $seite = $this->pages->findForEditor($datenschutz); + $this->pages->publish($datenschutz, $seite['draft_revision_id'], $seite['version']); + $this->site->save(['country' => 'DE', 'homePath' => 'home', 'privacyPath' => 'datenschutz', 'imprintPath' => 'home']); + $document = DemoContent::starter('Startseite'); + $document['schemaVersion'] = 19; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + + // Ohne Baustein: die eingebaute Leiste am Seitenende. + $html = $this->request('/')->getContent(); + self::assertStringContainsString('