Files
KantSites/public/assets/form-editor.js
T
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:52 +02:00

352 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;
button.closest('[data-field]').remove(); refresh(); add.focus();
});
list.addEventListener('input', event => {
const card = event.target.closest('[data-field]');
if (event.target.matches('[data-field-label]')) {
// Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt.
const name = card.querySelector('[data-field-name]');
if (!name.dataset.touched) name.value = slug(event.target.value);
}
if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : '';
refresh();
});
list.addEventListener('change', refresh);
for (const card of cards()) {
const name = card.querySelector('[data-field-name]');
if (name.value) name.dataset.touched = '1';
}
for (const input of form.querySelectorAll('[data-form-color]')) {
const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent;
const id = input.id; input.type = 'hidden'; input.id += '-value';
colorInputs[key] = input;
const control = createColorControl({
id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors,
onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {},
});
control.querySelector('.color-inherit').textContent = 'Wie die Website';
input.after(control);
}
buttonLabel.addEventListener('input', renderPreview);
refresh();
}
}