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 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-22 09:35:05 +02:00
1 parent 31fb53c1fc
commit 711af312af
19 files changed
+446 -43

No files matched your search

+17
View File
@@ -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?“
+15 -2
View File
@@ -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 }
+31 -9
View File
@@ -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' });
}
}
+121 -15
View File
@@ -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();
};
+5 -1
View File
@@ -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}
+31
View File
@@ -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); } });
});
+32 -1
View File
@@ -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 = `<div class="media-heading"><div><p class="eyebrow">LINKZIEL</p><h2 id="page-dialog-title">Seite wählen</h2></div><button type="button" class="secondary" data-close>Schließen</button></div>
<div class="content-picker">
<label for="page-dialog-search">Seite oder Beitrag suchen</label>
<div class="picker-input"><input id="page-dialog-search" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="page-dialog-results" placeholder="Titel oder Adresse …"><button type="button" class="subtle picker-clear" aria-label="Suche leeren">✕</button></div>
<input type="hidden" class="picker-value">
<div id="page-dialog-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
<p class="picker-status field-help" role="status"></p>
</div>`;
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();
}
+8 -1
View File
@@ -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();
+74
View File
@@ -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.');
+17
View File
@@ -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<string, array<string, string>> 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
+10
View File
@@ -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
{
+1 -1
View File
@@ -1,5 +1,5 @@
{% set selected = selected|default(null) %}
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}>
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}{% if free|default(false) %} data-free="1"{% endif %}>
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
<div class="picker-input">
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
+2 -4
View File
@@ -53,8 +53,7 @@
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
</div>
<div>
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
{% 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 %}
</div>
{% if page.type == 'post' %}
<div>
@@ -75,8 +74,7 @@
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
</div>
<div>
<label for="media-{{ page.id }}">Beitragsbild</label>
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
{% 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 %}
</div>
<div>
<label for="excerpt-{{ page.id }}">Auszug</label>
+3 -3
View File
@@ -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 %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block title %}Formulare{% endblock %}
{% block heading %}Formulare{% endblock %}
@@ -105,7 +105,7 @@
{% else %}
<div class="card-head">
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
<span class="muted">Im Editor als Formularblock mit der Adresse <code>{{ form.slug }}</code></span>
<span class="muted">Im Editor über den Formularblock wählbar · Adresse <code>{{ form.slug }}</code></span>
</div>
{% endif %}
<div class="card-body form-editor">
@@ -120,7 +120,7 @@
<div>
<label for="slug-new">Adresse</label>
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
<p class="field-help">Wird im Formularblock eingetragen. Kleinbuchstaben, Ziffern, Bindestrich.</p>
<p class="field-help">Kurzname des Formulars, etwa kontakt. Kleinbuchstaben, Ziffern, Bindestrich. Im Editor wird das Formular dann aus einer Liste gewählt.</p>
</div>
{% endif %}
<div>
+17
View File
@@ -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 %}
<div class="media-field" data-media-field role="group" aria-labelledby="{{ id }}-label">
<span class="media-field-label" id="{{ id }}-label">{{ label }}</span>
<input type="hidden" id="{{ id }}" name="{{ name }}" value="{{ value }}">
<div class="media-field-current">
<img class="media-field-thumb" alt="" width="96" height="64"{% if item %} src="{{ (item.variants|first).src }}"{% else %} hidden{% endif %}>
<span class="media-field-name" aria-live="polite">{{ item ? item.title : (value ? 'Bild nicht gefunden' : 'Kein Bild gewählt') }}</span>
</div>
<div class="media-field-actions">
<button type="button" class="secondary" data-media-choose>{{ value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
<button type="button" class="text-button danger-text" data-media-remove{{ value ? '' : ' hidden' }}>Entfernen</button>
</div>
{% if help|default('') %}<p class="field-help">{{ help }}</p>{% endif %}
</div>
+2 -1
View File
@@ -4,7 +4,8 @@
{% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %}
{% block title %}{{ plural }}{% endblock %}
{% block heading %}{{ plural }}{% endblock %}
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script><script type="module" src="/assets/media-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
{% block content %}
+5 -2
View File
@@ -2,13 +2,17 @@
{% set nav = 'plugins' %}
{% block title %}Plugins{% endblock %}
{% block heading %}Plugins{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/media-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block lead %}Erweiterungen bringen eigene Bausteine, Vorlagen und Adressen mit. Sie liegen als Ordner unter <code>plugins/</code>; 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' %}
<div>{% include 'admin/media-field.html.twig' with {id: id, name: name, label: schema.label, value: value|default(''), help: schema.help|default('')} only %}</div>
{% elseif schema.kind == 'boolean' %}
<div><label class="check-label" for="{{ id }}"><input id="{{ id }}" type="checkbox" name="{{ name }}" value="1"{{ value ? ' checked' : '' }}> {{ schema.label }}</label>{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}</div>
{% else %}
<div>
@@ -16,7 +20,6 @@
{% if schema.kind == 'textarea' %}<textarea id="{{ id }}" name="{{ name }}" rows="3" maxlength="{{ schema.maxLength }}">{{ value }}</textarea>
{% elseif schema.kind == 'select' %}<select id="{{ id }}" name="{{ name }}">{% for wert, text in schema.choices %}<option value="{{ wert }}"{{ value == wert ? ' selected' : '' }}>{{ text }}</option>{% endfor %}</select>
{% elseif schema.kind == 'number' %}<input id="{{ id }}" name="{{ name }}" type="number" step="any" min="{{ schema.min }}" max="{{ schema.max }}" value="{{ value }}">
{% elseif schema.kind == 'media' %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" placeholder="Kennung aus der Mediathek" pattern="[a-f0-9]{32}">
{% else %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" maxlength="{{ schema.maxLength }}">{% endif %}
{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}
</div>
+2 -3
View File
@@ -292,9 +292,8 @@
<input id="from" name="from" maxlength="400" required placeholder="alte-seite">
</div>
<div>
<label for="to">Neue Adresse</label>
<input id="to" name="to" maxlength="400" placeholder="leistungen/webdesign">
<p class="field-help">Bei 410 leer lassen.</p>
{% include 'admin/content-picker.html.twig' with {id: 'to', name: 'to', label: 'Neue Adresse', valueKey: 'path', free: true} only %}
<p class="field-help">Seite suchen und wählen – oder einen anderen Pfad eintippen, etwa kategorie/projekte. Bei 410 leer lassen.</p>
</div>
<div>
<label for="status">Art</label>
+53
View File
@@ -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');