v2: Session-Login & Rollen, Premium-Admin, Visual-Block-Builder, KI-/MCP-API

- Auth-Umbau: Session-Login (signiertes HMAC-Cookie, scrypt-Hashing) statt Basic-Auth;
  users-/audit-Tabellen, Initial-Owner aus ENV, Rate-Limit, konfigurierbarer ADMIN_PATH
  (Middleware-Rewrite), Rollen-Gate (owner/redaktion/versand), Nutzerverwaltung, Audit-Log,
  Login/Logout/Konto-Seiten.
- Premium-Pass: Command-Palette (Cmd-K), Toasts, Account-Menue, aufgewertetes Dashboard
  (KPI-Trend+Sparkline, Aktivitaets-Feed, Schnellaktionen), schoene Empty-States.
- Block-Builder: pages.blocks, Vollbild-Editor (Liste/Live-Vorschau/Settings, Desktop/Mobil),
  10 Block-Typen, Storefront-BlockRenderer auf /seite/[slug], Save-Endpoint.
- KI-Editierbarkeit: token-gesicherte /api/admin/* (CRUD), Manifest /api/admin + /ai-admin.txt,
  MCP-Server unter mcp/ (14 Tools).
- Docs: README + .env.example + mcp/README aktualisiert.
This commit is contained in:
2026-06-17 12:46:31 +00:00
parent 3c48b69880
commit aec179db36
41 changed files with 9525 additions and 143 deletions
+319
View File
@@ -0,0 +1,319 @@
---
import '@fontsource-variable/public-sans';
import '@fontsource-variable/fraunces';
import '../../../../styles/admin.css';
import { getPageById, listFeatured, listProducts, listActiveSlides, getSettings, formatPrice } from '../../../../lib/store.js';
import { BLOCK_TYPES } from '../../../../lib/blocks.js';
import { adminBase } from '../../../../lib/auth.js';
const base = adminBase();
const { id } = Astro.params;
const page = getPageById(id);
if (!page) return Astro.redirect(base + '/inhalte?tab=pages');
const settings = getSettings();
const accent = settings.brand_accent || '#b8566a';
const accentDark = settings.brand_accent_dark || '#8d3f50';
// Daten für die Client-Vorschau
const products = listProducts().map(p => ({ slug: p.slug, name: p.name, shortName: p.shortName, category: p.category, cardImage: p.cardImage, badge: p.badge, price: formatPrice(p.priceCents) }));
const featuredSlugs = listFeatured().map(p => p.slug);
const slides = listActiveSlides().map(s => ({ image: s.image, headline: s.headline, subline: s.subline, link: s.link }));
const categories = [...new Set(products.map(p => p.category).filter(Boolean))];
const data = {
pageId: page.id, slug: page.slug, title: page.title,
blocks: page.blocks || [], blockTypes: BLOCK_TYPES,
products, featuredSlugs, slides, categories,
saveUrl: '/api/admin-page-blocks', base,
shopOrigin: '',
};
const dataJson = JSON.stringify(data);
---
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>Editor · {page.title}</title>
<style is:inline set:html={`:root{--accent:${accent};--accent-dark:${accentDark};}`}></style>
<style is:inline>
html,body{height:100%;margin:0}
.ed-shell{display:grid;grid-template-rows:auto 1fr;height:100vh;background:var(--s-bg);font-family:var(--s-font)}
.ed-top{display:flex;align-items:center;gap:14px;padding:11px 18px;background:color-mix(in srgb,var(--s-bg) 86%, transparent);backdrop-filter:blur(8px);border-bottom:1px solid var(--s-border);position:sticky;top:0;z-index:20}
.ed-top .ed-title{font-family:var(--s-display);font-size:17px;font-weight:560;color:var(--s-ink);letter-spacing:-.01em}
.ed-top .ed-slug{font-size:12px;color:var(--s-faint)}
.ed-top .spacer{flex:1}
.ed-cols{display:grid;grid-template-columns:262px 1fr 304px;min-height:0;overflow:hidden}
.ed-pane{overflow:auto;height:100%}
.ed-left{border-right:1px solid var(--s-border);background:var(--s-bg);padding:14px}
.ed-right{border-left:1px solid var(--s-border);background:var(--s-surface);padding:18px}
.ed-center{background:var(--s-sunken);display:flex;flex-direction:column;align-items:center;padding:20px;gap:14px}
.ed-sec{font-size:10px;text-transform:uppercase;letter-spacing:.09em;color:var(--s-faint);font-weight:700;margin:6px 4px 8px}
.ed-add{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:16px}
.ed-add button{display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px 6px;border:1px solid var(--s-border);border-radius:10px;background:var(--s-surface);cursor:pointer;font-size:11px;font-weight:600;color:var(--s-text);transition:.13s;font-family:inherit}
.ed-add button:hover{border-color:var(--accent);color:var(--accent-dark);transform:translateY(-1px);box-shadow:var(--s-shadow)}
.ed-add svg{width:18px;height:18px;color:var(--s-subtle)}
.ed-add button:hover svg{color:var(--accent)}
.ed-list{display:flex;flex-direction:column;gap:7px}
.ed-item{display:flex;align-items:center;gap:9px;padding:9px 11px;border:1px solid var(--s-border);border-radius:10px;background:var(--s-surface);cursor:pointer;transition:.12s}
.ed-item:hover{border-color:var(--s-border-2)}
.ed-item.active{border-color:var(--accent);box-shadow:0 0 0 2px var(--s-acc-ring)}
.ed-item .grip{color:var(--s-faint);cursor:grab;font-size:14px;line-height:1}
.ed-item .lbl{flex:1;font-size:13px;font-weight:600;color:var(--s-ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ed-item .acts{display:flex;gap:3px}
.ed-item .acts button{width:24px;height:24px;border:none;background:transparent;color:var(--s-subtle);cursor:pointer;border-radius:6px;font-size:13px;display:grid;place-items:center}
.ed-item .acts button:hover{background:var(--s-bg);color:var(--s-ink)}
.ed-item.dragging{opacity:.4}
.ed-empty{padding:30px 14px;text-align:center;color:var(--s-faint);font-size:12.5px}
.ed-frame-wrap{width:100%;flex:1;display:flex;justify-content:center}
.ed-frame{width:100%;max-width:1180px;height:100%;border:1px solid var(--s-border);border-radius:12px;background:#fff;box-shadow:var(--s-shadow);transition:max-width .25s var(--s-ease)}
.ed-frame.mobile{max-width:400px}
.ed-device{display:inline-flex;background:var(--s-sunken);border:1px solid var(--s-border);border-radius:9px;padding:3px;gap:3px}
.ed-device button{border:none;background:transparent;padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;color:var(--s-subtle);cursor:pointer;font-family:inherit}
.ed-device button.active{background:var(--s-surface);color:var(--s-ink);box-shadow:var(--s-shadow)}
.ed-field{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
.ed-field label{font-size:12.5px;font-weight:600;color:var(--s-text)}
.ed-right .s-input,.ed-right .s-textarea,.ed-right .s-select{font-size:13px}
.ed-imgrow{display:flex;gap:7px}
.ed-imgrow .s-input{flex:1}
.ed-nosel{padding:36px 10px;text-align:center;color:var(--s-faint);font-size:13px}
.ed-mediabtn{font-size:11px}
</style>
</head>
<body class="admin-body">
<div class="ed-shell">
<header class="ed-top">
<a class="s-btn s-btn-sm" href={base + '/inhalte?tab=pages'}> Schließen</a>
<div><div class="ed-title">{page.title}</div><div class="ed-slug">/seite/{page.slug}</div></div>
<div class="spacer"></div>
<div class="ed-device" id="edDevice">
<button data-dev="desktop" class="active">Desktop</button>
<button data-dev="mobile">Mobil</button>
</div>
<a class="s-btn s-btn-sm" href={'/seite/' + page.slug} target="_blank">Vorschau im Shop ↗</a>
<button class="s-btn s-btn-primary s-btn-sm" id="edSave">Speichern</button>
</header>
<div class="ed-cols">
<aside class="ed-pane ed-left">
<div class="ed-sec">Block hinzufügen</div>
<div class="ed-add" id="edAdd"></div>
<div class="ed-sec">Blöcke</div>
<div class="ed-list" id="edList"></div>
</aside>
<main class="ed-pane ed-center">
<div class="ed-frame-wrap"><iframe class="ed-frame" id="edFrame" title="Vorschau"></iframe></div>
</main>
<aside class="ed-pane ed-right" id="edSettings">
<div class="ed-nosel">Wähle links einen Block, um ihn zu bearbeiten.</div>
</aside>
</div>
</div>
<div class="s-toasts" id="toasts" aria-live="polite"></div>
<script is:inline define:vars={{ dataJson }}>
(function () {
var D = JSON.parse(dataJson);
var blocks = Array.isArray(D.blocks) ? D.blocks : [];
var selected = blocks.length ? 0 : -1;
var device = 'desktop';
var uid = 1;
blocks.forEach(function (b) { if (!b._id) b._id = 'b' + (uid++); });
function toast(msg, kind) {
var c = document.getElementById('toasts'); if (!c) return;
var t = document.createElement('div'); t.className = 's-toast ' + (kind || 'ok'); t.textContent = msg;
c.appendChild(t); requestAnimationFrame(function () { t.classList.add('show'); });
setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 250); }, 2600);
}
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
function meta(type) { for (var i = 0; i < D.blockTypes.length; i++) if (D.blockTypes[i].key === type) return D.blockTypes[i]; return null; }
function defaults(type) { var m = meta(type); return m ? JSON.parse(JSON.stringify(m.defaults)) : {}; }
// ---- Block -> HTML (Vorschau, spiegelt BlockRenderer) ----
function productsFor(b) {
var limit = Math.max(1, Math.min(12, Number(b.limit) || 4));
var items;
if (b.source === 'all') items = D.products.slice();
else if (b.source === 'category' && b.category) items = D.products.filter(function (p) { return p.category === b.category; });
else items = D.products.filter(function (p) { return D.featuredSlugs.indexOf(p.slug) > -1; });
if (!items.length) items = D.products.filter(function (p) { return D.featuredSlugs.indexOf(p.slug) > -1; });
if (!items.length) items = D.products.slice();
return items.slice(0, limit);
}
function prodCard(p) {
return '<a class="prod-card" href="#" onclick="return false">' +
'<div class="prod-media">' + (p.cardImage ? '<img src="' + esc(p.cardImage) + '" alt="">' : '') + (p.badge ? '<span class="prod-badge">' + esc(p.badge) + '</span>' : '') + '</div>' +
'<div class="prod-info"><span class="prod-cat">' + esc(p.category) + '</span><span class="prod-name">' + esc(p.shortName || p.name) + '</span><span class="prod-price">' + esc(p.price) + '</span></div></a>';
}
function blockHtml(b) {
var spacer = { small: 28, medium: 56, large: 96 };
switch (b.type) {
case 'hero':
return '<section class="blk blk-hero ' + (b.image ? 'has-img' : '') + ' align-' + (b.align || 'center') + '"' + (b.image ? ' style="--hero-img:url(\'' + esc(b.image) + '\')"' : '') + '><div class="wrap blk-hero-inner">' +
(b.headline ? '<h1>' + esc(b.headline) + '</h1>' : '') + (b.subline ? '<p class="blk-hero-sub">' + esc(b.subline) + '</p>' : '') +
(b.cta_text ? '<a class="btn btn-primary btn-lg" href="#" onclick="return false">' + esc(b.cta_text) + '</a>' : '') + '</div></section>';
case 'richtext':
return '<section class="blk blk-rich"><div class="wrap prose">' + (b.html || '') + '</div></section>';
case 'image':
return '<section class="blk blk-image"><div class="wrap img-' + (b.width || 'wide') + '">' + (b.image ? '<img src="' + esc(b.image) + '" alt="">' : '<div style="aspect-ratio:16/7;background:#eee;border-radius:14px"></div>') + (b.caption ? '<p class="blk-cap">' + esc(b.caption) + '</p>' : '') + '</div></section>';
case 'gallery':
var cols = Math.max(2, Math.min(4, Number(b.columns) || 3));
var imgs = (b.images || []).map(function (s) { return '<img src="' + esc(s) + '" alt="">'; }).join('');
return '<section class="blk blk-gallery"><div class="wrap"><div class="blk-gal-grid" style="grid-template-columns:repeat(' + cols + ',1fr)">' + (imgs || '<div style="grid-column:1/-1;padding:30px;text-align:center;color:#999">Noch keine Bilder</div>') + '</div></div></section>';
case 'slider':
if (!D.slides.length) return '<section class="blk"><div class="wrap" style="padding:30px;text-align:center;color:#999">Slider (keine aktiven Slides)</div></section>';
var s0 = D.slides[0];
return '<section class="blk blk-sliderref"><div class="wrap"><div class="slider"><div class="slides"><div class="slide">' + (s0.image ? '<img src="' + esc(s0.image) + '" alt="">' : '') + '<div class="slide-cap"><h2>' + esc(s0.headline) + '</h2>' + (s0.subline ? '<p>' + esc(s0.subline) + '</p>' : '') + '</div></div></div></div></div></section>';
case 'features':
var fi = (b.items || []).map(function (it) { return '<div class="blk-feat"><h3>' + esc(it.title) + '</h3><p>' + esc(it.text) + '</p></div>'; }).join('');
return '<section class="blk blk-features"><div class="wrap">' + (b.headline ? '<h2 class="blk-h2">' + esc(b.headline) + '</h2>' : '') + '<div class="blk-feat-grid">' + fi + '</div></div></section>';
case 'productgrid':
var pc = productsFor(b).map(prodCard).join('');
return '<section class="blk blk-products"><div class="wrap">' + (b.headline ? '<h2 class="blk-h2">' + esc(b.headline) + '</h2>' : '') + '<div class="prod-grid">' + (pc || '<div style="padding:30px;color:#999">Keine Produkte</div>') + '</div></div></section>';
case 'cta':
return '<section class="blk blk-cta"><div class="wrap"><div class="blk-cta-box">' + (b.headline ? '<h2>' + esc(b.headline) + '</h2>' : '') + (b.text ? '<p>' + esc(b.text) + '</p>' : '') + (b.cta_text ? '<a class="btn btn-primary btn-lg" href="#" onclick="return false">' + esc(b.cta_text) + '</a>' : '') + '</div></div></section>';
case 'spacer':
return '<div class="blk-spacer" style="height:' + (spacer[b.size] || 56) + 'px"></div>';
case 'html':
return '<section class="blk blk-html">' + (b.code || '') + '</section>';
}
return '';
}
var frame = document.getElementById('edFrame');
function renderPreview() {
var body = blocks.map(blockHtml).join('\n') || '<div style="padding:80px 24px;text-align:center;color:#aaa;font-family:sans-serif">Diese Seite ist noch leer.<br>Füge links einen Block hinzu.</div>';
var doc = '<!doctype html><html lang="de"><head><meta charset="utf-8"><link rel="stylesheet" href="/styles/global.css">' +
'<style>:root{--accent:' + getComputedStyle(document.documentElement).getPropertyValue('--accent') + ';--accent-dark:' + getComputedStyle(document.documentElement).getPropertyValue('--accent-dark') + ';}body{margin:0}</style>' +
'</head><body>' + body + '</body></html>';
// CSS via @fontsource ist gebundlet; wir referenzieren global.css statisch — fällt aus, aber Klassen reichen für Layout.
var d = frame.contentDocument || frame.contentWindow.document;
d.open(); d.write(doc); d.close();
}
// ---- Linke Liste ----
var listEl = document.getElementById('edList');
function renderList() {
listEl.innerHTML = '';
if (!blocks.length) { listEl.innerHTML = '<div class="ed-empty">Noch keine Blöcke.<br>Oben einen Typ wählen.</div>'; return; }
blocks.forEach(function (b, i) {
var m = meta(b.type);
var row = document.createElement('div');
row.className = 'ed-item' + (i === selected ? ' active' : '');
row.draggable = true; row.dataset.idx = i;
row.innerHTML = '<span class="grip">⋮⋮</span><span class="lbl">' + esc(m ? m.label : b.type) + '</span>' +
'<span class="acts"><button title="Hoch" data-act="up">▲</button><button title="Runter" data-act="down">▼</button><button title="Duplizieren" data-act="dup">⧉</button><button title="Löschen" data-act="del">✕</button></span>';
row.addEventListener('click', function (e) {
var act = e.target.getAttribute('data-act');
if (act) { e.stopPropagation(); doAct(act, i); return; }
selected = i; renderList(); renderSettings();
});
// Drag
row.addEventListener('dragstart', function (e) { row.classList.add('dragging'); e.dataTransfer.setData('text/plain', i); });
row.addEventListener('dragend', function () { row.classList.remove('dragging'); });
row.addEventListener('dragover', function (e) { e.preventDefault(); });
row.addEventListener('drop', function (e) {
e.preventDefault(); var from = Number(e.dataTransfer.getData('text/plain')); var to = i;
if (from === to) return; var moved = blocks.splice(from, 1)[0]; blocks.splice(to, 0, moved);
selected = to; renderAll();
});
listEl.appendChild(row);
});
}
function doAct(act, i) {
if (act === 'up' && i > 0) { var t = blocks[i - 1]; blocks[i - 1] = blocks[i]; blocks[i] = t; selected = i - 1; }
else if (act === 'down' && i < blocks.length - 1) { var t2 = blocks[i + 1]; blocks[i + 1] = blocks[i]; blocks[i] = t2; selected = i + 1; }
else if (act === 'dup') { var copy = JSON.parse(JSON.stringify(blocks[i])); copy._id = 'b' + (uid++); blocks.splice(i + 1, 0, copy); selected = i + 1; }
else if (act === 'del') { blocks.splice(i, 1); selected = Math.min(selected, blocks.length - 1); }
renderAll();
}
// ---- Add-Buttons ----
var addEl = document.getElementById('edAdd');
D.blockTypes.forEach(function (m) {
var b = document.createElement('button');
b.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="' + m.icon + '"/></svg><span>' + esc(m.label) + '</span>';
b.addEventListener('click', function () {
var nb = defaults(m.key); nb.type = m.key; nb._id = 'b' + (uid++);
var at = selected > -1 ? selected + 1 : blocks.length;
blocks.splice(at, 0, nb); selected = at; renderAll();
});
addEl.appendChild(b);
});
// ---- Settings-Panel ----
var setEl = document.getElementById('edSettings');
function renderSettings() {
if (selected < 0 || !blocks[selected]) { setEl.innerHTML = '<div class="ed-nosel">Wähle links einen Block, um ihn zu bearbeiten.</div>'; return; }
var b = blocks[selected], m = meta(b.type);
var h = '<div class="ed-sec" style="margin-bottom:14px">' + esc(m ? m.label : b.type) + ' bearbeiten</div>';
if (!m || !m.fields.length) h += '<p class="s-help">Dieser Block hat keine Einstellungen.</p>';
m && m.fields.forEach(function (f) {
h += '<div class="ed-field"><label>' + esc(f.label) + '</label>';
var val = b[f.name];
if (f.type === 'textarea') h += '<textarea class="s-textarea" data-f="' + f.name + '" style="min-height:90px">' + esc(val) + '</textarea>';
else if (f.type === 'select') h += '<select class="s-select" data-f="' + f.name + '">' + f.options.map(function (o) { return '<option value="' + esc(o) + '"' + (String(val) === String(o) ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('') + '</select>';
else if (f.type === 'number') h += '<input class="s-input" type="number" data-f="' + f.name + '" value="' + esc(val) + '">';
else if (f.type === 'image') h += '<div class="ed-imgrow"><input class="s-input" data-f="' + f.name + '" value="' + esc(val) + '" placeholder="Bild-URL"><button class="s-btn s-btn-sm ed-mediabtn" data-pick="' + f.name + '" type="button">📷</button></div>';
else if (f.type === 'imagelist') h += '<textarea class="s-textarea" data-fl="' + f.name + '" placeholder="Eine Bild-URL pro Zeile" style="min-height:90px">' + esc((val || []).join('\n')) + '</textarea>';
else if (f.type === 'features') {
(val || []).forEach(function (it, fi) {
h += '<input class="s-input" data-feat="' + fi + '" data-featk="title" value="' + esc(it.title) + '" placeholder="Titel" style="margin-bottom:5px"><input class="s-input" data-feat="' + fi + '" data-featk="text" value="' + esc(it.text) + '" placeholder="Text" style="margin-bottom:10px">';
});
}
else h += '<input class="s-input" data-f="' + f.name + '" value="' + esc(val) + '">';
h += '</div>';
});
setEl.innerHTML = h;
// Bindings
setEl.querySelectorAll('[data-f]').forEach(function (el) {
el.addEventListener('input', function () { blocks[selected][el.getAttribute('data-f')] = el.value; renderPreview(); });
});
setEl.querySelectorAll('[data-fl]').forEach(function (el) {
el.addEventListener('input', function () { blocks[selected][el.getAttribute('data-fl')] = el.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean); renderPreview(); });
});
setEl.querySelectorAll('[data-feat]').forEach(function (el) {
el.addEventListener('input', function () {
var fi = Number(el.getAttribute('data-feat')), k = el.getAttribute('data-featk');
if (!blocks[selected].items) blocks[selected].items = [];
if (!blocks[selected].items[fi]) blocks[selected].items[fi] = { title: '', text: '' };
blocks[selected].items[fi][k] = el.value; renderPreview();
});
});
setEl.querySelectorAll('[data-pick]').forEach(function (el) {
el.addEventListener('click', function () {
var url = prompt('Bild-URL eingeben (oder aus der Medien-Bibliothek kopieren):', blocks[selected][el.getAttribute('data-pick')] || '');
if (url != null) { blocks[selected][el.getAttribute('data-pick')] = url; renderSettings(); renderPreview(); }
});
});
}
function renderAll() { renderList(); renderSettings(); renderPreview(); }
// Device toggle
document.getElementById('edDevice').addEventListener('click', function (e) {
var dev = e.target.getAttribute('data-dev'); if (!dev) return;
device = dev;
Array.prototype.forEach.call(this.children, function (c) { c.classList.toggle('active', c.getAttribute('data-dev') === dev); });
frame.classList.toggle('mobile', dev === 'mobile');
});
// Save
document.getElementById('edSave').addEventListener('click', function () {
var btn = this; btn.disabled = true; var old = btn.textContent; btn.textContent = 'Speichert …';
var clean = blocks.map(function (b) { var c = JSON.parse(JSON.stringify(b)); delete c._id; return c; });
fetch(D.saveUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: D.pageId, blocks: clean }) })
.then(function (r) { return r.json(); })
.then(function (d) { if (d.ok) toast('Gespeichert (' + d.count + ' Blöcke).', 'ok'); else toast('Fehler: ' + (d.error || '?'), 'err'); })
.catch(function () { toast('Speichern fehlgeschlagen.', 'err'); })
.then(function () { btn.disabled = false; btn.textContent = old; });
});
renderAll();
})();
</script>
</body>
</html>