155 lines
10 KiB
JavaScript
155 lines
10 KiB
JavaScript
import { setupPicker } from './page-picker.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 rows = () => [...list.children].map(row => {
|
|
const data = { level: Number(row.dataset.level) };
|
|
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = 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;
|
|
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', '', label); caption.htmlFor = input.id; 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');
|
|
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'muted', data.type === 'page' ? 'Inhalt' : 'Eigener Link'));
|
|
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(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;
|
|
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
|
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;
|
|
}
|
|
|
|
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; }
|
|
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), level: 1 }); name.value = url.value = '';
|
|
});
|
|
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); });
|
|
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,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,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); }
|
|
});
|
|
refresh(); last = state(); saved = key(last);
|