Fonts: every Google font the editor offers (13 families, latin and latin-ext, 1.2 MB) now lives in public/fonts; the layout inlines @font-face rules only for the fonts in use, built from src/lib/font-faces.json. scripts/fonts-holen.py re-fetches them. The admin font preview loads the same files via /admin/fonts and /admin/api/font-faces. Checked: no request leaves larpaso.de on the start page, Impressum or Datenschutz, nor in the admin editor. nginx: access_log off, so visitor IPs are not stored. Caddy has no access log configured either. Privacy policy replaces the placeholder and describes only what the site actually does: controller (from the Impressum), hosting at netcup in Nuremberg (from the server IP), no access logs, HTTPS, mailto contact form and e-mail, self-hosted fonts and media, no cookies, data subject rights and the Lower Saxony supervisory authority. The closing note is no longer styled as a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
682 lines
22 KiB
JavaScript
682 lines
22 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
let schema = [];
|
|
let fonts = [];
|
|
let state = { content: null, theme: null };
|
|
let activeSectionKey = null;
|
|
const statusEl = document.getElementById('status');
|
|
const sectionsEl = document.getElementById('sections');
|
|
const navEl = document.getElementById('section-nav');
|
|
|
|
function setStatus(text, kind) {
|
|
statusEl.textContent = text;
|
|
statusEl.className = 'status' + (kind ? ' ' + kind : '');
|
|
}
|
|
|
|
function el(tag, attrs, children) {
|
|
const node = document.createElement(tag);
|
|
if (attrs) {
|
|
for (const [key, value] of Object.entries(attrs)) {
|
|
if (key === 'class') node.className = value;
|
|
else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2), value);
|
|
else if (value !== undefined && value !== null) node.setAttribute(key, value);
|
|
}
|
|
}
|
|
(children || []).forEach((child) => {
|
|
if (child === null || child === undefined) return;
|
|
node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
|
|
});
|
|
return node;
|
|
}
|
|
|
|
async function api(path, options) {
|
|
const res = await fetch(path, {
|
|
...options,
|
|
headers: {
|
|
'X-Admin-Request': '1',
|
|
...(options && options.body && !(options.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
|
|
...(options ? options.headers : {}),
|
|
},
|
|
});
|
|
if (res.status === 401) {
|
|
window.location.href = '/admin/login';
|
|
throw new Error('Nicht angemeldet.');
|
|
}
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.error || `Fehler (${res.status})`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function injectFontPreviewStyles() {
|
|
const rules = fonts
|
|
.map((f) => `[data-font="${f.name}"] { font-family: '${f.name}', ${f.fallback}; }`)
|
|
.join('\n');
|
|
const style = el('style', {}, [rules]);
|
|
document.head.appendChild(style);
|
|
|
|
// Font files come from our own server (/admin/fonts), not from Google.
|
|
api('/admin/api/font-faces')
|
|
.then((faces) => {
|
|
const faceRules = Object.entries(faces)
|
|
.flatMap(([name, list]) =>
|
|
list.map(
|
|
(face) =>
|
|
`@font-face { font-family: '${name}'; font-weight: ${face.weight}; font-display: swap; ` +
|
|
`src: url('/admin${face.file}') format('woff2'); unicode-range: ${face.unicodeRange}; }`,
|
|
),
|
|
)
|
|
.join('\n');
|
|
document.head.appendChild(el('style', {}, [faceRules]));
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// --- Field renderers ---
|
|
|
|
function textField(field, getValue, setValue) {
|
|
const isArea = field.type === 'textarea';
|
|
const input = isArea
|
|
? el('textarea', { rows: 4, oninput: (e) => setValue(e.target.value) })
|
|
: el('input', {
|
|
type: field.type === 'email' ? 'email' : 'text',
|
|
oninput: (e) => setValue(e.target.value),
|
|
});
|
|
input.value = getValue() || '';
|
|
return el('div', { class: 'field' }, [el('label', {}, [field.label]), input]);
|
|
}
|
|
|
|
function selectField(field, getValue, setValue) {
|
|
const select = el(
|
|
'select',
|
|
{ onchange: (e) => setValue(e.target.value) },
|
|
(field.options || []).map((opt) => {
|
|
const optEl = el('option', { value: opt }, [opt]);
|
|
if (opt === getValue()) optEl.selected = true;
|
|
return optEl;
|
|
})
|
|
);
|
|
return el('div', { class: 'field' }, [el('label', {}, [field.label]), select]);
|
|
}
|
|
|
|
function sizeField(field, getValue, setValue) {
|
|
const raw = getValue() || '1rem';
|
|
const numeric = parseFloat(raw) || 1;
|
|
const numberOut = el('span', { class: 'size-value' }, [raw]);
|
|
const slider = el('input', {
|
|
type: 'range',
|
|
min: String(field.min ?? 0.5),
|
|
max: String(field.max ?? 4),
|
|
step: String(field.step ?? 0.025),
|
|
oninput: (e) => {
|
|
const v = `${e.target.value}rem`;
|
|
setValue(v);
|
|
numberOut.textContent = v;
|
|
},
|
|
});
|
|
slider.value = String(numeric);
|
|
return el('div', { class: 'field' }, [el('label', {}, [field.label]), el('div', { class: 'size-field' }, [slider, numberOut])]);
|
|
}
|
|
|
|
function boolField(field, getValue, setValue) {
|
|
const input = el('input', { type: 'checkbox', onchange: (e) => setValue(e.target.checked) });
|
|
input.checked = Boolean(getValue());
|
|
const id = 'b_' + Math.random().toString(36).slice(2);
|
|
input.id = id;
|
|
return el('div', { class: 'field field-bool' }, [input, el('label', { for: id }, [field.label])]);
|
|
}
|
|
|
|
function clamp255(n) {
|
|
return Math.max(0, Math.min(255, Math.round(n)));
|
|
}
|
|
|
|
function toHex(color) {
|
|
const h = (n) => clamp255(n).toString(16).padStart(2, '0');
|
|
return `#${h(color.r)}${h(color.g)}${h(color.b)}`;
|
|
}
|
|
|
|
function fromHex(hex, alpha) {
|
|
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
|
|
if (!m) return null;
|
|
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16), a: alpha };
|
|
}
|
|
|
|
function colorField(field, getValue, setValue) {
|
|
const color = Object.assign({ r: 0, g: 0, b: 0, a: 1 }, getValue());
|
|
const swatch = el('div', { class: 'color-swatch' });
|
|
const hexInput = el('input', { type: 'text', class: 'color-hex' });
|
|
const nativePicker = el('input', { type: 'color' });
|
|
|
|
const channelInputs = {};
|
|
const channelNumbers = {};
|
|
['r', 'g', 'b'].forEach((ch) => {
|
|
channelInputs[ch] = el('input', { type: 'range', min: '0', max: '255', step: '1' });
|
|
channelNumbers[ch] = el('span', { class: 'channel-value' });
|
|
});
|
|
const alphaInput = el('input', { type: 'range', min: '0', max: '100', step: '1' });
|
|
const alphaNumber = el('span', { class: 'channel-value' });
|
|
|
|
function refresh() {
|
|
const hex = toHex(color);
|
|
swatch.style.background = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
|
|
hexInput.value = hex;
|
|
nativePicker.value = hex;
|
|
['r', 'g', 'b'].forEach((ch) => {
|
|
channelInputs[ch].value = String(color[ch]);
|
|
channelNumbers[ch].textContent = String(clamp255(color[ch]));
|
|
});
|
|
alphaInput.value = String(Math.round(color.a * 100));
|
|
alphaNumber.textContent = `${Math.round(color.a * 100)}%`;
|
|
}
|
|
|
|
function commit() {
|
|
setValue({ ...color });
|
|
refresh();
|
|
}
|
|
|
|
hexInput.addEventListener('change', () => {
|
|
const parsed = fromHex(hexInput.value, color.a);
|
|
if (parsed) {
|
|
color.r = parsed.r;
|
|
color.g = parsed.g;
|
|
color.b = parsed.b;
|
|
commit();
|
|
} else {
|
|
refresh(); // revert invalid input
|
|
}
|
|
});
|
|
|
|
nativePicker.addEventListener('input', () => {
|
|
const parsed = fromHex(nativePicker.value, color.a);
|
|
if (parsed) {
|
|
color.r = parsed.r;
|
|
color.g = parsed.g;
|
|
color.b = parsed.b;
|
|
commit();
|
|
}
|
|
});
|
|
|
|
['r', 'g', 'b'].forEach((ch) => {
|
|
channelInputs[ch].addEventListener('input', (e) => {
|
|
color[ch] = clamp255(Number(e.target.value));
|
|
commit();
|
|
});
|
|
});
|
|
|
|
alphaInput.addEventListener('input', (e) => {
|
|
color.a = Math.max(0, Math.min(100, Number(e.target.value))) / 100;
|
|
commit();
|
|
});
|
|
|
|
refresh();
|
|
|
|
return el('div', { class: 'field' }, [
|
|
el('label', {}, [field.label]),
|
|
el('div', { class: 'color-field' }, [
|
|
swatch,
|
|
el('div', { class: 'color-field-controls' }, [
|
|
el('div', { class: 'color-field-row' }, [nativePicker, hexInput]),
|
|
el('div', { class: 'color-channels' }, [
|
|
el('span', { class: 'channel-label' }, ['R']),
|
|
channelInputs.r,
|
|
channelNumbers.r,
|
|
el('span', { class: 'channel-label' }, ['G']),
|
|
channelInputs.g,
|
|
channelNumbers.g,
|
|
el('span', { class: 'channel-label' }, ['B']),
|
|
channelInputs.b,
|
|
channelNumbers.b,
|
|
el('span', { class: 'channel-label' }, ['A']),
|
|
alphaInput,
|
|
alphaNumber,
|
|
]),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
function imageField(field, getValue, setValue) {
|
|
const preview = el('img', { class: 'image-preview', alt: '' });
|
|
const urlInput = el('input', { type: 'text', placeholder: '/uploads/...' });
|
|
const fileInput = el('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp,image/svg+xml', hidden: true });
|
|
|
|
function refresh() {
|
|
const v = getValue() || '';
|
|
if (v) preview.setAttribute('src', v);
|
|
else preview.removeAttribute('src');
|
|
preview.style.visibility = v ? 'visible' : 'hidden';
|
|
urlInput.value = v;
|
|
}
|
|
|
|
urlInput.addEventListener('change', () => {
|
|
setValue(urlInput.value.trim());
|
|
refresh();
|
|
});
|
|
|
|
fileInput.addEventListener('change', async () => {
|
|
const file = fileInput.files[0];
|
|
if (!file) return;
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
setStatus('Bild wird hochgeladen …');
|
|
try {
|
|
const data = await api('/admin/api/upload', { method: 'POST', body: formData });
|
|
setValue(data.url);
|
|
refresh();
|
|
setStatus('Bild hochgeladen.', 'ok');
|
|
} catch (err) {
|
|
setStatus(err.message, 'error');
|
|
}
|
|
});
|
|
|
|
const uploadBtn = el('button', { type: 'button', onclick: () => fileInput.click() }, ['Hochladen']);
|
|
const clearBtn = el('button', {
|
|
type: 'button',
|
|
class: 'btn-ghost',
|
|
onclick: () => {
|
|
setValue('');
|
|
refresh();
|
|
},
|
|
}, ['Entfernen']);
|
|
|
|
refresh();
|
|
|
|
return el('div', { class: 'field' }, [
|
|
el('label', {}, [field.label]),
|
|
el('div', { class: 'image-field' }, [
|
|
preview,
|
|
el('div', { class: 'image-field-controls' }, [uploadBtn, clearBtn, urlInput, fileInput]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// --- Rich text (WYSIWYG) ---
|
|
|
|
function richTextEditor(getValue, setValue) {
|
|
const editable = el('div', { class: 'richtext-editable', contenteditable: 'true' });
|
|
editable.innerHTML = getValue() || '';
|
|
|
|
function commit() {
|
|
setValue(editable.innerHTML);
|
|
}
|
|
|
|
editable.addEventListener('input', commit);
|
|
editable.addEventListener('blur', commit);
|
|
|
|
// Force plain-text paste so what you see while editing matches what
|
|
// gets saved (formatting from external sources isn't in our allowlist
|
|
// and would otherwise be silently stripped again on save).
|
|
editable.addEventListener('paste', (e) => {
|
|
e.preventDefault();
|
|
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
|
document.execCommand('insertText', false, text);
|
|
});
|
|
|
|
function runCommand(cmd) {
|
|
editable.focus();
|
|
document.execCommand(cmd, false, null);
|
|
commit();
|
|
}
|
|
|
|
function applyFont(fontName) {
|
|
editable.focus();
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
|
setStatus('Bitte zuerst Text markieren.', 'error');
|
|
return;
|
|
}
|
|
const range = sel.getRangeAt(0);
|
|
if (!editable.contains(range.commonAncestorContainer)) return;
|
|
const span = document.createElement('span');
|
|
span.setAttribute('data-font', fontName);
|
|
try {
|
|
range.surroundContents(span);
|
|
} catch (err) {
|
|
const contents = range.extractContents();
|
|
span.appendChild(contents);
|
|
range.insertNode(span);
|
|
}
|
|
sel.removeAllRanges();
|
|
commit();
|
|
}
|
|
|
|
function removeFontFromSelection() {
|
|
editable.focus();
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) return;
|
|
const range = sel.getRangeAt(0);
|
|
if (!editable.contains(range.commonAncestorContainer)) return;
|
|
editable.querySelectorAll('span[data-font]').forEach((span) => {
|
|
if (range.intersectsNode(span)) {
|
|
const parent = span.parentNode;
|
|
while (span.firstChild) parent.insertBefore(span.firstChild, span);
|
|
parent.removeChild(span);
|
|
}
|
|
});
|
|
commit();
|
|
}
|
|
|
|
const boldBtn = el('button', { type: 'button', class: 'rt-btn', title: 'Fett', onclick: () => runCommand('bold') }, ['B']);
|
|
const italicBtn = el('button', { type: 'button', class: 'rt-btn rt-italic', title: 'Kursiv', onclick: () => runCommand('italic') }, ['I']);
|
|
const underlineBtn = el('button', { type: 'button', class: 'rt-btn rt-underline', title: 'Unterstrichen', onclick: () => runCommand('underline') }, ['U']);
|
|
|
|
const fontSelect = el('select', {
|
|
class: 'rt-font-select',
|
|
onchange: (e) => {
|
|
if (e.target.value === '__remove__') removeFontFromSelection();
|
|
else if (e.target.value) applyFont(e.target.value);
|
|
e.target.value = '';
|
|
},
|
|
}, [
|
|
el('option', { value: '' }, ['Schriftart …']),
|
|
el('option', { value: '__remove__' }, ['— Schriftart entfernen —']),
|
|
...fonts.map((f) => el('option', { value: f.name }, [f.name])),
|
|
]);
|
|
|
|
const toolbar = el('div', { class: 'richtext-toolbar' }, [boldBtn, italicBtn, underlineBtn, fontSelect]);
|
|
|
|
return el('div', {}, [toolbar, editable]);
|
|
}
|
|
|
|
function richTextField(field, getValue, setValue) {
|
|
return el('div', { class: 'field' }, [el('label', {}, [field.label]), richTextEditor(getValue, setValue)]);
|
|
}
|
|
|
|
function renderRichTextList(field, getObj) {
|
|
const wrap = el('div', { class: 'field' }, [el('label', {}, [field.label])]);
|
|
const listEl = el('div', {}, []);
|
|
|
|
function draw() {
|
|
listEl.innerHTML = '';
|
|
const arr = getObj()[field.key];
|
|
arr.forEach((_, i) => {
|
|
const editorEl = richTextEditor(
|
|
() => arr[i],
|
|
(v) => {
|
|
arr[i] = v;
|
|
}
|
|
);
|
|
const removeBtn = el('button', {
|
|
type: 'button',
|
|
class: 'remove-btn',
|
|
onclick: () => {
|
|
arr.splice(i, 1);
|
|
draw();
|
|
},
|
|
}, ['Entfernen']);
|
|
listEl.appendChild(el('div', { class: 'list-item-card' }, [editorEl, removeBtn]));
|
|
});
|
|
}
|
|
|
|
draw();
|
|
|
|
const addBtn = el('button', {
|
|
type: 'button',
|
|
class: 'add-btn',
|
|
onclick: () => {
|
|
getObj()[field.key].push('');
|
|
draw();
|
|
},
|
|
}, ['+ hinzufügen']);
|
|
|
|
wrap.appendChild(listEl);
|
|
wrap.appendChild(addBtn);
|
|
return wrap;
|
|
}
|
|
|
|
function renderFieldGroup(fields, getObj) {
|
|
const container = el('div', {}, []);
|
|
fields.forEach((field) => {
|
|
const getValue = () => getObj()[field.key];
|
|
const setValue = (v) => {
|
|
getObj()[field.key] = v;
|
|
};
|
|
if (field.type === 'boolean') {
|
|
container.appendChild(boolField(field, getValue, setValue));
|
|
} else if (field.type === 'image') {
|
|
container.appendChild(imageField(field, getValue, setValue));
|
|
} else if (field.type === 'select') {
|
|
container.appendChild(selectField(field, getValue, setValue));
|
|
} else if (field.type === 'size') {
|
|
container.appendChild(sizeField(field, getValue, setValue));
|
|
} else if (field.type === 'color') {
|
|
container.appendChild(colorField(field, getValue, setValue));
|
|
} else if (field.type === 'richtext') {
|
|
container.appendChild(richTextField(field, getValue, setValue));
|
|
} else if (field.type === 'group') {
|
|
container.appendChild(
|
|
el('fieldset', { class: 'group-field' }, [
|
|
el('legend', {}, [field.label]),
|
|
renderFieldGroup(field.itemFields, () => getObj()[field.key]),
|
|
])
|
|
);
|
|
} else if (field.type === 'list-string') {
|
|
container.appendChild(renderStringList(field, getObj));
|
|
} else if (field.type === 'list-richtext') {
|
|
container.appendChild(renderRichTextList(field, getObj));
|
|
} else if (field.type === 'list-object') {
|
|
container.appendChild(renderObjectList(field, getObj));
|
|
} else {
|
|
container.appendChild(textField(field, getValue, setValue));
|
|
}
|
|
});
|
|
return container;
|
|
}
|
|
|
|
function renderStringList(field, getObj) {
|
|
const wrap = el('div', { class: 'field' }, [el('label', {}, [field.label])]);
|
|
const listEl = el('div', {}, []);
|
|
|
|
function draw() {
|
|
listEl.innerHTML = '';
|
|
const arr = getObj()[field.key];
|
|
arr.forEach((_, i) => {
|
|
const input = el('input', { type: 'text' });
|
|
input.value = arr[i];
|
|
input.addEventListener('input', (e) => {
|
|
arr[i] = e.target.value;
|
|
});
|
|
const removeBtn = el('button', {
|
|
type: 'button',
|
|
class: 'remove-btn',
|
|
onclick: () => {
|
|
arr.splice(i, 1);
|
|
draw();
|
|
},
|
|
}, ['✕']);
|
|
listEl.appendChild(el('div', { class: 'list-string-row' }, [input, removeBtn]));
|
|
});
|
|
}
|
|
|
|
draw();
|
|
|
|
const addBtn = el('button', {
|
|
type: 'button',
|
|
class: 'add-btn',
|
|
onclick: () => {
|
|
getObj()[field.key].push('');
|
|
draw();
|
|
},
|
|
}, ['+ hinzufügen']);
|
|
|
|
wrap.appendChild(listEl);
|
|
wrap.appendChild(addBtn);
|
|
return wrap;
|
|
}
|
|
|
|
function emptyItemFor(itemFields) {
|
|
const obj = {};
|
|
itemFields.forEach((f) => {
|
|
if (f.type === 'boolean') obj[f.key] = false;
|
|
else if (f.type === 'list-string' || f.type === 'list-richtext' || f.type === 'list-object') obj[f.key] = [];
|
|
else if (f.type === 'color') obj[f.key] = { r: 0, g: 0, b: 0, a: 1 };
|
|
else if (f.type === 'group') obj[f.key] = emptyItemFor(f.itemFields);
|
|
else obj[f.key] = '';
|
|
});
|
|
return obj;
|
|
}
|
|
|
|
function renderObjectList(field, getObj) {
|
|
const wrap = el('div', { class: 'field' }, [el('label', {}, [field.label])]);
|
|
const listEl = el('div', {}, []);
|
|
|
|
function draw() {
|
|
listEl.innerHTML = '';
|
|
const arr = getObj()[field.key];
|
|
arr.forEach((item, i) => {
|
|
const header = el('div', { class: 'list-item-card-header' }, [
|
|
el('span', {}, [`${field.itemLabel || 'Eintrag'} ${i + 1}`]),
|
|
el('button', {
|
|
type: 'button',
|
|
class: 'remove-btn',
|
|
onclick: () => {
|
|
arr.splice(i, 1);
|
|
draw();
|
|
},
|
|
}, ['Entfernen']),
|
|
]);
|
|
const body = renderFieldGroup(field.itemFields, () => arr[i]);
|
|
listEl.appendChild(el('div', { class: 'list-item-card' }, [header, body]));
|
|
});
|
|
}
|
|
|
|
draw();
|
|
|
|
const addBtn = el('button', {
|
|
type: 'button',
|
|
class: 'add-btn',
|
|
onclick: () => {
|
|
getObj()[field.key].push(emptyItemFor(field.itemFields));
|
|
draw();
|
|
},
|
|
}, [`+ ${field.itemLabel || 'Eintrag'} hinzufügen`]);
|
|
|
|
wrap.appendChild(listEl);
|
|
wrap.appendChild(addBtn);
|
|
return wrap;
|
|
}
|
|
|
|
function renderSection(section) {
|
|
const id = 'section-' + section.key;
|
|
const fileKind = section.file || 'content';
|
|
const block = el('section', { class: 'section-block', id, hidden: true }, [el('h2', {}, [section.label])]);
|
|
|
|
if (section.type === 'array-root') {
|
|
const listField = { key: '__root__', itemLabel: section.itemLabel, itemFields: section.itemFields, label: '' };
|
|
const fakeGetObj = () => ({ __root__: state[fileKind][section.key] });
|
|
const wrap = renderObjectList(listField, fakeGetObj);
|
|
wrap.querySelector('label').remove();
|
|
block.appendChild(wrap);
|
|
} else {
|
|
block.appendChild(renderFieldGroup(section.fields, () => state[fileKind][section.key]));
|
|
}
|
|
|
|
return block;
|
|
}
|
|
|
|
function showSection(key) {
|
|
activeSectionKey = key;
|
|
sectionsEl.querySelectorAll('.section-block').forEach((elm) => {
|
|
elm.hidden = elm.id !== 'section-' + key;
|
|
});
|
|
navEl.querySelectorAll('button').forEach((btn) => {
|
|
btn.classList.toggle('active', btn.dataset.key === key);
|
|
});
|
|
try {
|
|
localStorage.setItem('larpaso-admin-active-section', key);
|
|
} catch (err) {
|
|
/* ignore - not essential */
|
|
}
|
|
}
|
|
|
|
function renderAll() {
|
|
sectionsEl.innerHTML = '';
|
|
navEl.innerHTML = '';
|
|
|
|
let lastGroup = null;
|
|
schema.forEach((section) => {
|
|
const group = section.file === 'theme' ? 'Design' : 'Inhalte';
|
|
if (group !== lastGroup) {
|
|
navEl.appendChild(el('div', { class: 'nav-group-label' }, [group]));
|
|
lastGroup = group;
|
|
}
|
|
navEl.appendChild(
|
|
el('button', { type: 'button', 'data-key': section.key, onclick: () => showSection(section.key) }, [section.label])
|
|
);
|
|
sectionsEl.appendChild(renderSection(section));
|
|
});
|
|
|
|
let initial = schema[0] && schema[0].key;
|
|
try {
|
|
const saved = localStorage.getItem('larpaso-admin-active-section');
|
|
if (saved && schema.some((s) => s.key === saved)) initial = saved;
|
|
} catch (err) {
|
|
/* ignore - not essential */
|
|
}
|
|
if (initial) showSection(initial);
|
|
}
|
|
|
|
async function load() {
|
|
setStatus('Lädt …');
|
|
const [fetchedSchema, content, theme, fetchedFonts] = await Promise.all([
|
|
api('/admin/api/schema'),
|
|
api('/admin/api/content'),
|
|
api('/admin/api/theme'),
|
|
api('/admin/api/fonts'),
|
|
]);
|
|
schema = fetchedSchema;
|
|
fonts = fetchedFonts;
|
|
state = { content, theme };
|
|
injectFontPreviewStyles();
|
|
renderAll();
|
|
setStatus('Bereit.');
|
|
}
|
|
|
|
async function save() {
|
|
setStatus('Speichert …');
|
|
document.getElementById('save-btn').disabled = true;
|
|
document.getElementById('publish-btn').disabled = true;
|
|
try {
|
|
await Promise.all([
|
|
api('/admin/api/content', { method: 'PUT', body: JSON.stringify(state.content) }),
|
|
api('/admin/api/theme', { method: 'PUT', body: JSON.stringify(state.theme) }),
|
|
]);
|
|
setStatus('Gespeichert.', 'ok');
|
|
return true;
|
|
} catch (err) {
|
|
setStatus(err.message, 'error');
|
|
return false;
|
|
} finally {
|
|
document.getElementById('save-btn').disabled = false;
|
|
document.getElementById('publish-btn').disabled = false;
|
|
}
|
|
}
|
|
|
|
async function publish() {
|
|
const saved = await save();
|
|
if (!saved) return;
|
|
setStatus('Veröffentlicht wird gebaut …');
|
|
const logEl = document.getElementById('log');
|
|
const logOutput = document.getElementById('log-output');
|
|
try {
|
|
const result = await api('/admin/api/publish', { method: 'POST' });
|
|
logOutput.textContent = result.output || '';
|
|
logEl.hidden = false;
|
|
setStatus('Veröffentlicht.', 'ok');
|
|
} catch (err) {
|
|
setStatus(err.message, 'error');
|
|
logOutput.textContent = err.message;
|
|
logEl.hidden = false;
|
|
}
|
|
}
|
|
|
|
document.getElementById('save-btn').addEventListener('click', save);
|
|
document.getElementById('publish-btn').addEventListener('click', publish);
|
|
document.getElementById('log-close').addEventListener('click', () => {
|
|
document.getElementById('log').hidden = true;
|
|
});
|
|
|
|
load().catch((err) => setStatus(err.message, 'error'));
|
|
})();
|