(function () { 'use strict'; let schema = []; let state = { content: null, theme: 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; } // --- Field renderers --- function textField(field, getValue, setValue) { const isArea = field.type === 'textarea' || field.type === 'richtext'; 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, field.type === 'richtext' ? el('p', { class: 'field-hint' }, ['**fett**, *kursiv* möglich']) : null, ]); } 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]), ]), ]); } 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 === 'group') { container.appendChild( el('fieldset', { class: 'group-field' }, [ el('legend', {}, [field.label]), renderFieldGroup(field.itemFields, () => getObj()[field.key]), ]) ); } else if (field.type === 'list-string' || field.type === 'list-richtext') { container.appendChild(renderStringList(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 = field.type === 'list-richtext' ? el('textarea', { rows: 2 }) : 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 }, [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 renderAll() { sectionsEl.innerHTML = ''; navEl.innerHTML = ''; schema.forEach((section) => { sectionsEl.appendChild(renderSection(section)); navEl.appendChild(el('a', { href: '#section-' + section.key }, [section.label])); }); } async function load() { setStatus('Lädt …'); const [fetchedSchema, content, theme] = await Promise.all([ api('/admin/api/schema'), api('/admin/api/content'), api('/admin/api/theme'), ]); schema = fetchedSchema; state = { content, theme }; 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')); })();