From 711af312af5c869035a61f42c6dc6e3cd3c517d5 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Tue, 22 Sep 2026 09:35:05 +0200 Subject: [PATCH] Pick images, pages, forms, sections and terms instead of typing ids and addresses The share image and featured image of a page become image fields with the library dialog, as do image settings of plugins. The editor offers forms, sections, categories and tags as lists, a page search next to every link target and in text links, the redirect target has a page search, and the form editor picks the reply field, the condition and the option images. Co-Authored-By: Claude Opus 5 --- docs/STATUS.md | 17 +++ public/assets/admin.css | 17 ++- public/assets/editor.js | 40 +++++-- public/assets/form-editor.js | 136 +++++++++++++++++++--- public/assets/media-dialog.css | 6 +- public/assets/media-field.js | 31 +++++ public/assets/page-picker.js | 33 +++++- public/assets/rich-text.js | 9 +- scripts/editor-check.mjs | 74 ++++++++++++ src/Controller/EditorController.php | 17 +++ src/Twig/PluginExtension.php | 10 ++ templates/admin/content-picker.html.twig | 2 +- templates/admin/content-results.html.twig | 6 +- templates/admin/forms.html.twig | 6 +- templates/admin/media-field.html.twig | 17 +++ templates/admin/pages.html.twig | 3 +- templates/admin/plugins.html.twig | 7 +- templates/admin/settings.html.twig | 5 +- tests/EditorSecurityTest.php | 53 +++++++++ 19 files changed, 446 insertions(+), 43 deletions(-) create mode 100644 public/assets/media-field.js create mode 100644 templates/admin/media-field.html.twig diff --git a/docs/STATUS.md b/docs/STATUS.md index d1785b4..ebc7a03 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,22 @@ # Projektstatus +## Fassung 1.22.2: Auswahl statt abgetippter Kennungen und Adressen (22.09.2026) + +**Anlass:** Umut zu den Beitragseinstellungen: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild“, dann: „Andere bereiche prüfen ob es sowas noch gibt!“ + +Die ganze Verwaltung und der Editor wurden nach Feldern durchsucht, in die man eine Kennung, eine Adresse oder einen technischen Namen tippen musste, der auf etwas anderes zeigt. Umgebaut: + +- **Vorschaubild beim Teilen und Beitragsbild** (Seiten und Beiträge → Einstellungen): Bildfeld mit Vorschau, „Bild wählen oder hochladen“ im Mediathek-Dialog, „Entfernen“ (`admin/media-field.html.twig`, `media-field.js`, Twig-Funktion `kant_media`). Die Kennung steht nur noch im versteckten Feld. +- **Plugin-Einstellungen der Art Bild:** dasselbe Bildfeld statt „Kennung aus der Mediathek“. +- **Editor, Formular-Baustein, Sektions-Baustein, Beitragsliste (Kategorie, Schlagwort):** Auswahllisten statt Adressen. Die Listen kommen mit `/api/design` (`choices`). Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt als „(nicht mehr vorhanden)“ sichtbar. +- **Linkziele im Editor** (Button, Bild, Symbolkarte, CTA, Symbol, Link-Felder von Plugins) und **Links im Textbaustein:** „Seite wählen“ mit Seitensuche (`openPageDialog` in `page-picker.js`) neben „PDF aus Mediathek wählen“. Freie Adressen bleiben möglich. Der Schalter „In neuem Tab öffnen“ erscheint bei Plugin-Feldern nicht mehr, deren Schema kennt ihn nicht. +- **Weiterleitungen, neue Adresse:** Seitensuche. Ein anderer Pfad (etwa `kategorie/projekte`) lässt sich weiter eintippen (`data-free` im Seitenwähler). +- **Formular-Editor:** „Antwortadresse aus Feld“ wählt aus den E-Mail-Feldern, „Nur zeigen, wenn“ aus den Auswahlfeldern weiter oben, mit Kästchen für die Antworten. „Bild je Option“ öffnet den Mediathek-Dialog mit Vorschau statt einer Aufklappliste. Ohne Skript bleiben die Textfelder, die gespeichert werden. + +**Bewusst getippt bleibt:** was an der Stelle erst entsteht (Adresse einer neuen Seite, eines Formulars, einer Sektion, Farb- und Schriftschlüssel, technischer Feldname, Anker), die alte Adresse einer Weiterleitung, Einbettungsadressen, Schlüssel für Turnstile. Noch offen: Link-Einstellungen von Plugins in der Plugin-Verwaltung (Textfeld), Sprungziel im Menü, Vorschläge vorhandener Kategorien und Schlagwörter in den Beitragseinstellungen. + +**Geprüft:** neue Tests `EditorSecurityTest::testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId` und `…testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses`. Die Editor-Prüfung (`npm run test:editor`) wählt jetzt im Browser ein Vorschaubild und speichert es. Sie wählt eine Seite als Linkziel (mit axe) und prüft, dass Formular, Kategorie und Schlagwort Auswahlfelder sind. Im Formular-Editor wählt sie Bedingung, Antwortadresse und ein Optionsbild, legt das Formular an und liest es wieder (mit axe). + ## Fassung 1.22.1: vergrößerbare Galerie mit einem Haken (22.09.2026) **Anlass:** Umut: „Bei Klick vergrößern bei Galerie muss man da alles einzeln machen? kann man bei Galerie das mit einem Klick für alle elemente machen?“ diff --git a/public/assets/admin.css b/public/assets/admin.css index 42b110e..8eb63be 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -898,8 +898,14 @@ details.card > summary:hover { background: #fafbf8 } .field-card-condition .field-help { grid-column: 1 / -1 } .field-card-images { grid-column: 1 / -1 } .field-image-pickers { display: grid; gap: 6px; margin-top: 6px } -.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 10px; align-items: center; font-size: 13px } -.field-image-picker select { margin: 0 } +.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; font-size: 13px } +.field-image-picker-thumb { width: 64px; height: 48px; object-fit: cover; border-radius: 6px; background: #eef1eb } +.field-image-picker-thumb[hidden] { display: block; visibility: hidden } +.field-image-picker-name { color: var(--muted); overflow-wrap: anywhere; min-width: 0 } +@media (max-width: 767px) { .field-image-picker { grid-template-columns: 64px minmax(0, 1fr); } .field-image-picker-option { grid-column: 1 / -1; font-weight: 650 } } +.field-condition-picker { display: grid; gap: 6px; grid-column: 1 / -1 } +.field-condition-values { border: 0; padding: 0; margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 16px } +.field-condition-values legend { font-size: 13px; font-weight: 650; margin-bottom: 4px; padding: 0 } .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 } @@ -977,3 +983,10 @@ details.card > summary:hover { background: #fafbf8 } @media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } } /* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */ .row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em } +/* Ein Bild der Mediathek als Formularfeld: Vorschau, Name, Wählen und Entfernen. */ +.media-field-label { display: block; font-weight: 650; font-size: 13px; margin-bottom: 6px } +.media-field { min-width: 0 } +.media-field-current { display: flex; align-items: center; gap: 10px; min-height: 64px; padding: 6px; border: 1px solid var(--line); border-radius: 9px; background: #fff } +.media-field-thumb { width: 96px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #eef1eb } +.media-field-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; min-width: 0 } +.media-field-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px } diff --git a/public/assets/editor.js b/public/assets/editor.js index 36ae307..acd2724 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -1,5 +1,6 @@ import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js'; import { openMedia, openDocuments, createMediaPreview } from './media.js'; +import { openPageDialog } from './page-picker.js'; import { createDesignPanel } from './design-panel.js'; import { renderButtonControls, setButtonDesign } from './button-design.js'; import { createRichText } from './rich-text.js'; @@ -330,13 +331,19 @@ function renderContent(block, panel) { input.addEventListener('blur', () => { history.mergeKey = null; }); if (options.checkbox) container.append(labelEl); else container.append(labelEl, input); if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); } - if (key === 'href') { + if (key === 'href' || options.link) { // Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen // Tab zeigt die Website es im Betrachter über der Seite. + // Eine eigene Seite sucht man, statt ihren Pfad zu kennen. + const choose = value => { input.value = value; applyValue(); history.mergeKey = null; }; + const pages = document.createElement('button'); pages.type = 'button'; pages.className = 'secondary href-document'; pages.textContent = 'Seite wählen'; + pages.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) })); const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen'; - pdf.addEventListener('click', () => openDocuments({ onChoose: item => { input.value = item.src; applyValue(); history.mergeKey = null; } })); - container.append(pdf); - field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container }); + pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) })); + const row = document.createElement('div'); row.className = 'href-pickers'; row.append(pages, pdf); + container.append(row); + // Den Schalter kennen nur die Linkbausteine des Kerns; ein Plugin legt seine Felder selbst fest. + if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container }); } }; if (!block) { @@ -346,6 +353,21 @@ function renderContent(block, panel) { } const plugin = pluginDefinition(block); if (plugin) { renderPluginFields(block, panel, plugin, field); return; } + /* + * Eine Auswahl statt einer abgetippten Adresse, für alles, was ein Baustein nur verknüpft. + * Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt sichtbar statt still zu + * verschwinden. Ohne die Liste (sie kommt mit dem Design-Schema) bleibt es ein Textfeld. + */ + const pick = (kind, current, empty = null) => { + const list = designSchema?.choices?.[kind]; + if (!list) return {}; + const choices = {}; + if (empty !== null) choices[''] = empty; + else if (!current) choices[''] = 'Bitte wählen …'; + Object.assign(choices, list); + if (current && !(current in list)) choices[current] = `${current} (nicht mehr vorhanden)`; + return { choices }; + }; if (block.type === 'text') { // Formatted text with a toolbar and an HTML view. The document moves to v12 on the first // formatted save; until then it keeps plain text semantics and stays escaped when rendered. @@ -410,7 +432,7 @@ function renderContent(block, panel) { field('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' }); field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' }); } - if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' }); + if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' }); if (block.type === 'icon') { field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) }); // number, so the value is stored as a number: the server wants an int, not "48". @@ -490,7 +512,7 @@ function renderContent(block, panel) { : 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.'; panel.append(label, input, note); } - if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' }); + if (block.type === 'form') field('Formular', 'form', block.props.form, { ...pick('forms', block.props.form), help: 'Felder, Empfänger und Einwilligungstext werden unter „Formulare“ gepflegt, nicht hier.' }); if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' }); if (block.type === 'accordion') { field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' }); @@ -526,8 +548,8 @@ function renderContent(block, panel) { } if (block.type === 'postlist') { field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' }); - field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' }); - field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' }); + field('Nur Kategorie', 'category', block.props.category || '', { ...pick('categories', block.props.category, 'Alle Kategorien') }); + field('Nur Schlagwort', 'tag', block.props.tag || '', { ...pick('tags', block.props.tag, 'Alle Schlagwörter'), help: 'Kategorien und Schlagwörter entstehen in den Einstellungen eines Beitrags.' }); field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' }); field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' }); field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true }); @@ -774,7 +796,7 @@ function renderPluginFields(block, panel, plugin, field) { if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); } panel.append(wrap); } - else field(schema.label, key, value, { ...options, maxLength: schema.maxLength }); + else field(schema.label, key, value, { ...options, maxLength: schema.maxLength, plugin: true, link: schema.kind === 'url' }); } } diff --git a/public/assets/form-editor.js b/public/assets/form-editor.js index e15722a..79a5d15 100644 --- a/public/assets/form-editor.js +++ b/public/assets/form-editor.js @@ -9,6 +9,7 @@ */ import { createColorControl } from './color-control.js'; import { buttonSample } from './button-design.js'; +import { openMedia } from './media.js'; const root = document.querySelector('[data-form-schema]'); if (root) { @@ -115,8 +116,10 @@ if (root) { const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status'); preview.after(note); - // Statt Kennungen zu tippen: je Option eine Liste der Mediathek. Das Textfeld bleibt das - // gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein. + // Statt Kennungen zu tippen: je Option ein Bild aus der Mediathek, gewählt im Dialog mit + // Vorschau und Hochladen. Das Textfeld bleibt das gespeicherte Original und wird aus der + // Auswahl geschrieben; ohne Skript tippt man hinein. + const bildVon = new Map(bilder.map(bild => [bild.id, bild])); const renderImagePickers = card => { const area = card.querySelector('[data-field-images-input]'); const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean); @@ -129,23 +132,124 @@ if (root) { const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim()))); box.dataset.signature = options.join('|'); box.replaceChildren(); + const write = () => { + const map = {}; + for (const row of box.querySelectorAll('[data-option]')) if (row.dataset.media) map[row.dataset.option] = row.dataset.media; + area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n'); + renderPreview(); + }; for (const option of options) { - const row = el('label', 'field-image-picker'); - row.append(el('span', 'field-image-picker-option', option)); - const select = document.createElement('select'); - select.append(new Option('Kein Bild', '')); - for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id)); - select.dataset.option = option; - select.addEventListener('change', () => { - const map = {}; - for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value; - area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n'); - renderPreview(); - }); - row.append(select); box.append(row); + const row = el('div', 'field-image-picker'); row.dataset.option = option; + const thumb = document.createElement('img'); thumb.className = 'field-image-picker-thumb'; thumb.alt = ''; thumb.width = 64; thumb.height = 48; + const name = el('span', 'field-image-picker-name'); + const choose = el('button', 'secondary', ''); choose.type = 'button'; + const remove = el('button', 'text-button danger-text', 'Entfernen'); remove.type = 'button'; + remove.setAttribute('aria-label', `Bild für „${option}“ entfernen`); + const show = id => { + row.dataset.media = id || ''; + const bild = id ? bildVon.get(id) : null; + thumb.hidden = !bild; if (bild) thumb.src = bild.src; else thumb.removeAttribute('src'); + name.textContent = id ? (bild?.title || 'Bild gewählt') : 'Kein Bild'; + choose.textContent = id ? 'Anderes Bild' : 'Bild wählen'; + choose.setAttribute('aria-label', `${id ? 'Anderes Bild' : 'Bild'} für „${option}“ wählen`); + remove.hidden = !id; + }; + choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => { + if (!item) return; + bildVon.set(item.id, { id: item.id, title: item.title, src: item.variants[0].src }); + show(item.id); write(); + } })); + remove.addEventListener('click', () => { show(''); write(); choose.focus(); }); + show(current[option] || ''); + row.append(el('span', 'field-image-picker-option', option), thumb, name, choose, remove); box.append(row); } }; + /* + * Auswahl statt Feldnamen: die Bedingung eines Feldes nennt ein Auswahlfeld weiter oben und + * dessen Optionen, die Antwortadresse ein E-Mail-Feld. Die Textfelder bleiben das + * gespeicherte Original, versteckt, und werden aus der Auswahl geschrieben. + */ + const fieldsBefore = card => { + const list = []; + for (const other of cards()) { + if (other === card) break; + const name = other.querySelector('[data-field-name]').value.trim(); + const type = other.querySelector('[data-field-type]').value; + if (name && ['select', 'radio', 'checkboxes'].includes(type)) { + list.push({ name, label: other.querySelector('[data-field-label]').value.trim() || name, + options: other.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean) }); + } + } + return list; + }; + const renderCondition = card => { + const fieldInput = card.querySelector('[data-field-showif]'); + const valuesInput = card.querySelector('[data-field-showvalues]'); + const candidates = fieldsBefore(card); + const signature = JSON.stringify([candidates, fieldInput.value, valuesInput.value]); + let box = card.querySelector('[data-condition-picker]'); + if (!box) { + box = el('div', 'field-condition-picker'); box.dataset.conditionPicker = ''; + fieldInput.closest('div').hidden = true; valuesInput.closest('div').hidden = true; + fieldInput.closest('[data-field-condition]').prepend(box); + const help = box.parentElement.querySelector(':scope > .field-help'); + if (help) help.textContent = 'Zur Wahl stehen Auswahlfelder weiter oben. Bei einem Schritt gilt die Bedingung für alle seine Felder.'; + } + if (box.dataset.signature === signature) return; + box.dataset.signature = signature; + const focused = box.contains(document.activeElement) ? document.activeElement.dataset.key : null; + box.replaceChildren(); + const id = `${fieldInput.id}-select`; + const label = el('label', '', 'Nur zeigen, wenn'); label.htmlFor = id; + const select = document.createElement('select'); select.id = id; select.dataset.key = 'field'; + select.append(new Option('Immer zeigen', '')); + for (const candidate of candidates) select.append(new Option(candidate.label, candidate.name)); + if (fieldInput.value && !candidates.some(c => c.name === fieldInput.value)) select.append(new Option(`${fieldInput.value} (nicht mehr weiter oben)`, fieldInput.value)); + select.value = fieldInput.value; + select.addEventListener('change', () => { fieldInput.value = select.value; valuesInput.value = ''; refresh(); }); + box.append(label, select); + const chosen = candidates.find(c => c.name === fieldInput.value); + if (chosen) { + const set = document.createElement('fieldset'); set.className = 'field-condition-values'; + set.append(el('legend', '', '… eine dieser Antworten gewählt ist')); + const current = valuesInput.value.split(',').map(v => v.trim()).filter(Boolean); + chosen.options.forEach((option, index) => { + const check = el('label', 'check-label'); + const box2 = document.createElement('input'); box2.type = 'checkbox'; box2.value = option; box2.checked = current.includes(option); + box2.dataset.key = `value-${index}`; + box2.addEventListener('change', () => { + valuesInput.value = [...set.querySelectorAll('input:checked')].map(input => input.value).join(', '); + refresh(); + }); + check.append(box2, document.createTextNode(` ${option}`)); set.append(check); + }); + box.append(set); + } + if (focused) box.querySelector(`[data-key="${focused}"]`)?.focus(); + }; + const reply = form.querySelector('[name="replyToField"]'); + const renderReply = () => { + if (!reply) return; + const emails = cards().map(card => ({ name: card.querySelector('[data-field-name]').value.trim(), label: card.querySelector('[data-field-label]').value.trim(), type: card.querySelector('[data-field-type]').value })) + .filter(field => field.name && field.type === 'email'); + let select = form.querySelector('[data-reply-picker]'); + if (!select) { + select = document.createElement('select'); select.dataset.replyPicker = ''; select.id = reply.id; + reply.id += '-value'; reply.type = 'hidden'; reply.after(select); + select.addEventListener('change', () => { reply.value = select.value; }); + } + const signature = JSON.stringify([emails, reply.value]); + if (select.dataset.signature === signature) return; + select.dataset.signature = signature; + select.replaceChildren(new Option('Keine – Antworten gehen an den Absender der E-Mail', '')); + for (const field of emails) select.append(new Option(field.label || field.name, field.name)); + if (reply.value && !emails.some(field => field.name === reply.value)) select.append(new Option(`${reply.value} (kein E-Mail-Feld mehr)`, reply.value)); + select.value = reply.value; + const help = reply.parentElement.querySelector('.field-help'); + if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.'; + }; + const refresh = () => { cards().forEach((card, index) => { card.querySelector('[data-field-index]').textContent = String(index + 1); @@ -153,6 +257,7 @@ if (root) { card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type); card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type); if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card); + renderCondition(card); card.querySelector('[data-field-number]').hidden = type !== 'number'; const remove = card.querySelector('[data-remove-field]'); remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`); @@ -162,6 +267,7 @@ if (root) { else if (type === 'step') { required.checked = false; required.disabled = true; } else required.disabled = false; }); + renderReply(); renderPreview(); }; diff --git a/public/assets/media-dialog.css b/public/assets/media-dialog.css index 84e0066..a0bee4f 100644 --- a/public/assets/media-dialog.css +++ b/public/assets/media-dialog.css @@ -37,4 +37,8 @@ .document-card span{font-size:11px;color:var(--muted)} .document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em} .media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px} -.href-document{margin:6px 0 4px} +.href-pickers{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 4px} +.href-document{margin:0} +/* Seite als Linkziel wählen: schmal, die Trefferliste darf überstehen. */ +.page-dialog{max-width:560px;min-height:420px;overflow:visible} +.page-dialog .content-picker{margin-top:16px} diff --git a/public/assets/media-field.js b/public/assets/media-field.js new file mode 100644 index 0000000..9b197e6 --- /dev/null +++ b/public/assets/media-field.js @@ -0,0 +1,31 @@ +import { openMedia } from './media.js'; + +/* + * Bildfelder in Formularen der Verwaltung: Vorschaubild beim Teilen, Beitragsbild. + * + * Über den Dokumentknoten statt je Feld, weil die Suche in der Inhaltsliste die Ergebnisse + * austauscht - ein Feld, das danach erscheint, funktioniert trotzdem. + */ +document.addEventListener('click', event => { + const button = event.target.closest('[data-media-choose], [data-media-remove]'); + const field = button?.closest('[data-media-field]'); + if (!field) return; + const input = field.querySelector('input[type="hidden"]'); + const thumb = field.querySelector('.media-field-thumb'); + const name = field.querySelector('.media-field-name'); + const choose = field.querySelector('[data-media-choose]'); + const remove = field.querySelector('[data-media-remove]'); + const show = item => { + input.value = item?.id ?? ''; + thumb.hidden = !item; + if (item) thumb.src = item.variants[0].src; else thumb.removeAttribute('src'); + name.textContent = item ? item.title : 'Kein Bild gewählt'; + choose.textContent = item ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen'; + remove.hidden = !item; + input.dispatchEvent(new Event('change', { bubbles: true })); + }; + if (button.matches('[data-media-remove]')) { show(null); choose.focus(); return; } + // Kein Alternativtext nötig: das Bild erscheint als Vorschau beim Teilen oder in einer Karte, + // deren Titel schon sagt, worum es geht. + openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) show(item); } }); +}); diff --git a/public/assets/page-picker.js b/public/assets/page-picker.js index 5f93b92..8094cca 100644 --- a/public/assets/page-picker.js +++ b/public/assets/page-picker.js @@ -52,7 +52,11 @@ export function setupPicker(picker, onSelect = () => {}) { }; input.addEventListener('focus', () => search('')); input.addEventListener('input', () => { - value.value = ''; input.setCustomValidity(input.value && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : ''); + // Ein freies Feld (etwa das Ziel einer Weiterleitung) nimmt auch einen Pfad, der keine Seite + // ist; gewählt wird trotzdem am bequemsten aus der Suche. + const free = picker.dataset.free === '1'; + value.value = free ? input.value.trim().replace(/^\/+/, '') : ''; + input.setCustomValidity(input.value && !free && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : ''); value.dispatchEvent(new Event('change', { bubbles: true })); controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180); }); @@ -66,3 +70,30 @@ export function setupPicker(picker, onSelect = () => {}) { } document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker)); + +/** + * Eine Seite oder einen Beitrag für ein Linkziel wählen, in einem kleinen Dialog mit derselben + * Suche wie in den Menüs. onChoose bekommt den Treffer mit Titel und Pfad. + */ +export function openPageDialog({ onChoose } = {}) { + const opener = document.activeElement; + const dialog = document.createElement('dialog'); dialog.className = 'media-dialog page-dialog'; + dialog.setAttribute('aria-labelledby', 'page-dialog-title'); + const source = document.querySelector('meta[name="admin-base"]').content + '/api/content-choices'; + dialog.innerHTML = `

LINKZIEL

Seite wählen

+
+ +
+ + +

+
`; + const picker = dialog.querySelector('.content-picker'); + picker.dataset.source = source; picker.classList.add('menu-add'); + document.body.append(dialog); + setupPicker(picker, item => { if (item) { onChoose?.(item); dialog.close(); } }); + dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); }); + dialog.showModal(); + dialog.querySelector('#page-dialog-search').focus(); +} diff --git a/public/assets/rich-text.js b/public/assets/rich-text.js index 498d1a3..cf798db 100644 --- a/public/assets/rich-text.js +++ b/public/assets/rich-text.js @@ -9,6 +9,8 @@ * the alternative would be a selection engine of our own. The formatting it produces is normalised * on the server anyway, so a browser quirk cannot store something unexpected. */ +import { openPageDialog } from './page-picker.js'; +import { openDocuments } from './media.js'; /** Mirrors RichText::ALLOWED. Only used for the hint text and for cleaning up on paste. */ export const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'mark', 'sup', 'sub', 'ul', 'ol', 'li', 'a']; @@ -116,7 +118,12 @@ export function createRichText(value, apply, { label = 'Text', plain = false } = if ('Enter' === event.key) { event.preventDefault(); confirmLink(); } if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); } }); - linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button')); + // Eine eigene Seite oder ein PDF wählt man, statt die Adresse zu kennen. + const fill = value => { linkInput.value = value; linkInput.focus(); }; + linkRow.append(linkInput, + button('Seite …', 'Seite oder Beitrag als Linkziel wählen', () => openPageDialog({ onChoose: item => fill(item.path === 'home' ? '/' : `/${item.path}`) }), 'text-button'), + button('PDF …', 'PDF aus der Mediathek als Linkziel wählen', () => openDocuments({ onChoose: item => fill(item.src) }), 'text-button'), + newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button')); bar.append(button('🔗', 'Link setzen oder entfernen', () => { const selection = window.getSelection(); diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index d73ead1..12ede30 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -375,9 +375,28 @@ try { assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/); await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? '')); checks.push({ name: 'a PDF from the library as a link target', passed: true }); + // Eine eigene Seite als Linkziel: gesucht und gewählt, nicht als Pfad abgetippt. + await page.getByRole('button', { name: 'Seite wählen', exact: true }).click(); + const pageDialog = page.locator('dialog.page-dialog'); + await pageDialog.locator('[role="option"]').first().waitFor(); + const pageAxe = await new AxeBuilder({ page }).include('dialog.page-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + assert.deepEqual(pageAxe.violations.map(v => v.id), [], 'the page dialog has no accessibility violations'); + await pageDialog.locator('[role="option"]').first().click(); + await pageDialog.waitFor({ state: 'detached' }); + assert.match(await page.locator('#property-href').inputValue(), /^\/[a-z0-9\-/]*$/); + checks.push({ name: 'a page as a link target, picked by search', passed: true }); + await page.getByRole('button', { name: 'Rückgängig', exact: true }).click(); await page.getByRole('button', { name: 'Rückgängig', exact: true }).click(); await page.getByRole('button', { name: 'Rückgängig', exact: true }).click(); await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons); + // Verknüpfte Dinge kommen aus Listen: Formular, Kategorie und Schlagwort sind Auswahlfelder. + for (const [type, id] of [['form', 'property-form'], ['postlist', 'property-category'], ['postlist', 'property-tag']]) { + await addFromLibrary(`button[data-add="${type}"]`); + assert.equal(await page.locator(`#${id}`).evaluate(node => node.tagName), 'SELECT', `${id} is a choice, not a typed address`); + await page.getByRole('button', { name: 'Rückgängig', exact: true }).click(); + await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons); + } + checks.push({ name: 'forms, categories and tags are picked from lists', passed: true }); checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true }); await page.screenshot({ path: 'reports/editor/canvas-overlay.png' }); // --- Der Werkzeugkasten kennt den Zusammenhang --------------------------------------------- @@ -1108,6 +1127,61 @@ try { await axe('mobile media library'); await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true }); await page.getByRole('button', { name: 'Schließen', exact: true }).click(); + // Das Vorschaubild einer Seite: aus der Mediathek gewählt, nicht als Kennung abgetippt. + await page.setViewportSize({ width: 1600, height: 1100 }); + await page.goto(origin + '/admin'); + const sheet = page.locator('details.sheet').first(); + await sheet.locator('summary').click(); + const ogField = sheet.locator('[data-media-field]').filter({ has: page.locator('input[name="ogMediaId"]') }); + await ogField.getByRole('button', { name: 'Bild wählen oder hochladen', exact: true }).click(); + await page.locator('#media-dialog [data-media-id]').first().click(); + await page.locator('#media-insert').click(); + await page.locator('#media-dialog').waitFor({ state: 'detached' }); + const chosenOg = await ogField.locator('input[name="ogMediaId"]').inputValue(); + assert.match(chosenOg, /^[a-f0-9]{32}$/); + assert.equal(await ogField.locator('.media-field-thumb').isVisible(), true, 'the chosen image is shown'); + await axe('content settings with an image field'); + await ogField.locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click(); + await page.waitForLoadState('load'); + const savedOg = page.locator('input[name="ogMediaId"]').first(); + assert.equal(await savedOg.inputValue(), chosenOg, 'the image survives saving'); + checks.push({ name: 'image fields in the content settings pick from the library', passed: true }); + // Formular-Editor: Bedingung, Antwortadresse und Bild je Option werden gewählt, nicht getippt. + await page.goto(origin + '/admin/forms'); + const neu = page.locator('#neues-formular'); + if (!(await neu.evaluate(details => details.open))) await neu.locator('summary').click(); + await neu.locator('[name="name"]').fill('Anfrage Prüfung'); + await neu.locator('[name="slug"]').fill('anfrage-pruefung'); + await neu.locator('[name="recipient"]').fill('info@example.com'); + const karte = index => neu.locator('[data-field-list] [data-field]').nth(index); + await karte(0).locator('[data-field-label]').fill('E-Mail'); + await karte(0).locator('[data-field-type]').selectOption('email'); + await neu.locator('[data-add-field]').click(); + await karte(1).locator('[data-field-label]').fill('Art'); + await karte(1).locator('[data-field-type]').selectOption('radio'); + await karte(1).locator('[data-field-options-input]').fill('Wohnung, Haus'); + await neu.locator('[data-add-field]').click(); + await karte(2).locator('[data-field-label]').fill('Etage'); + await karte(2).getByLabel('Nur zeigen, wenn', { exact: true }).selectOption({ label: 'Art' }); + await karte(2).getByLabel('Wohnung', { exact: true }).check(); + assert.equal(await karte(2).locator('[data-field-showif]').inputValue(), 'art'); + assert.equal(await karte(2).locator('[data-field-showvalues]').inputValue(), 'Wohnung'); + await neu.getByLabel('Antwortadresse aus Feld', { exact: true }).selectOption({ label: 'E-Mail' }); + assert.equal(await neu.locator('input[name="replyToField"]').inputValue(), 'e_mail'); + await karte(1).getByRole('button', { name: 'Bild für „Wohnung“ wählen', exact: true }).click(); + await page.locator('#media-dialog [data-media-id]').first().click(); + await page.locator('#media-insert').click(); + await page.locator('#media-dialog').waitFor({ state: 'detached' }); + assert.match(await karte(1).locator('[data-field-images-input]').inputValue(), /^Wohnung = [a-f0-9]{32}$/); + assert.equal(await karte(1).locator('.field-image-picker-thumb').first().isVisible(), true); + await axe('form editor with pickers'); + await neu.getByRole('button', { name: 'Formular anlegen', exact: true }).click(); + await page.waitForLoadState('load'); + const gespeichert = page.locator('form[action$="/forms/anfrage-pruefung"]'); + await gespeichert.waitFor({ state: 'attached' }); + assert.equal(await gespeichert.locator('input[name="replyToField"]').inputValue(), 'e_mail'); + assert.equal(await gespeichert.locator('[data-field-showif]').nth(2).inputValue(), 'art'); + checks.push({ name: 'form editor: condition, reply address and option images are picked', passed: true }); assert.deepEqual(errors, []); await writeFile('reports/editor/result.json', JSON.stringify({ checks, publicDataUntouched: true }, null, 2)); console.log('Editor end-to-end checks passed; isolated database, production data unchanged.'); diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index f92a6d3..0c9adc7 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -139,9 +139,26 @@ final class EditorController extends AbstractController 'buttonOptions' => \App\Content\ButtonDesign::OPTIONS, 'buttonPresets' => \App\Content\ButtonDesign::PRESETS, 'csrfToken' => $this->csrf->getToken('editor')->getValue(), + // Was ein Baustein per Adresse verknuepft, waehlt man aus einer Liste - niemand soll + // die Adresse eines Formulars oder einer Kategorie auswendig wissen muessen. + 'choices' => $this->choices(), ]); } + /** @return array> Adresse => Name, je Art */ + private function choices(): array + { + $choices = ['forms' => [], 'sections' => $this->partials->choices('section'), 'categories' => [], 'tags' => []]; + foreach ($this->forms->all() as $form) { + $choices['forms'][$form['slug']] = $form['name']; + } + foreach ($this->posts->allTerms() as $term) { + $choices['category' === $term['kind'] ? 'categories' : 'tags'][$term['slug']] = $term['name']; + } + + return $choices; + } + /** Saves or removes a reusable style, so a look can be applied to other blocks and pages. */ #[Route('/api/style-presets', name: 'admin_style_preset', methods: ['POST'])] public function stylePreset(Request $request): JsonResponse diff --git a/src/Twig/PluginExtension.php b/src/Twig/PluginExtension.php index 8977d1a..1e431ec 100644 --- a/src/Twig/PluginExtension.php +++ b/src/Twig/PluginExtension.php @@ -33,9 +33,19 @@ final class PluginExtension extends AbstractExtension new TwigFunction('kant_plugin_props', $this->props(...)), new TwigFunction('kant_plugin_media', $this->mediaItem(...)), new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)), + new TwigFunction('kant_media', $this->adminMedia(...)), ]; } + /** + * Ein Bild fuer ein Bildfeld der Verwaltung - auch ein archiviertes: es ist noch gewaehlt und + * wird noch ausgeliefert, also soll es auch zu sehen sein. + */ + public function adminMedia(string $id): ?array + { + return 1 === preg_match('/^[a-f0-9]{32}$/D', $id) ? $this->media->find($id) : null; + } + /** Die geprüften Eigenschaften eines Bausteins - oder null, wenn sie nicht ins Schema passen. */ public function props(array $block): ?array { diff --git a/templates/admin/content-picker.html.twig b/templates/admin/content-picker.html.twig index 0625f8d..fdfa42a 100644 --- a/templates/admin/content-picker.html.twig +++ b/templates/admin/content-picker.html.twig @@ -1,5 +1,5 @@ {% set selected = selected|default(null) %} -
+
diff --git a/templates/admin/content-results.html.twig b/templates/admin/content-results.html.twig index b5636eb..06f5689 100644 --- a/templates/admin/content-results.html.twig +++ b/templates/admin/content-results.html.twig @@ -53,8 +53,7 @@

Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.

- - + {% include 'admin/media-field.html.twig' with {id: 'og-' ~ page.id, name: 'ogMediaId', label: 'Vorschaubild beim Teilen', value: page.og_media_id|default(''), help: 'Erscheint, wenn jemand die Seite in sozialen Netzen oder Messengern teilt.'} only %}
{% if page.type == 'post' %}
@@ -75,8 +74,7 @@

Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.

- - + {% include 'admin/media-field.html.twig' with {id: 'media-' ~ page.id, name: 'featuredMediaId', label: 'Beitragsbild', value: page.featured_media_id|default(''), help: 'Erscheint in Beitragslisten und im Feed.'} only %}
diff --git a/templates/admin/forms.html.twig b/templates/admin/forms.html.twig index f545abc..e44cba0 100644 --- a/templates/admin/forms.html.twig +++ b/templates/admin/forms.html.twig @@ -2,7 +2,7 @@ {% 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 %}{% endblock %} +{% block styles %}{% endblock %} {% block scripts %}{% endblock %} {% block title %}Formulare{% endblock %} {% block heading %}Formulare{% endblock %} @@ -105,7 +105,7 @@ {% else %}

{{ form.name }} {{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}

- Im Editor als Formularblock mit der Adresse {{ form.slug }} + Im Editor über den Formularblock wählbar · Adresse {{ form.slug }}
{% endif %}
@@ -120,7 +120,7 @@
-

Wird im Formularblock eingetragen. Kleinbuchstaben, Ziffern, Bindestrich.

+

Kurzname des Formulars, etwa kontakt. Kleinbuchstaben, Ziffern, Bindestrich. Im Editor wird das Formular dann aus einer Liste gewählt.

{% endif %}
diff --git a/templates/admin/media-field.html.twig b/templates/admin/media-field.html.twig new file mode 100644 index 0000000..f8f35c3 --- /dev/null +++ b/templates/admin/media-field.html.twig @@ -0,0 +1,17 @@ +{# Ein Bild aus der Mediathek als Formularfeld: Vorschau, Wählen oder Hochladen im Dialog, + Entfernen. Die Kennung steht nur im versteckten Feld - niemand soll sie abtippen müssen. + Erwartet: id, name, label, value, help (optional). #} +{% set item = value ? kant_media(value) : null %} +
+ {{ label }} + +
+ + {{ item ? item.title : (value ? 'Bild nicht gefunden' : 'Kein Bild gewählt') }} +
+
+ + +
+ {% if help|default('') %}

{{ help }}

{% endif %} +
diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index 016a5cb..82cfd81 100644 --- a/templates/admin/pages.html.twig +++ b/templates/admin/pages.html.twig @@ -4,7 +4,8 @@ {% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %} {% block title %}{{ plural }}{% endblock %} {% block heading %}{{ plural }}{% endblock %} -{% block scripts %}{% endblock %} +{% block styles %}{% endblock %} +{% block scripts %}{% endblock %} {% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %} {% block content %} diff --git a/templates/admin/plugins.html.twig b/templates/admin/plugins.html.twig index c547063..fa432e1 100644 --- a/templates/admin/plugins.html.twig +++ b/templates/admin/plugins.html.twig @@ -2,13 +2,17 @@ {% set nav = 'plugins' %} {% block title %}Plugins{% endblock %} {% block heading %}Plugins{% endblock %} +{% block styles %}{% endblock %} +{% block scripts %}{% endblock %} {% block lead %}Erweiterungen bringen eigene Bausteine, Vorlagen und Adressen mit. Sie liegen als Ordner unter plugins/; hier schaltest du sie ein und aus und stellst sie ein. Ein ausgeschaltetes Plugin lässt seine Bausteine in den Seiten stehen, zeigt sie aber nicht mehr.{% endblock %} {# Ein Feld aus dem Schema eines Plugins - dieselben sieben Arten wie im Editor. #} {% macro feld(plugin, key, schema, value) %} {% set id = 'plugin-' ~ plugin ~ '-' ~ key %} {% set name = 'settings[' ~ key ~ ']' %} -{% if schema.kind == 'boolean' %} +{% if schema.kind == 'media' %} +
{% include 'admin/media-field.html.twig' with {id: id, name: name, label: schema.label, value: value|default(''), help: schema.help|default('')} only %}
+{% elseif schema.kind == 'boolean' %}
{% if schema.help %}

{{ schema.help }}

{% endif %}
{% else %}
@@ -16,7 +20,6 @@ {% if schema.kind == 'textarea' %} {% elseif schema.kind == 'select' %} {% elseif schema.kind == 'number' %} - {% elseif schema.kind == 'media' %} {% else %}{% endif %} {% if schema.help %}

{{ schema.help }}

{% endif %}
diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 0c08b33..6f04da9 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -292,9 +292,8 @@
- - -

Bei 410 leer lassen.

+ {% include 'admin/content-picker.html.twig' with {id: 'to', name: 'to', label: 'Neue Adresse', valueKey: 'path', free: true} only %} +

Seite suchen und wählen – oder einen anderen Pfad eintippen, etwa kategorie/projekte. Bei 410 leer lassen.

diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php index 5482f4c..307eb34 100644 --- a/tests/EditorSecurityTest.php +++ b/tests/EditorSecurityTest.php @@ -9,6 +9,7 @@ use App\Content\MediaLibrary; use App\Content\MenuRepository; use App\Content\PageRepository; use App\Content\PartialRepository; +use App\Content\PostRepository; use App\Content\SiteSettings; use App\Content\Theme; use App\Domain\InvalidForm; @@ -268,6 +269,35 @@ final class EditorSecurityTest extends WebTestCase } } + public function testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId(): void + { + // Umut: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild" + $client = $this->login('editor'); + $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'))); + $id = $pages->createPage('mit-bild', null, 'post', DemoContent::pages()['home']); + $media = str_repeat('c', 32); + $this->db->insert('media', ['id' => $media, 'title' => 'Klassenzimmer', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$media.'/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]); + $this->db->executeStatement('UPDATE page SET featured_media_id = ? WHERE id = ?', [$media, $id]); + + $crawler = $client->request('GET', '/admin/posts'); + self::assertResponseIsSuccessful(); + self::assertStringNotContainsString('ID aus der Mediathek', $client->getResponse()->getContent()); + self::assertCount(1, $crawler->filter('script[src^="/assets/media-field.js"]')); + // Das gewählte Beitragsbild: Kennung versteckt, Vorschau und Titel sichtbar. + $featured = $crawler->filter('input[name="featuredMediaId"]')->closest('[data-media-field]'); + self::assertCount(1, $featured); + self::assertSame('hidden', $featured->filter('input[name="featuredMediaId"]')->attr('type')); + self::assertSame($media, $featured->filter('input[name="featuredMediaId"]')->attr('value')); + self::assertSame('/media/'.$media.'/480', $featured->filter('img')->attr('src')); + self::assertStringContainsString('Klassenzimmer', $featured->text()); + self::assertStringContainsString('Anderes Bild wählen', $featured->text()); + // Ohne Vorschaubild: Wählen statt Kennung eintippen. + $og = $crawler->filter('input[name="ogMediaId"]')->closest('[data-media-field]'); + self::assertCount(1, $og); + self::assertStringContainsString('Bild wählen oder hochladen', $og->text()); + self::assertNull($og->filter('img')->attr('src')); + } + public function testLiveSearchReturnsFilteredEscapedResultsWithTheSamePermissionsAsTheFullScreen(): void { $client = $this->login('editor'); @@ -653,6 +683,29 @@ final class EditorSecurityTest extends WebTestCase self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form')); } + public function testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses(): void + { + // Was ein Baustein nur verknüpft, kommt als Liste Adresse => Name - der Editor macht + // daraus Auswahlfelder statt Textfelder für Adressen. + $media = new MediaLibrary($this->db, $this->path.'-media'); + $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media)); + (new FormRepository($this->db))->save('anfrage', 'Anfrage', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true, 'help' => '', 'options' => []]], ['recipient' => 'info@example.com'], 0); + $band = DemoContent::starter('Werbeband'); + $band['blocks'] = [['id' => 'band-text', 'type' => 'text', 'props' => ['text' => 'Aktion']]]; + (new PartialRepository($this->db, new DocumentValidator()))->create('section', 'band', 'Werbeband', $band); + $post = $pages->createPage('ein-beitrag', null, 'post', DemoContent::pages()['home']); + (new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']); + + $client = $this->login('editor'); + $client->request('GET', '/admin/api/design'); + self::assertResponseIsSuccessful(); + $choices = json_decode($client->getResponse()->getContent(), true)['choices']; + self::assertSame(['anfrage' => 'Anfrage'], $choices['forms']); + self::assertSame(['band' => 'Werbeband'], $choices['sections']); + self::assertSame(['projekte' => 'Projekte'], $choices['categories']); + self::assertSame(['umbau' => 'Umbau'], $choices['tags']); + } + public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void { $client = $this->login('editor');