Files
hd-commerce/src/pages/admin/inhalte/editor/[id].astro
T

320 lines
22 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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: (Array.isArray(page.blocks) && page.blocks.length) ? page.blocks : (page.body && String(page.body).trim() ? [{ type: 'richtext', html: page.body }] : []), 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>