larpaso-website/admin/public/app.js
Lara f000d64cca Design round: logo colours, own fonts, blob shapes, broken grid
Prototype for review, not deployed. Newsreader + Schibsted Grotesk
(served locally, italic included), palette from the logo (petrol, sage,
sand, rose) instead of gold, the logo blob as a recurring shape, petrol
bands with a wave edge, margin-note section labels and uneven columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:09:28 +02:00

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-style: ${face.style || 'normal'}; 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'));
})();