Files
KantSites/public/assets/menu-editor.js
T
kantarciandClaude Opus 5 a976c146de Choose menu anchors, link targets in plugin settings and existing post terms
A menu entry picks its anchor from the linked page, link settings of plugins and own menu
links get page and PDF choice next to the address, and the post settings offer existing
categories and tags to click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:56:07 +02:00

276 lines
19 KiB
JavaScript

import { setupPicker } from './page-picker.js';
import './link-field.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 = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken', people: 'Menschen', music: 'Musik', home: 'Haus' };
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 = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg>';
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 = '<path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2"/>';
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;
const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true);
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();
summary.append(handle, 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(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';
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { 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 caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select);
}
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 bekommt eine Sektion unter „Erweitert“ einen Anker.';
} 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);
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);