(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); if (fonts.some((f) => f.name !== 'System UI (Systemschrift)' && f.name !== 'Georgia (Systemschrift)')) { const families = fonts .filter((f) => !f.name.includes('Systemschrift')) .map((f) => `family=${encodeURIComponent(f.name)}:wght@400;500;600;700`) .join('&'); const link = el('link', { rel: 'stylesheet', href: `https://fonts.googleapis.com/css2?${families}&display=swap` }); document.head.appendChild(link); } } // --- 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')); })();