Files
KantSites/public/assets/form-editor.js
T
kantarciandClaude Fable 5.2 ead47cc924 Keep old pages readable, detect stale settings writes, make backups honest
- Own CSS is parsed on write only. A page whose stored CSS no longer
  passes a stricter rule answers 200 and opens in the editor; that one
  block loses its CSS. A document read several times in one request is
  validated once.
- Settings and theme refuse a write from an outdated form.
- A backup fails when a media file does not arrive completely and
  leaves no half directory behind; rotation counts intact backups only.
- The archive update takes its backup behind the maintenance page.
- A package import puts all media in place before removing the old
  ones, reports honestly when that fails and keeps a maintenance state
  that was already on.
- Restoring replaces the SQLite file in one move and points at pending
  migrations.
- Six irreversible actions ask first, in a dialog of the admin's own.
- Ctrl+S in the editor takes the field being typed in along; Delete
  no longer removes a block while a dialog is open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:41:05 +01:00

357 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Der Formulareditor in der Verwaltung.
*
* Ohne Skript ist die Seite vollständig bedienbar: Felder sind Karten mit Eingaben, ein Feld ohne
* Beschriftung wird nicht gespeichert, die Vorschau zeigt den gespeicherten Stand. Das Skript
* macht daraus einen Editor: Felder hinzufügen und entfernen, den technischen Namen aus der
* Beschriftung ableiten, Optionen nur bei einer Auswahl zeigen, Farben aus der Palette wählen -
* und eine Vorschau, die jeder Eingabe folgt, mit dem Standardbutton der Website.
*/
import { createColorControl } from './color-control.js';
import { buttonSample } from './button-design.js';
import { openMedia } from './media.js';
const root = document.querySelector('[data-form-schema]');
if (root) {
const schema = JSON.parse(root.dataset.formSchema);
const bilder = JSON.parse(root.dataset.formImages || '[]');
const defaults = {
fieldBorderColor: schema.base.color,
fieldBackgroundColor: '#ffffff',
buttonColor: schema.buttons.backgroundColor,
buttonInk: schema.buttons.color,
};
const hex = value => /^#[0-9a-f]{6}/i.test(value || '') ? value.slice(0, 7) : schema.colors.find(color => `@${color.key}` === value)?.value || '';
// Dunkler beim Überfahren, so wie es die Seite selbst tut (82 % Farbe, Rest Schwarz).
const darker = value => {
const rgb = hex(value); if (!rgb) return value;
return '#' + [1, 3, 5].map(index => Math.round(parseInt(rgb.slice(index, index + 2), 16) * 0.82).toString(16).padStart(2, '0')).join('');
};
const luminance = rgb => {
const [r, g, b] = [1, 3, 5].map(index => parseInt(rgb.slice(index, index + 2), 16) / 255)
.map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (back, front) => {
if (!back || !front) return null;
const [light, dark] = [luminance(back), luminance(front)].sort((a, b) => b - a);
return (light + 0.05) / (dark + 0.05);
};
const slug = text => text.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 32);
const el = (tag, className, text) => {
const node = document.createElement(tag); node.className = className;
if (text !== undefined) node.textContent = text; return node;
};
for (const form of root.querySelectorAll('[data-form-editor]')) {
const list = form.querySelector('[data-field-list]'), template = form.querySelector('[data-field-template]');
const preview = form.querySelector('[data-form-preview]'), add = form.querySelector('[data-add-field]');
const buttonLabel = form.querySelector('[data-form-button-label]');
const colorInputs = {};
const cards = () => [...list.querySelectorAll('[data-field]')];
let next = cards().length;
const renderPreview = () => {
preview.replaceChildren();
preview.style.color = hex(schema.base.color) || '#203b34';
preview.style.background = hex(schema.base.backgroundColor) || '#ffffff';
preview.style.setProperty('--form-border', hex(colorInputs.fieldBorderColor.value) || 'currentColor');
preview.style.setProperty('--form-bg', hex(colorInputs.fieldBackgroundColor.value) || '#ffffff');
let shown = 0;
for (const card of cards()) {
const label = card.querySelector('[data-field-label]').value.trim();
if (!label) continue;
shown++;
const type = card.querySelector('[data-field-type]').value;
const help = card.querySelector('[data-field-help]').value.trim();
if (type === 'step') {
// Ein Schritt ist eine Zwischenüberschrift in der Vorschau, mit seiner Einleitung.
const step = el('div', 'form-preview-step');
step.append(el('span', 'form-preview-step-title', label));
if (help) step.append(el('span', 'form-preview-help', help));
preview.append(step);
continue;
}
const required = type === 'consent' || card.querySelector('[data-field-required]').checked;
const showIf = card.querySelector('[data-field-showif]').value.trim();
const field = el('div', `form-preview-field${showIf ? ' form-preview-conditional' : ''}`);
const options = () => card.querySelector('[data-field-options-input]').value.split(',').map(option => option.trim()).filter(Boolean);
if (type === 'consent') {
const check = el('span', 'form-preview-check');
check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`));
field.append(check);
} else if (type === 'radio' || type === 'checkboxes') {
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
for (const option of options()) {
const check = el('span', 'form-preview-check');
check.append(el('span', `form-preview-box${type === 'radio' ? ' form-preview-radio' : ''}`), document.createTextNode(option));
field.append(check);
}
} else {
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
const unit = type === 'number' ? card.querySelector('[data-field-unit]').value.trim() : '';
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}${type === 'number' ? ' form-preview-number' : ''}`, type === 'select' ? '— bitte wählen —' : unit));
}
if (help) field.append(el('span', 'form-preview-help', help));
if (showIf) field.append(el('span', 'form-preview-help', `Nur wenn ${showIf} = ${card.querySelector('[data-field-showvalues]').value.split(',').map(v => v.trim()).filter(Boolean).join(' oder ') || '…'}`));
preview.append(field);
}
if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.'));
const values = { ...schema.buttons };
const fill = colorInputs.buttonColor.value, ink = colorInputs.buttonInk.value;
if (fill) { values.backgroundColor = fill; values.hoverBackgroundColor = darker(fill); }
if (ink) { values.color = ink; values.hoverColor = ink; }
preview.append(buttonSample(buttonLabel.value.trim() || 'Absenden', values, schema));
// Der Kontrast der Buttonschrift auf der Buttonfarbe, nach WCAG: unter 4,5:1 ist der Text
// fuer viele nicht mehr lesbar - das soll man sehen, bevor es auf der Website steht.
const ratio = contrast(hex(values.backgroundColor), hex(values.color));
note.hidden = ratio === null;
if (ratio !== null) {
note.className = `contrast-note ${ratio >= 4.5 ? 'contrast-ok' : ratio >= 3 ? 'contrast-warn' : 'contrast-bad'}`;
note.textContent = `Buttonschrift auf Buttonfarbe: Kontrast ${ratio.toFixed(1).replace('.', ',')}:1 – ${ratio >= 4.5 ? 'gut lesbar.' : ratio >= 3 ? 'nur für große Schrift ausreichend, für den Button zu wenig.' : 'zu wenig, der Text ist kaum lesbar.'}`;
}
};
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
preview.after(note);
// 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);
let box = card.querySelector('[data-image-pickers]');
if (!box) {
box = el('div', 'field-image-pickers'); box.dataset.imagePickers = '';
area.hidden = true; area.after(box);
}
if (box.dataset.signature === options.join('|')) return;
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('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.';
};
// Eine Feldkarte ist eine Zeile; die Details öffnet man bei Bedarf. Darunter steht kurz, was
// in ihnen eingestellt ist, damit man nicht jede Karte aufklappen muss.
const setOpen = (card, open) => {
card.querySelector('[data-field-body]').hidden = !open;
const toggle = card.querySelector('[data-field-toggle]');
toggle.setAttribute('aria-expanded', String(open));
card.classList.toggle('is-open', open);
};
const meta = card => {
const type = card.querySelector('[data-field-type]').value;
const parts = [card.querySelector('[data-field-name]').value.trim()];
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
if (['select', 'radio', 'checkboxes'].includes(type)) parts.push(options.length ? options.join(', ') : 'noch keine Optionen');
if (card.querySelector('[data-field-help]').value.trim()) parts.push('Hilfetext');
if (card.querySelector('[data-field-showif]').value.trim()) parts.push('mit Bedingung');
for (const translation of card.querySelectorAll('[data-field-translation]')) {
if ([...translation.querySelectorAll('input')].some(input => input.value.trim())) parts.push(`${translation.dataset.fieldTranslation.toUpperCase()} ✓`);
}
return parts.filter(Boolean).join(' · ');
};
const refresh = () => {
cards().forEach((card, index) => {
card.querySelector('[data-field-index]').textContent = String(index + 1);
const type = card.querySelector('[data-field-type]').value;
const line = card.querySelector('[data-field-meta]');
line.textContent = meta(card); line.hidden = !line.textContent || card.classList.contains('is-open');
const label = card.querySelector('[data-field-label]').value.trim() || `Feld ${index + 1}`;
card.querySelector('[data-field-toggle]').setAttribute('aria-label', `Details zu „${label}“`);
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`);
const required = card.querySelector('[data-field-required]');
// Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken.
if (type === 'consent') { required.checked = true; required.disabled = true; }
else if (type === 'step') { required.checked = false; required.disabled = true; }
else required.disabled = false;
});
renderReply();
renderPreview();
};
// Die leere Karte für ein neues Feld braucht es nur, solange es kein Feld gibt: mit Skript
// kommt jedes weitere über den Knopf.
const empty = cards().filter(card => card.classList.contains('field-card-new'));
if (cards().length > empty.length) empty.forEach(card => card.remove());
add.hidden = false;
for (const card of cards()) {
card.querySelector('[data-field-toggle]').hidden = false;
setOpen(card, card.classList.contains('field-card-new'));
}
add.addEventListener('click', () => {
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
const card = list.lastElementChild; card.classList.remove('field-card-new');
card.querySelector('[data-field-toggle]').hidden = false; setOpen(card, true);
refresh(); card.querySelector('[data-field-label]').focus();
});
list.addEventListener('click', event => {
const toggle = event.target.closest('[data-field-toggle]');
if (toggle) { const card = toggle.closest('[data-field]'); setOpen(card, !card.classList.contains('is-open')); refresh(); return; }
const button = event.target.closest('[data-remove-field]');
if (!button) return;
const card = button.closest('[data-field]');
const name = card.querySelector('[data-field-label]')?.value.trim();
const remove = () => { card.remove(); refresh(); add.focus(); };
// Ein eben angelegtes, leeres Feld verschwindet ohne Frage; alles andere fragt nach.
if (!name || !window.kantConfirm) { remove(); return; }
window.kantConfirm({ title: `Feld „${name}“ entfernen?`, message: 'Bereits eingegangene Anfragen behalten ihre Angaben. Wirksam wird es erst mit „Speichern“.', confirmLabel: 'Feld entfernen', variant: 'danger', onConfirm: remove });
});
list.addEventListener('input', event => {
const card = event.target.closest('[data-field]');
if (event.target.matches('[data-field-label]')) {
// Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt.
const name = card.querySelector('[data-field-name]');
if (!name.dataset.touched) name.value = slug(event.target.value);
}
if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : '';
refresh();
});
list.addEventListener('change', refresh);
for (const card of cards()) {
const name = card.querySelector('[data-field-name]');
if (name.value) name.dataset.touched = '1';
}
for (const input of form.querySelectorAll('[data-form-color]')) {
const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent;
const id = input.id; input.type = 'hidden'; input.id += '-value';
colorInputs[key] = input;
const control = createColorControl({
id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors,
onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {},
});
control.querySelector('.color-inherit').textContent = 'Wie die Website';
input.after(control);
}
buttonLabel.addEventListener('input', renderPreview);
refresh();
}
}