import { setupPicker } from './page-picker.js';
import './link-field.js';
import { renderIconField, iconSvg, ICON_LABELS } from './icons.js';
import { createColorControl } from './color-control.js';
import { buttonSample } from './button-design.js';
const form = document.querySelector('#menu-editor');
const list = form.querySelector('#menu-items');
const status = form.querySelector('#menu-status');
const error = form.querySelector('#menu-error');
const history = [];
let serial = list.children.length, pendingPage = null, busy = false;
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
const iconNames = ICON_LABELS;
const buttonSchema = JSON.parse(form.dataset.buttonSchema || '{}');
const menuIcons = JSON.parse(form.dataset.icons || '[]');
const chrome = () => {
const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = '';
handle.title = 'Ziehen · Pfeiltasten ändern Reihenfolge und Ebene';
handle.innerHTML = '';
const chevron = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); chevron.setAttribute('class','menu-chevron'); chevron.setAttribute('viewBox','0 0 24 24'); chevron.setAttribute('width','20'); chevron.setAttribute('height','20'); chevron.setAttribute('aria-hidden','true');
chevron.innerHTML = '';
return { handle, chevron };
};
const rows = () => [...list.children].map(row => {
const data = { level: Number(row.dataset.level) };
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.type === 'checkbox' ? (input.checked ? '1' : '0') : input.value; });
data.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
return data;
});
const state = () => ({ name: form.elements.name.value, rows: rows() });
const key = value => JSON.stringify(value);
let last = state(), saved = key(last);
const dirty = () => key(state()) !== saved;
function refresh() {
[...list.children].forEach((row, index) => {
row.classList.toggle('menu-child', row.dataset.level === '2');
row.querySelectorAll('[data-key]').forEach(input => { input.name = `items[${index}][${input.dataset.key}]`; });
row.querySelector('[data-key="position"]').value = index;
row.querySelector('[data-key="level"]').value = row.dataset.level;
const picker = row.querySelector('.picker-value'); if (picker) picker.name = `items[${index}][pageId]`;
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
row.querySelector('.menu-entry-title').textContent = name;
const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value;
row.querySelector('.menu-entry-title svg')?.remove();
const graphic = icon && iconSvg(icon);
if (graphic) { graphic.setAttribute('width', '18'); graphic.setAttribute('height', '18'); }
if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic);
row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`);
row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; });
row.querySelector('[data-menu-action="up"]').disabled = index === 0;
row.querySelector('[data-menu-action="down"]').disabled = index === list.children.length - 1;
row.querySelector('[data-menu-action="indent"]').disabled = index === 0 || row.dataset.level === '2' || list.children[index + 1]?.dataset.level === '2';
row.querySelector('[data-menu-action="outdent"]').disabled = row.dataset.level === '1';
row.querySelector('[role="group"]').setAttribute('aria-label', `${name} verschieben oder entfernen`);
row.querySelectorAll('button').forEach(button => { button.disabled ||= busy; });
row.querySelector('[data-menu-action="up"]').setAttribute('aria-label', `${name} nach oben`);
row.querySelector('[data-menu-action="down"]').setAttribute('aria-label', `${name} nach unten`);
});
form.querySelector('#menu-count').textContent = `${list.children.length} Einträge`;
form.querySelector('#menu-empty').hidden = Boolean(list.children.length);
form.querySelector('#menu-undo').disabled = !history.length || busy;
status.textContent = busy ? 'Wird gespeichert …' : dirty() ? 'Ungespeicherte Änderungen' : 'Gespeicherter Stand';
}
function createRow(data) {
const id = `new-${serial++}`;
const row = el('li', 'menu-entry'); row.dataset.level = String(data.level ?? 1);
const field = (key, value, label, type = 'text') => {
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
input.id = `menu-${key}-${id}`;
if (type !== 'hidden') {
const caption = el('label', type === 'checkbox' ? 'check-label' : '', label); caption.htmlFor = input.id;
if (type === 'checkbox') { caption.prepend(input); body.append(caption); } else body.append(caption, input);
}
else row.append(input);
return input;
};
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
const details = el('details'); const summary = el('summary');
const { handle, chevron } = chrome();
// Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr - kein Knopf in einem Knopf.
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details);
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
if (data.type === 'page') {
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
picker.querySelector('label').htmlFor = input.id; picker.querySelector('label').textContent = 'Verknüpfter Inhalt';
const options = picker.querySelector('[role="listbox"]'); options.id = `${input.id}-results`; options.replaceChildren(); options.hidden = true;
input.setAttribute('aria-controls', options.id); input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant');
picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = '';
body.append(picker); setupPicker(picker);
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
anchorChoice(row);
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); }
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
const icons = el('div', 'menu-icon-fields'); body.append(icons);
for (const [key, label, choices] of [['icon', 'Symbol', { '': 'Kein Symbol', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Symbol steht', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value));
select.value = data[key] || (key === 'iconPosition' ? 'before' : '');
const wrap = el('div'); const caption = el('label', '', label); caption.htmlFor = select.id; wrap.append(caption, select); icons.append(wrap);
}
body.append(buttonFieldset(data, id));
enhance(row);
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
}
row.append(actions); return row;
}
/*
* Das Sprungziel wird aus den Ankern der verknüpften Seite gewählt. Das Textfeld bleibt, was
* gespeichert wird - versteckt, und ohne Skript sichtbar zum Eintippen.
*/
const adminBase = document.querySelector('meta[name="admin-base"]').content;
function anchorChoice(row) {
const input = row.querySelector('input[data-key="anchor"]');
const pageValue = row.querySelector('.picker-value');
if (!input || !pageValue || input.dataset.enhanced) return;
input.dataset.enhanced = '1';
const label = row.querySelector(`label[for="${input.id}"]`);
const select = el('select'); select.id = input.id; input.id += '-value'; input.type = 'hidden';
if (label) { label.htmlFor = select.id; label.textContent = 'Sprungziel'; }
input.after(select);
const help = el('p', 'field-help', ''); select.after(help);
let request = 0;
const load = async () => {
const mine = ++request, current = input.value;
select.replaceChildren(new Option('Seitenanfang', ''));
if (current) select.append(new Option(`#${current}`, current));
select.value = current;
help.textContent = '';
if (!pageValue.value) { select.disabled = true; return; }
select.disabled = false;
try {
const response = await fetch(`${adminBase}/api/sprungziele/${encodeURIComponent(pageValue.value)}`, { credentials: 'same-origin' });
if (!response.ok || response.redirected) throw new Error();
const { items } = await response.json();
if (mine !== request) return;
select.replaceChildren(new Option('Seitenanfang', ''));
for (const item of items) select.append(new Option(item.label, item.anchor));
if (current && !items.some(item => item.anchor === current)) select.append(new Option(`#${current} (auf der Seite nicht gefunden)`, current));
select.value = current;
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor lässt sich einer Sektion, Zeile oder Spalte ein Sprungziel geben.';
} catch {
if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.';
}
};
select.addEventListener('change', () => { input.value = select.value; input.dispatchEvent(new Event('change', { bubbles: true })); });
pageValue.addEventListener('change', () => { input.value = ''; load(); });
load();
}
list.querySelectorAll('.menu-entry').forEach(anchorChoice);
function linkButtons(input) {
// Die Adresse eines eigenen Links: Seite oder PDF wählen, eine fremde Adresse tippen.
if (input.closest('[data-link-field]')) return;
input.dataset.linkInput = '';
const box = el('div', 'link-field'); box.dataset.linkField = '';
input.before(box); box.append(input);
}
list.querySelectorAll('input[data-key="url"]').forEach(linkButtons);
/*
* Ein Eintrag als Button (ADR 110): dieselben Felder wie die Vorlage menu-entry.html.twig, damit
* neue und geladene Zeilen gleich aussehen und dieselben Namen tragen. Leer heißt Website-Standard.
*/
const BUTTON_COLORS = { buttonFill: 'Fläche', buttonInk: 'Schrift', buttonHoverFill: 'Fläche beim Überfahren', buttonHoverInk: 'Schrift beim Überfahren', buttonIconColor: 'Symbolfarbe' };
function buttonFieldset(data, id) {
const set = el('fieldset', 'menu-button-fields'); set.dataset.buttonFields = '';
set.append(el('legend', '', 'Button'));
const toggle = el('input'); toggle.type = 'checkbox'; toggle.dataset.key = 'button'; toggle.value = '1'; toggle.id = `menu-button-${id}`;
toggle.checked = data.button === '1' || data.button === true;
const toggleLabel = el('label', 'check-label', ' Als Button darstellen'); toggleLabel.htmlFor = toggle.id; toggleLabel.prepend(toggle);
const hidden = el('input'); hidden.type = 'hidden'; hidden.dataset.key = 'buttonId'; hidden.value = data.buttonId || '';
const options = el('div', 'menu-button-options'); options.dataset.buttonOptions = ''; options.hidden = !toggle.checked;
const select = (key, label, choices, value) => {
const wrap = el('div'); const input = el('select'); input.dataset.key = key; input.id = `menu-${key}-${id}`;
for (const [option, caption] of Object.entries(choices)) input.append(new Option(caption, option));
input.value = value; const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
};
for (const [key, label] of [['shape', 'Form'], ['appearance', 'Darstellung'], ['size', 'Größe']]) {
const field = `button${key[0].toUpperCase()}${key.slice(1)}`;
select(field, label, { '': 'Website-Standard', ...(buttonSchema.buttonOptions?.[key] ?? {}) }, data[field] || '');
}
for (const [key, label] of Object.entries(BUTTON_COLORS)) {
const wrap = el('div'); wrap.dataset.colorField = '';
const input = el('input'); input.dataset.key = key; input.id = `menu-${key}-${id}`; input.value = data[key] || ''; input.placeholder = 'Website-Standard';
const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
}
select('iconMotion', 'Symbol beim Überfahren', { none: 'Ruhig', slide: 'Rückt', reveal: 'Erscheint' }, data.iconMotion || 'none');
const only = el('input'); only.type = 'checkbox'; only.dataset.key = 'iconOnly'; only.value = '1'; only.id = `menu-iconOnly-${id}`; only.checked = data.iconOnly === '1';
const onlyLabel = el('label', 'check-label', ' Nur das Symbol zeigen'); onlyLabel.htmlFor = only.id; onlyLabel.prepend(only); options.append(onlyLabel);
select('buttonMobile', 'Auf dem Telefon', buttonSchema.mobile ?? { menu: 'Im aufgeklappten Menü' }, data.buttonMobile || 'menu');
set.append(toggleLabel, hidden, options);
return set;
}
/*
* Aus Feldern wird Bedienung: das Symbol über das Auswahlfenster statt einer langen Liste, Farben
* mit der Palette der Website wie im Editor, und eine Vorschau des Buttons - normal und beim
* Überfahren. Gespeichert wird weiter, was in den Feldern steht; ohne Skript bleiben sie sichtbar.
*/
function enhance(row) {
if (row.dataset.enhanced) return;
row.dataset.enhanced = '1';
const value = key => row.querySelector(`[data-key="${key}"]`);
const changed = input => input.dispatchEvent(new Event('change', { bubbles: true }));
// Symbol: das Auswahlfenster schreibt in die versteckte Auswahl.
const iconSelect = value('icon');
if (iconSelect) {
const holder = iconSelect.closest('div'); holder.hidden = true;
const mount = el('div', 'menu-icon-choice'); holder.before(mount);
const draw = () => mount.replaceChildren(renderIconField({
label: 'Symbol', value: iconSelect.value || 'none', none: 'Kein Symbol',
onChoose: name => { iconSelect.value = name === 'none' ? '' : name; changed(iconSelect); draw(); preview(); mount.querySelector('.icon-field-change')?.focus(); },
}));
draw();
}
const toggle = value('button'), options = row.querySelector('[data-button-options]');
if (!toggle || !options) return;
const show = () => { options.hidden = !toggle.checked; };
toggle.addEventListener('change', show);
for (const wrap of options.querySelectorAll('[data-color-field]')) {
const input = wrap.querySelector('input'); const caption = wrap.querySelector('label');
input.type = 'hidden';
const control = createColorControl({
id: `${input.id}-control`, label: caption.textContent, value: input.value, colors: buttonSchema.colors ?? [],
onChange: raw => { input.value = raw; changed(input); preview(); },
});
caption.htmlFor = `${input.id}-control`;
wrap.append(control);
}
const box = el('div', 'menu-button-preview'); box.setAttribute('aria-hidden', 'true');
options.prepend(box);
function preview() {
if (!buttonSchema.buttons) return;
const pick = key => value(key)?.value || '';
const values = { ...buttonSchema.buttons };
for (const [field, key] of [['buttonShape', 'shape'], ['buttonAppearance', 'appearance'], ['buttonSize', 'size'], ['buttonFill', 'backgroundColor'], ['buttonInk', 'color'], ['buttonHoverFill', 'hoverBackgroundColor'], ['buttonHoverInk', 'hoverColor']]) {
if (pick(field)) values[key] = pick(field);
}
const icon = pick('icon'), before = pick('iconPosition') !== 'after', only = value('iconOnly')?.checked && icon;
const samples = [['Normal', false], ['Beim Überfahren', true]].map(([title, hover]) => {
const state = el('div', 'button-state'); state.append(el('span', 'field-help', title));
const sample = buttonSample(only ? '\u200b' : (value('label')?.value || 'Button'), values, buttonSchema, hover);
if (icon) {
const holder = el('span', 'button-icon'); holder.append(iconSvg(icon));
const color = pick('buttonIconColor');
if (color) holder.style.color = color.startsWith('@') ? (buttonSchema.colors ?? []).find(entry => `@${entry.key}` === color)?.value || '' : color;
if (before) sample.prepend(holder); else sample.append(holder);
}
state.append(sample); return state;
});
box.replaceChildren(...samples);
}
row.addEventListener('input', preview);
row.addEventListener('change', preview);
show(); preview();
}
list.querySelectorAll('.menu-entry').forEach(enhance);
function restore(value) {
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
}
function commit(before) {
refresh(); const after = state();
if (key(before) !== key(after)) { history.push(before); if (history.length > 50) history.shift(); }
last = after; refresh();
}
function add(data) {
if (list.children.length >= 60) { error.textContent = 'Ein Menü fasst höchstens 60 Einträge.'; error.hidden = false; return; }
const before = state(); const row = createRow(data); list.append(row); commit(before);
row.scrollIntoView({ block: 'nearest' }); row.querySelector('summary').focus();
}
const addPicker = form.querySelector('.menu-add .content-picker');
addPicker.querySelector('.picker-value').addEventListener('change', () => {
const id = addPicker.querySelector('.picker-value').value;
pendingPage = id ? { id, display: addPicker.querySelector('[role="combobox"]').value } : null;
form.querySelector('#menu-add-page').disabled = !pendingPage;
});
form.querySelector('#menu-add-page').addEventListener('click', () => {
if (pendingPage) add({ type: 'page', label: pendingPage.display.replace(/ \(\/.*\)$/, ''), pageId: pendingPage.id, pageDisplay: pendingPage.display, level: 1 });
});
form.querySelector('#menu-add-url').addEventListener('click', () => {
const name = form.querySelector('#link-label'), url = form.querySelector('#link-url');
if (!name.value.trim() || !url.value.trim()) { error.hidden = false; error.textContent = 'Bitte Beschriftung und Adresse des Links ausfüllen.'; (!name.value.trim() ? name : url).focus(); return; }
const newTab = form.querySelector('#link-new-tab');
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), newTab: newTab.checked, level: 1 }); name.value = url.value = ''; newTab.checked = false;
});
form.addEventListener('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
list.addEventListener('click', event => {
const button = event.target.closest('[data-menu-action]'); if (!button || busy) return;
const row = button.closest('.menu-entry'), before = state(), action = button.dataset.menuAction;
const entries = [...list.children], index = entries.indexOf(row);
const bundle = [row];
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
if (action === 'remove') {
row.remove();
// Removing a heading never deletes its links: promote its children visibly.
bundle.slice(1).forEach(child => { child.dataset.level = '1'; });
}
if (action === 'indent') row.dataset.level = '2';
if (action === 'outdent') row.dataset.level = '1';
if (action === 'up' && index > 0) {
let target = index - 1;
if (row.dataset.level === '1') while (target > 0 && entries[target].dataset.level === '2') target--;
bundle.forEach(item => list.insertBefore(item, entries[target]));
}
if (action === 'down') {
let target = index + bundle.length;
if (entries[target]) {
target++;
if (row.dataset.level === '1') while (entries[target]?.dataset.level === '2') target++;
bundle.forEach(item => list.insertBefore(item, entries[target] || null));
}
}
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
commit(before); if (row.isConnected) row.querySelector('summary').focus();
});
form.querySelector('#menu-undo').addEventListener('click', () => { const previous = history.pop(); if (previous) restore(previous); });
// Pointer capture supports mouse, pen and touch. A parent travels with its children.
let drag = null;
list.addEventListener('click', event => { if (event.target.closest('[data-menu-drag]')) { event.preventDefault(); event.stopPropagation(); } });
list.addEventListener('keydown', event => {
const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy) return;
const action = { ArrowUp: 'up', ArrowDown: 'down', ArrowRight: 'indent', ArrowLeft: 'outdent' }[event.key];
if (action) { event.preventDefault(); const row = handle.closest('.menu-entry'); row.querySelector(`[data-menu-action="${action}"]`).click(); handle.focus(); }
});
list.addEventListener('pointerdown', event => {
const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy || event.button !== 0) return;
event.preventDefault(); handle.focus(); handle.setPointerCapture(event.pointerId);
const row = handle.closest('.menu-entry'), entries = [...list.children], index = entries.indexOf(row), bundle = [row];
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
drag = { handle, row, bundle, before: state(), x: event.clientX, y: event.clientY, pointer: event.pointerId, active: false, level: row.dataset.level };
});
list.addEventListener('pointermove', event => {
if (!drag || event.pointerId !== drag.pointer) return;
if (!drag.active && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 6) return;
drag.active = true; drag.bundle.forEach(row => row.classList.add('menu-dragging'));
const others = [...list.children].filter(row => !drag.bundle.includes(row));
let target = others.find(row => event.clientY < row.getBoundingClientRect().top + row.getBoundingClientRect().height / 2) || null;
// A main entry never splits the children of another main entry.
if ((drag.bundle.length > 1 || drag.level === '1') && target?.dataset.level === '2') {
let index = others.indexOf(target); while (index > 0 && others[index].dataset.level === '2') index--; target = others[index];
}
drag.bundle.forEach(row => list.insertBefore(row, target));
if (drag.bundle.length === 1) drag.row.dataset.level = event.clientX - drag.x > 36 ? '2' : event.clientX - drag.x < -36 ? '1' : drag.level;
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
refresh();
if (event.clientY > window.innerHeight - 65) window.scrollBy(0, 18); else if (event.clientY < 65) window.scrollBy(0, -18);
});
function endDrag(cancel = false) {
if (!drag) return;
const previous = drag; drag = null;
previous.bundle.forEach(row => row.classList.remove('menu-dragging'));
if (previous.handle.hasPointerCapture(previous.pointer)) previous.handle.releasePointerCapture(previous.pointer);
if (cancel) restore(previous.before); else if (previous.active) { commit(previous.before); previous.handle.focus(); status.textContent = 'Reihenfolge geändert · noch nicht gespeichert'; }
}
list.addEventListener('pointerup', () => endDrag()); list.addEventListener('pointercancel', () => endDrag(true));
document.addEventListener('keydown', event => { if (event.key === 'Escape' && drag) { event.preventDefault(); endDrag(true); } });
window.addEventListener('beforeunload', event => { if (dirty()) { event.preventDefault(); event.returnValue = ''; } });
form.noValidate = true;
form.addEventListener('submit', async event => {
event.preventDefault(); if (busy) return;
const invalid = form.querySelector(':invalid');
if (invalid) { const details = invalid.closest('details'); if (details) details.open = true; invalid.reportValidity(); return; }
refresh(); const body = new FormData(form); const snapshot = key(state());
busy = true; error.hidden = true; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = true; }); refresh();
try {
const response = await fetch(form.action, { method: 'POST', body, headers: { Accept: 'application/json' }, credentials: 'same-origin' });
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Die Änderungen bleiben hier erhalten. Bitte erneut anmelden.');
const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Das Menü konnte nicht gespeichert werden.');
form.elements.expectedVersion.value = result.version; saved = snapshot;
} catch (failure) { error.textContent = failure.message || 'Verbindung unterbrochen. Die Änderungen bleiben hier erhalten.'; error.hidden = false; }
finally { busy = false; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); }
});
refresh(); last = state(); saved = key(last);