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
+106
View File
@@ -0,0 +1,106 @@
---
import { listFeatured, listProducts, listActiveSlides, formatPrice } from '../lib/store.js';
export interface Props { blocks?: any[] }
const { blocks = [] } = Astro.props;
const list = Array.isArray(blocks) ? blocks : [];
const slides = listActiveSlides();
function productsFor(b) {
const limit = Math.max(1, Math.min(12, Number(b.limit) || 4));
let items = [];
if (b.source === 'all') items = listProducts();
else if (b.source === 'category' && b.category) items = listProducts().filter(p => p.category === b.category);
else items = listFeatured();
if (!items.length) items = listFeatured();
return items.slice(0, limit);
}
const spacerPx = { small: 28, medium: 56, large: 96 };
const galCols = (b) => Math.max(2, Math.min(4, Number(b.columns) || 3));
---
{list.map((b) => (
<>
{b.type === 'hero' && (
<section class={`blk blk-hero ${b.image ? 'has-img' : ''} align-${b.align || 'center'}`} style={b.image ? `--hero-img:url('${b.image}')` : ''}>
<div class="wrap blk-hero-inner">
{b.headline && <h1>{b.headline}</h1>}
{b.subline && <p class="blk-hero-sub">{b.subline}</p>}
{b.cta_text && <a class="btn btn-primary btn-lg" href={b.cta_url || '#'}>{b.cta_text}</a>}
</div>
</section>
)}
{b.type === 'richtext' && (
<section class="blk blk-rich"><div class="wrap prose" set:html={b.html || ''}></div></section>
)}
{b.type === 'image' && (
<section class="blk blk-image">
<div class={`wrap img-${b.width || 'wide'}`}>
{b.image && <img src={b.image} alt={b.caption || ''} loading="lazy" />}
{b.caption && <p class="blk-cap">{b.caption}</p>}
</div>
</section>
)}
{b.type === 'gallery' && (
<section class="blk blk-gallery"><div class="wrap">
<div class="blk-gal-grid" style={`grid-template-columns:repeat(${galCols(b)},1fr)`}>
{(b.images || []).map((src) => (<img src={src} alt="" loading="lazy" />))}
</div>
</div></section>
)}
{b.type === 'slider' && slides.length > 0 && (
<section class="blk blk-sliderref"><div class="wrap">
<div class="slider" id="hdcSlider">
<div class="slides" id="hdcSlides">
{slides.map((s) => (
<div class="slide">
{s.image && <img src={s.image} alt={s.headline} loading="lazy" />}
<div class="slide-cap"><h2>{s.headline}</h2>{s.subline && <p>{s.subline}</p>}{s.link && <a class="btn btn-primary btn-lg" href={s.link}>Jetzt entdecken</a>}</div>
</div>
))}
</div>
</div>
</div></section>
)}
{b.type === 'features' && (
<section class="blk blk-features"><div class="wrap">
{b.headline && <h2 class="blk-h2">{b.headline}</h2>}
<div class="blk-feat-grid">
{(b.items || []).map((it) => (
<div class="blk-feat"><h3>{it.title}</h3><p>{it.text}</p></div>
))}
</div>
</div></section>
)}
{b.type === 'productgrid' && (
<section class="blk blk-products"><div class="wrap">
{b.headline && <h2 class="blk-h2">{b.headline}</h2>}
<div class="prod-grid">
{productsFor(b).map((p) => (
<a class="prod-card" href={`/produkt/${p.slug}`}>
<div class="prod-media">{p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />}{p.badge && <span class="prod-badge">{p.badge}</span>}</div>
<div class="prod-info"><span class="prod-cat">{p.category}</span><span class="prod-name">{p.shortName || p.name}</span><span class="prod-price">{formatPrice(p.priceCents)}</span></div>
</a>
))}
</div>
</div></section>
)}
{b.type === 'cta' && (
<section class="blk blk-cta"><div class="wrap"><div class="blk-cta-box">
{b.headline && <h2>{b.headline}</h2>}
{b.text && <p>{b.text}</p>}
{b.cta_text && <a class="btn btn-primary btn-lg" href={b.cta_url || '#'}>{b.cta_text}</a>}
</div></div></section>
)}
{b.type === 'spacer' && (<div class="blk-spacer" style={`height:${spacerPx[b.size] || 56}px`}></div>)}
{b.type === 'html' && (<section class="blk blk-html"><div set:html={b.code || ''}></div></section>)}
</>
))}
+134 -11
View File
@@ -3,6 +3,7 @@ import '@fontsource-variable/public-sans';
import '@fontsource-variable/fraunces';
import '../styles/admin.css';
import { getSettings } from '../lib/store.js';
import { currentUser, adminBase, allowedSections } from '../lib/auth.js';
export interface Props { title: string; active?: string; crumbs?: { label: string; href?: string }[]; }
const { title, active = '', crumbs = [] } = Astro.props;
@@ -13,16 +14,40 @@ const accent = settings.brand_accent || '#b8566a';
const accentDark = settings.brand_accent_dark || '#8d3f50';
const initial = (shopName.trim()[0] || 'H').toUpperCase();
const nav = [
{ key:'dashboard', label:'Dashboard', href:'/admin', icon:'M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z' },
{ key:'bestellungen', label:'Bestellungen', href:'/admin/bestellungen', icon:'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4H6Zm.5 2h11l1.5 2H5l1.5-2ZM5 8h14v12H5V8Zm4 2a3 3 0 0 0 6 0' },
{ key:'produkte', label:'Produkte', href:'/admin/produkte', icon:'M20.5 7.3 12 2 3.5 7.3 12 12.6l8.5-5.3ZM3 9v8l8 5v-8L3 9Zm10 13 8-5V9l-8 5v8Z' },
{ key:'kunden', label:'Kunden', href:'/admin/kunden', icon:'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-8 2-8 5v1h16v-1c0-3-4-5-8-5Z' },
{ key:'analytics', label:'Analytics', href:'/admin/analytics', icon:'M4 20V10m6 10V4m6 16v-7m4 7H2' },
{ key:'marketing', label:'Marketing', href:'/admin/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' },
{ key:'inhalte', label:'Inhalte', href:'/admin/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' },
{ key:'einstellungen', label:'Einstellungen', href:'/admin/einstellungen', icon:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 4-2 .5.4 2-1.7 1.3-1.7-1.2-1.8.8-.3 2H10l-.3-2-1.8-.8-1.7 1.2L4.5 14.5 5 12.5 3 12l.5-2 2-.5-.4-2L6.8 6.2l1.7 1.2 1.8-.8.3-2h2.8l.3 2 1.8.8 1.7-1.2 1.7 1.3-.4 2 2 .5-.5 2Z' },
const base = adminBase();
const user = currentUser(Astro.request);
const role = user?.role || 'owner';
const sections = allowedSections(role);
const roleLabel = { owner: 'Inhaber', redaktion: 'Redaktion', versand: 'Versand' }[role] || role;
const userInitial = (user?.name?.trim()?.[0] || user?.email?.trim()?.[0] || 'A').toUpperCase();
const allNav = [
{ key:'dashboard', label:'Dashboard', href: base, icon:'M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z' },
{ key:'bestellungen', label:'Bestellungen', href: base + '/bestellungen', icon:'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4H6Zm.5 2h11l1.5 2H5l1.5-2ZM5 8h14v12H5V8Zm4 2a3 3 0 0 0 6 0' },
{ key:'produkte', label:'Produkte', href: base + '/produkte', icon:'M20.5 7.3 12 2 3.5 7.3 12 12.6l8.5-5.3ZM3 9v8l8 5v-8L3 9Zm10 13 8-5V9l-8 5v8Z' },
{ key:'kunden', label:'Kunden', href: base + '/kunden', icon:'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-8 2-8 5v1h16v-1c0-3-4-5-8-5Z' },
{ key:'analytics', label:'Analytics', href: base + '/analytics', icon:'M4 20V10m6 10V4m6 16v-7m4 7H2' },
{ key:'marketing', label:'Marketing', href: base + '/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' },
{ key:'inhalte', label:'Inhalte', href: base + '/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' },
];
const ownerNav = [
{ key:'nutzer', label:'Nutzer & Zugänge', href: base + '/nutzer', icon:'M16 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 2c-3 0-6 1.5-6 4v1h7M16 13c-3.3 0-6 1.7-6 4.2V19h12v-1.8c0-2.5-2.7-4.2-6-4.2Z' },
{ key:'audit', label:'Aktivität (Audit)', href: base + '/audit', icon:'M12 8v4l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z' },
{ key:'einstellungen', label:'Einstellungen', href: base + '/einstellungen', icon:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 4-2 .5.4 2-1.7 1.3-1.7-1.2-1.8.8-.3 2H10l-.3-2-1.8-.8-1.7 1.2L4.5 14.5 5 12.5 3 12l.5-2 2-.5-.4-2L6.8 6.2l1.7 1.2 1.8-.8.3-2h2.8l.3 2 1.8.8 1.7-1.2 1.7 1.3-.4 2 2 .5-.5 2Z' },
];
const nav = allNav.filter(n => sections.includes(n.key));
const ownerItems = ownerNav.filter(n => sections.includes(n.key));
// Command-Palette-Items (nur erlaubte)
const paletteItems = [
...nav.map(n => ({ label: n.label, href: n.href, kind: 'Navigation' })),
...ownerItems.map(n => ({ label: n.label, href: n.href, kind: 'Navigation' })),
];
if (sections.includes('produkte')) paletteItems.push({ label: 'Neues Produkt anlegen', href: base + '/produkte/neu', kind: 'Aktion' });
if (sections.includes('inhalte')) paletteItems.push({ label: 'Neue Seite anlegen', href: base + '/inhalte?tab=pages&new=1', kind: 'Aktion' });
paletteItems.push({ label: 'Shop ansehen', href: '/', kind: 'Aktion' });
paletteItems.push({ label: 'Abmelden', href: base + '/logout', kind: 'Aktion' });
const paletteJson = JSON.stringify(paletteItems);
---
<!doctype html>
<html lang="de">
@@ -47,6 +72,13 @@ const nav = [
{n.label}
</a>
))}
{ownerItems.length > 0 && <div class="s-nav-sec">Verwaltung</div>}
{ownerItems.map((n) => (
<a href={n.href} class={active === n.key ? 'active' : ''}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={n.icon}/></svg>
{n.label}
</a>
))}
<div class="s-nav-sec">Vertriebskanal</div>
<a href="/" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
@@ -60,15 +92,106 @@ const nav = [
<header class="s-topbar">
<div>
<div class="s-crumbs">
<a href="/admin">Admin</a>
<a href={base}>Admin</a>
{crumbs.map((c) => (<><span>/</span>{c.href ? <a href={c.href}>{c.label}</a> : <span>{c.label}</span>}</>))}
</div>
<div class="s-title">{title}</div>
</div>
<div class="s-actions"><slot name="actions" /></div>
<div class="s-actions">
<slot name="actions" />
<button type="button" class="s-btn s-cmdk-trigger" id="cmdkOpen" title="Befehle (⌘K)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<span class="s-kbd">⌘K</span>
</button>
<div class="s-account">
<button type="button" class="s-account-btn" id="acctBtn">
<span class="s-acct-av">{userInitial}</span>
<span class="s-acct-meta"><span class="s-acct-name">{user?.name || user?.email || 'Konto'}</span><span class="s-acct-role">{roleLabel}</span></span>
</button>
<div class="s-account-menu" id="acctMenu" hidden>
<a href={base + '/konto'}>Mein Konto</a>
<a href={base + '/logout'} class="danger">Abmelden</a>
</div>
</div>
</div>
</header>
<div class="s-content"><slot /></div>
</div>
</div>
<!-- Command Palette -->
<div class="s-cmdk" id="cmdk" hidden>
<div class="s-cmdk-backdrop" data-close></div>
<div class="s-cmdk-panel" role="dialog" aria-label="Befehle">
<input type="text" class="s-cmdk-input" id="cmdkInput" placeholder="Suchen oder Aktion wählen …" autocomplete="off" />
<ul class="s-cmdk-list" id="cmdkList"></ul>
</div>
</div>
<!-- Toasts -->
<div class="s-toasts" id="toasts" aria-live="polite"></div>
<script is:inline define:vars={{ paletteJson }}>
(function () {
// Toasts (global)
window.hdcToast = function (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); }, 3200);
};
try {
var p = new URLSearchParams(location.search);
if (p.get('saved')) window.hdcToast('Gespeichert.', 'ok');
} catch (e) {}
// Account-Menü
var ab = document.getElementById('acctBtn'), am = document.getElementById('acctMenu');
if (ab && am) {
ab.addEventListener('click', function (e) { e.stopPropagation(); am.hidden = !am.hidden; });
document.addEventListener('click', function () { am.hidden = true; });
}
// Command Palette
var items = JSON.parse(paletteJson);
var cmdk = document.getElementById('cmdk'), input = document.getElementById('cmdkInput'),
list = document.getElementById('cmdkList'), sel = 0, filtered = items.slice();
function render() {
list.innerHTML = '';
filtered.forEach(function (it, i) {
var li = document.createElement('li');
li.className = 's-cmdk-item' + (i === sel ? ' active' : '');
li.innerHTML = '<span>' + it.label + '</span><em>' + it.kind + '</em>';
li.addEventListener('click', function () { go(it); });
li.addEventListener('mousemove', function () { sel = i; paint(); });
list.appendChild(li);
});
}
function paint() { Array.prototype.forEach.call(list.children, function (li, i) { li.classList.toggle('active', i === sel); }); }
function go(it) { if (it && it.href) location.href = it.href; }
function open() { cmdk.hidden = false; input.value=''; filtered = items.slice(); sel = 0; render(); setTimeout(function(){input.focus();}, 30); }
function close() { cmdk.hidden = true; }
function filter() {
var q = input.value.toLowerCase().trim();
filtered = items.filter(function (it) { return it.label.toLowerCase().indexOf(q) > -1 || it.kind.toLowerCase().indexOf(q) > -1; });
sel = 0; render();
}
var openBtn = document.getElementById('cmdkOpen');
if (openBtn) openBtn.addEventListener('click', open);
document.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); cmdk.hidden ? open() : close(); return; }
if (cmdk.hidden) return;
if (e.key === 'Escape') { close(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); sel = Math.min(sel + 1, filtered.length - 1); paint(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = Math.max(sel - 1, 0); paint(); }
else if (e.key === 'Enter') { e.preventDefault(); go(filtered[sel]); }
});
if (input) input.addEventListener('input', filter);
if (cmdk) cmdk.addEventListener('click', function (e) { if (e.target.hasAttribute('data-close')) close(); });
})();
</script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
// hd-commerce — Token-gesicherte Admin-JSON-API (für KI/MCP).
// Bearer-Token aus ENV HDC_API_TOKEN (getrennt von der Session-Auth).
import * as store from './store.js';
import { BLOCK_TYPES } from './blocks.js';
export function json(obj, status = 200) {
return new Response(JSON.stringify(obj, null, 2), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
}
export function authOk(request) {
const token = (process.env.HDC_API_TOKEN || '').trim();
if (!token) return false; // ohne konfiguriertes Token bleibt die API gesperrt
const hdr = request.headers.get('authorization') || '';
const m = hdr.match(/^Bearer\s+(.+)$/i);
return !!m && m[1].trim() === token;
}
// ---- Ressourcen-Definitionen für das Manifest ----
export const RESOURCES = {
products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] },
pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] },
slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] },
popups: { rw: true, fields: ['title', 'type', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort'] },
settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] },
orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'items[]', 'address', 'created_at'] },
customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] },
};
export function listResource(name) {
switch (name) {
case 'products': return store.listProducts();
case 'pages': return store.listPages();
case 'slides': return store.listSlides();
case 'popups': return store.listPopups();
case 'orders': return store.listOrders();
case 'customers': return store.listCustomers();
case 'settings': return store.getSettings();
default: return null;
}
}
export function getResource(name, id) {
switch (name) {
case 'products': return store.getProductById(id);
case 'pages': return /^\d+$/.test(String(id)) ? store.getPageById(id) : store.getPageBySlug(id);
case 'slides': return store.getSlideById(id);
case 'popups': return store.getPopupById(id);
case 'orders': return store.getOrderById(id);
case 'customers': return store.getCustomerById(id);
default: return null;
}
}
// upsert: bei id -> update, sonst create. Für products/pages erlaubt auch slug als Schlüssel.
export function upsertResource(name, body) {
if (name === 'products') {
if (body.id) { store.updateProduct(body.id, body); return { id: Number(body.id), ...store.getProductById(body.id) }; }
if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); return store.getProductById(ex.id); } }
const id = store.createProduct(body); return store.getProductById(id);
}
if (name === 'pages') {
if (body.id) { store.updatePage(body.id, body); return store.getPageById(body.id); }
if (body.slug) { const ex = store.getPageBySlug(body.slug); if (ex) { store.updatePage(ex.id, { ...ex, ...body }); return store.getPageById(ex.id); } }
const id = store.createPage(body); return store.getPageById(id);
}
if (name === 'slides') {
if (body.id) { store.updateSlide(body.id, body); return store.getSlideById(body.id); }
const id = store.createSlide(body); return store.getSlideById(id);
}
if (name === 'popups') {
if (body.id) { store.updatePopup(body.id, body); return store.getPopupById(body.id); }
const id = store.createPopup(body); return store.getPopupById(id);
}
if (name === 'settings') {
const entries = body && typeof body === 'object' ? Object.entries(body) : [];
for (const [k, v] of entries) store.setSetting(k, v);
return store.getSettings();
}
throw new Error('Ressource nicht schreibbar: ' + name);
}
export function deleteResource(name, id) {
switch (name) {
case 'products': store.deleteProduct(id); return true;
case 'pages': store.deletePage(id); return true;
case 'slides': store.deleteSlide(id); return true;
case 'popups': store.deletePopup(id); return true;
default: throw new Error('Ressource nicht löschbar: ' + name);
}
}
export function updatePageBlocks(id, blocks) { store.updatePageBlocks(id, blocks); return store.getPageById(id); }
export function recordAudit(o) { store.recordAudit(o); }
export function blockTypes() { return BLOCK_TYPES.map(b => ({ key: b.key, label: b.label, fields: b.fields.map(f => ({ name: f.name, type: f.type })) })); }
export function manifest(origin) {
const ep = [];
ep.push({ method: 'GET', path: '/api/admin', desc: 'Dieses Manifest' });
for (const [name, def] of Object.entries(RESOURCES)) {
ep.push({ method: 'GET', path: `/api/admin/${name}`, desc: `Liste ${name}` });
ep.push({ method: 'GET', path: `/api/admin/${name}/{id}`, desc: `Einzelnes ${name}` });
if (def.rw) {
ep.push({ method: 'POST', path: `/api/admin/${name}`, desc: `Upsert ${name} (id oder slug => Update, sonst Create)` });
if (name !== 'settings') ep.push({ method: 'DELETE', path: `/api/admin/${name}/{id}`, desc: `Löschen ${name}` });
}
}
ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' });
return {
name: 'hd-commerce Admin API',
version: '2.0.0',
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
base_url: origin || '',
resources: RESOURCES,
block_types: blockTypes(),
endpoints: ep,
notes: [
'Preise in Cent (priceCents/total_cents).',
'orders und customers sind nur lesbar.',
'settings ist eine Key/Value-Map; POST mit beliebigen Keys aktualisiert sie.',
'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.',
],
};
}
+111
View File
@@ -0,0 +1,111 @@
// hd-commerce — Session-Auth (stateless signiertes Cookie), Rollen-Gate, Rate-Limit.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { getUserById } from './store.js';
const SECRET = process.env.SESSION_SECRET || 'hd-commerce-dev-secret-change-me';
export const COOKIE_NAME = 'hdc_session';
// --- konfigurierbarer Admin-Pfad ---
function rawAdminPath() {
let p = (process.env.ADMIN_PATH || 'admin').trim().replace(/^\/+|\/+$/g, '');
if (!p) p = 'admin';
return p;
}
export const adminBase = () => '/' + rawAdminPath(); // z.B. "/login" oder "/admin"
export const adminPathSegment = () => rawAdminPath(); // "login"
export const isCustomAdminPath = () => rawAdminPath() !== 'admin';
// Hilfsfunktion für Links in Astro-Seiten:
export const ab = (suffix = '') => adminBase() + (suffix || '');
// --- Cookie-Signatur ---
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
function b64urlDecode(str) { return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
export function signSession(uid, maxAgeSeconds) {
const exp = Math.floor(Date.now() / 1000) + (maxAgeSeconds || 60 * 60 * 12);
const payload = b64url(JSON.stringify({ uid: Number(uid), exp }));
const sig = b64url(createHmac('sha256', SECRET).update(payload).digest());
return payload + '.' + sig;
}
export function verifySession(token) {
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
const [payload, sig] = token.split('.');
if (!payload || !sig) return null;
const expected = b64url(createHmac('sha256', SECRET).update(payload).digest());
try {
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
} catch { return null; }
let data;
try { data = JSON.parse(b64urlDecode(payload).toString('utf8')); } catch { return null; }
if (!data || !data.uid || !data.exp) return null;
if (data.exp < Math.floor(Date.now() / 1000)) return null;
return data;
}
export function buildCookie(token, remember) {
const parts = [`${COOKIE_NAME}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
if (remember) parts.push('Max-Age=' + (60 * 60 * 24 * 30));
return parts.join('; ');
}
export function clearCookie() {
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
export function parseCookies(request) {
const h = request.headers.get('cookie') || '';
const out = {};
h.split(';').forEach(p => {
const i = p.indexOf('=');
if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
});
return out;
}
export function currentUser(request) {
const token = parseCookies(request)[COOKIE_NAME];
const sess = verifySession(token);
if (!sess) return null;
const u = getUserById(sess.uid);
if (!u || !u.active) return null;
return u;
}
// --- Rollen-Gate ---
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
const ROLE_SECTIONS = {
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'inhalte', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'analytics'],
versand: ['bestellungen'],
};
export function canAccess(role, section) {
const allowed = ROLE_SECTIONS[role] || ROLE_SECTIONS.redaktion;
return allowed.includes(section);
}
export function allowedSections(role) {
return ROLE_SECTIONS[role] || ROLE_SECTIONS.redaktion;
}
export function landingFor(role) {
if (role === 'versand') return adminBase() + '/bestellungen';
return adminBase();
}
// --- Login-Rate-Limit (In-Memory) ---
const attempts = new Map(); // ip -> { count, until }
export function rateLimited(ip) {
const r = attempts.get(ip);
if (r && r.until && Date.now() < r.until) return true;
return false;
}
export function registerFail(ip) {
const r = attempts.get(ip) || { count: 0, until: 0 };
r.count += 1;
if (r.count >= 5) { r.until = Date.now() + 60 * 1000; r.count = 0; }
attempts.set(ip, r);
}
export function clearFails(ip) { attempts.delete(ip); }
export function clientIp(request) {
return (request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'local').split(',')[0].trim();
}
+94
View File
@@ -0,0 +1,94 @@
// hd-commerce — Block-Definitionen für den Visual-Builder.
// Jeder Block-Typ: key, label, icon (svg path), defaults, fields (für Settings-Panel).
export const BLOCK_TYPES = [
{
key: 'hero', label: 'Hero', icon: 'M3 5h18v14H3z M3 11h18',
defaults: { headline: 'Willkommen', subline: 'Ein starker Untertitel', image: '', cta_text: 'Jetzt entdecken', cta_url: '/shop', align: 'center' },
fields: [
{ name: 'headline', label: 'Headline', type: 'text' },
{ name: 'subline', label: 'Subline', type: 'textarea' },
{ name: 'image', label: 'Hintergrundbild', type: 'image' },
{ name: 'cta_text', label: 'Button-Text', type: 'text' },
{ name: 'cta_url', label: 'Button-Link', type: 'text' },
{ name: 'align', label: 'Ausrichtung', type: 'select', options: ['left', 'center'] },
],
},
{
key: 'richtext', label: 'Rich-Text', icon: 'M4 6h16M4 12h16M4 18h10',
defaults: { html: '<p>Dein Text hier. <strong>HTML</strong> ist erlaubt.</p>' },
fields: [{ name: 'html', label: 'Inhalt (HTML)', type: 'textarea' }],
},
{
key: 'image', label: 'Bild', icon: 'M3 5h18v14H3z M3 16l5-5 4 4 3-3 6 6',
defaults: { image: '', caption: '', width: 'wide' },
fields: [
{ name: 'image', label: 'Bild', type: 'image' },
{ name: 'caption', label: 'Bildunterschrift', type: 'text' },
{ name: 'width', label: 'Breite', type: 'select', options: ['narrow', 'wide', 'full'] },
],
},
{
key: 'gallery', label: 'Galerie', icon: 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
defaults: { images: [], columns: 3 },
fields: [
{ name: 'images', label: 'Bilder (eine URL pro Zeile)', type: 'imagelist' },
{ name: 'columns', label: 'Spalten', type: 'select', options: ['2', '3', '4'] },
],
},
{
key: 'slider', label: 'Slider-Referenz', icon: 'M2 12h20 M7 7l-5 5 5 5 M17 7l5 5-5 5',
defaults: {},
fields: [],
},
{
key: 'features', label: 'Feature-Grid', icon: 'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z',
defaults: {
headline: 'Unsere Vorteile',
items: [
{ title: 'Schnell', text: 'Blitzschneller Versand.' },
{ title: 'Sicher', text: 'Geschützte Bezahlung.' },
{ title: 'Fair', text: 'Transparente Preise.' },
],
},
fields: [
{ name: 'headline', label: 'Überschrift', type: 'text' },
{ name: 'items', label: 'Features (3)', type: 'features' },
],
},
{
key: 'productgrid', label: 'Produkt-Grid', icon: 'M4 4h7v7H4z M13 4h7v7h-7z M4 13h7v7H4z M13 13h7v7h-7z',
defaults: { headline: 'Beliebte Produkte', source: 'featured', category: '', limit: 4 },
fields: [
{ name: 'headline', label: 'Überschrift', type: 'text' },
{ name: 'source', label: 'Quelle', type: 'select', options: ['featured', 'category', 'all'] },
{ name: 'category', label: 'Kategorie (bei „category")', type: 'text' },
{ name: 'limit', label: 'Anzahl', type: 'number' },
],
},
{
key: 'cta', label: 'CTA-Banner', icon: 'M3 7h18v10H3z M8 12h8',
defaults: { headline: 'Bereit loszulegen?', text: 'Stöbere jetzt im Shop.', cta_text: 'Zum Shop', cta_url: '/shop' },
fields: [
{ name: 'headline', label: 'Headline', type: 'text' },
{ name: 'text', label: 'Text', type: 'textarea' },
{ name: 'cta_text', label: 'Button-Text', type: 'text' },
{ name: 'cta_url', label: 'Button-Link', type: 'text' },
],
},
{
key: 'spacer', label: 'Abstand', icon: 'M12 4v16 M6 8l6-4 6 4 M6 16l6 4 6-4',
defaults: { size: 'medium' },
fields: [{ name: 'size', label: 'Größe', type: 'select', options: ['small', 'medium', 'large'] }],
},
{
key: 'html', label: 'Roh-HTML', icon: 'M8 6l-5 6 5 6 M16 6l5 6-5 6',
defaults: { code: '<div style="padding:2rem;text-align:center">Eigenes HTML</div>' },
fields: [{ name: 'code', label: 'HTML-Code', type: 'textarea' }],
},
];
export const blockMeta = (type) => BLOCK_TYPES.find(b => b.key === type) || null;
export function blockDefaults(type) {
const m = blockMeta(type);
return m ? JSON.parse(JSON.stringify(m.defaults)) : {};
}
+135 -12
View File
@@ -10,6 +10,12 @@ const DB_PATH = process.env.DB_PATH || './data/hdc.db';
try { mkdirSync(dirname(DB_PATH), { recursive: true }); } catch {}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
function ensureColumn(table, col, ddl) {
try {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
} catch {}
}
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
@@ -37,7 +43,16 @@ CREATE TABLE IF NOT EXISTS slides (
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT, body TEXT,
type TEXT DEFAULT 'content', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99
type TEXT DEFAULT 'content', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99, blocks TEXT DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT UNIQUE NOT NULL,
pass_hash TEXT, pass_salt TEXT, role TEXT DEFAULT 'owner', active INTEGER DEFAULT 1,
created_at TEXT, last_login TEXT
);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, action TEXT, entity TEXT,
entity_id TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS popups (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, type TEXT DEFAULT 'newsletter',
@@ -58,7 +73,9 @@ CREATE TABLE IF NOT EXISTS media (
);
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit(created_at);
`);
ensureColumn('pages', 'blocks', "blocks TEXT DEFAULT '[]'");
// ---------- mappers ----------
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured });
@@ -170,10 +187,17 @@ export const listFeatured = () => db.prepare('SELECT * FROM products WHERE featu
export const getProductBySlug = (slug) => P(db.prepare('SELECT * FROM products WHERE slug=?').get(slug));
export const getProductById = (id) => P(db.prepare('SELECT * FROM products WHERE id=?').get(Number(id)));
export const listCategories = () => [...new Set(db.prepare("SELECT category FROM products WHERE category IS NOT NULL AND category<>'' ORDER BY sort").all().map(r => r.category))];
function slugify(str) {
return String(str || '').toLowerCase()
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
.replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
}
function normProduct(d) {
const cardImage = d.cardImage || (Array.isArray(d.images) && d.images[0]) || '';
const slug = (d.slug && String(d.slug).trim()) ? slugify(d.slug) : slugify(d.name || 'produkt');
return {
slug: d.slug, name: d.name, shortName: d.shortName || d.name, priceCents: Math.round(Number(d.priceCents) || 0), category: d.category || '',
slug, name: d.name, shortName: d.shortName || d.name, priceCents: Math.round(Number(d.priceCents) || 0), category: d.category || '',
sizes: JSON.stringify(d.sizes && d.sizes.length ? d.sizes : ['One Size']), images: JSON.stringify(d.images || []), cardImage,
badge: d.badge || '', stock: (d.stock === '' || d.stock == null) ? null : Math.round(Number(d.stock)), material: d.material || '',
features: JSON.stringify(d.features || []), featured: d.featured ? 1 : 0, sort: Number(d.sort) || 99, desc: d.desc || '',
@@ -236,18 +260,35 @@ export function updateSlide(id, d) {
export const deleteSlide = (id) => db.prepare('DELETE FROM slides WHERE id=?').run(Number(id));
// ---------- pages ----------
export const listPages = () => db.prepare('SELECT * FROM pages ORDER BY sort, id').all();
export const listActivePages = () => db.prepare('SELECT * FROM pages WHERE active=1 ORDER BY sort, id').all();
export const listLegalPages = () => db.prepare("SELECT * FROM pages WHERE active=1 AND type='legal' ORDER BY sort, id").all();
export const getPageBySlug = (slug) => db.prepare('SELECT * FROM pages WHERE slug=?').get(slug);
export const getPageById = (id) => db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id));
function PG(r) {
if (!r) return r;
let blocks = [];
try { blocks = JSON.parse(r.blocks || '[]'); if (!Array.isArray(blocks)) blocks = []; } catch { blocks = []; }
return { ...r, blocks };
}
export const listPages = () => db.prepare('SELECT * FROM pages ORDER BY sort, id').all().map(PG);
export const listActivePages = () => db.prepare('SELECT * FROM pages WHERE active=1 ORDER BY sort, id').all().map(PG);
export const listLegalPages = () => db.prepare("SELECT * FROM pages WHERE active=1 AND type='legal' ORDER BY sort, id").all().map(PG);
export const getPageBySlug = (slug) => PG(db.prepare('SELECT * FROM pages WHERE slug=?').get(slug));
export const getPageById = (id) => PG(db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id)));
function normBlocks(b) {
if (typeof b === 'string') { try { b = JSON.parse(b); } catch { b = []; } }
return JSON.stringify(Array.isArray(b) ? b : []);
}
export function createPage(d) {
return db.prepare('INSERT INTO pages (slug,title,body,type,active,sort) VALUES (?,?,?,?,?,?)')
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99).lastInsertRowid;
return db.prepare('INSERT INTO pages (slug,title,body,type,active,sort,blocks) VALUES (?,?,?,?,?,?,?)')
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99, normBlocks(d.blocks)).lastInsertRowid;
}
export function updatePage(id, d) {
db.prepare('UPDATE pages SET slug=?,title=?,body=?,type=?,active=?,sort=? WHERE id=?')
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99, Number(id));
const cur = db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id)) || {};
const blocks = (d.blocks !== undefined) ? normBlocks(d.blocks) : (cur.blocks || '[]');
db.prepare('UPDATE pages SET slug=?,title=?,body=?,type=?,active=?,sort=?,blocks=? WHERE id=?')
.run(d.slug ?? cur.slug, d.title ?? cur.title ?? '', d.body ?? cur.body ?? '', d.type ?? cur.type ?? 'content',
(d.active !== undefined ? (d.active ? 1 : 0) : cur.active), Number(d.sort ?? cur.sort) || 99, blocks, Number(id));
return id;
}
export function updatePageBlocks(id, blocks) {
db.prepare('UPDATE pages SET blocks=? WHERE id=?').run(normBlocks(blocks), Number(id));
return id;
}
export const deletePage = (id) => db.prepare('DELETE FROM pages WHERE id=?').run(Number(id));
@@ -358,5 +399,87 @@ export function dashboard() {
const recentOrders = db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC LIMIT 6').all().map(O);
const lowStock = db.prepare('SELECT * FROM products WHERE stock IS NOT NULL AND stock <= 35 ORDER BY stock ASC LIMIT 6').all().map(P);
const a = analyticsSummary(30);
return { revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock, funnelMini: { views: a.pageviews, cart: a.addToCart, buy: a.purchases } };
// 14-Tage Umsatz-Spark + Trend (zweite Hälfte vs. erste Hälfte)
const spark = a.series.slice(-14).map(d => d.revenue);
const half = Math.floor(spark.length / 2) || 1;
const first = spark.slice(0, half).reduce((x, y) => x + y, 0) || 0;
const second = spark.slice(half).reduce((x, y) => x + y, 0) || 0;
const revTrend = first ? Math.round(((second - first) / first) * 100) : (second ? 100 : 0);
const feed = recentAudit(8);
return {
revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock,
funnelMini: { views: a.pageviews, cart: a.addToCart, buy: a.purchases },
spark, revTrend, visitors: a.visitors, conversion: a.conversion, feed,
};
}
// ---------- users / auth ----------
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
function hashPassword(password, salt) {
const s = salt || randomBytes(16).toString('hex');
const hash = scryptSync(String(password), s, 64).toString('hex');
return { pass_hash: hash, pass_salt: s };
}
export function verifyPassword(password, hash, salt) {
if (!hash || !salt) return false;
try {
const cand = scryptSync(String(password), salt, 64);
const ref = Buffer.from(hash, 'hex');
return cand.length === ref.length && timingSafeEqual(cand, ref);
} catch { return false; }
}
const U = (r) => r && ({ ...r, active: !!r.active });
export const listUsers = () => db.prepare('SELECT * FROM users ORDER BY id').all().map(U);
export const getUserById = (id) => U(db.prepare('SELECT * FROM users WHERE id=?').get(Number(id)));
export const getUserByEmail = (email) => U(db.prepare('SELECT * FROM users WHERE email=?').get(String(email || '').toLowerCase().trim()));
export const countUsers = () => db.prepare('SELECT COUNT(*) c FROM users').get().c;
const ROLES = ['owner', 'redaktion', 'versand'];
export function createUser({ name, email, password, role = 'owner', active = true }) {
const e = String(email || '').toLowerCase().trim();
if (!e) throw new Error('E-Mail erforderlich');
if (!ROLES.includes(role)) role = 'redaktion';
const { pass_hash, pass_salt } = hashPassword(password || randomBytes(8).toString('hex'));
const r = db.prepare('INSERT INTO users (name,email,pass_hash,pass_salt,role,active,created_at) VALUES (?,?,?,?,?,?,?)')
.run(name || e, e, pass_hash, pass_salt, role, active ? 1 : 0, new Date().toISOString());
return r.lastInsertRowid;
}
export function updateUserRole(id, role) {
if (!ROLES.includes(role)) return;
db.prepare('UPDATE users SET role=? WHERE id=?').run(role, Number(id));
}
export function setUserActive(id, active) {
db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, Number(id));
}
export function setUserPassword(id, password) {
const { pass_hash, pass_salt } = hashPassword(password);
db.prepare('UPDATE users SET pass_hash=?,pass_salt=? WHERE id=?').run(pass_hash, pass_salt, Number(id));
}
export function deleteUser(id) {
db.prepare('DELETE FROM users WHERE id=?').run(Number(id));
}
export function touchUserLogin(id) {
db.prepare('UPDATE users SET last_login=? WHERE id=?').run(new Date().toISOString(), Number(id));
}
// Seed the initial owner from ENV on first boot
export function seedAdminUser() {
if (countUsers() > 0) return;
const email = (process.env.ADMIN_EMAIL || 'admin@example.com').toLowerCase().trim();
const pass = process.env.ADMIN_PASS || 'admin';
try { createUser({ name: 'Administrator', email, password: pass, role: 'owner', active: true }); } catch {}
}
seedAdminUser();
// ---------- audit ----------
export function recordAudit({ user = '', action = '', entity = '', entity_id = '' }) {
try {
db.prepare('INSERT INTO audit (user,action,entity,entity_id,created_at) VALUES (?,?,?,?,?)')
.run(String(user || ''), String(action || ''), String(entity || ''), String(entity_id || ''), new Date().toISOString());
} catch {}
}
export const listAudit = (limit = 200) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 200);
export const recentAudit = (limit = 8) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 8);
+62 -18
View File
@@ -1,8 +1,8 @@
import { recordEvent, getSetting } from './lib/store.js';
import { createHash } from 'node:crypto';
const USER = process.env.ADMIN_USER || 'admin';
const PASS = process.env.ADMIN_PASS || 'admin';
import {
currentUser, adminBase, adminPathSegment, isCustomAdminPath, canAccess, landingFor,
} from './lib/auth.js';
const SKIP = ['/api/', '/uploads/', '/_astro', '/favicon', '/_image', '/robots.txt'];
@@ -13,26 +13,70 @@ function sessionHash(request) {
return createHash('sha256').update(ip + ua + day).digest('hex').slice(0, 16);
}
export function onRequest({ request }, next) {
function sectionOf(adminInner) {
const seg = adminInner.replace(/^\//, '').split('/')[0] || 'dashboard';
const map = {
'': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden',
'analytics': 'analytics', 'marketing': 'marketing', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen',
'nutzer': 'nutzer', 'audit': 'audit', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout',
};
return map[seg] || 'dashboard';
}
export async function onRequest(context, next) {
const { request, locals } = context;
const url = new URL(request.url);
const path = url.pathname;
const base = adminBase(); // "/login" oder "/admin"
const custom = isCustomAdminPath();
// --- Admin Basic-Auth ---
if (path.startsWith('/admin')) {
const hdr = request.headers.get('authorization') || '';
if (hdr.startsWith('Basic ')) {
let dec = ''; try { dec = atob(hdr.slice(6)); } catch {}
const i = dec.indexOf(':');
if (i > -1 && dec.slice(0, i) === USER && dec.slice(i + 1) === PASS) return next();
}
const shop = getSetting('shop_name', 'hd-commerce');
return new Response('Authentifizierung erforderlich', {
status: 401,
headers: { 'WWW-Authenticate': `Basic realm="${shop} Admin", charset="UTF-8"` },
});
// Interner Rewrite-Durchlauf (auf physische /admin-Routen) -> einfach durchreichen.
if (locals && locals.__hdcAdminRewrite) {
return next();
}
// --- First-Party Pageview-Tracking (nur Storefront-GET-Seiten) ---
// Custom-Admin-Pfad: direkter Zugriff auf physische /admin-Routen blocken (404).
if (custom && (path === '/admin' || path.startsWith('/admin/'))) {
return new Response('Not Found', { status: 404 });
}
// Admin-Bereich unter konfiguriertem Pfad
const isAdmin = path === base || path.startsWith(base + '/');
if (isAdmin) {
let inner = path.slice(base.length); // "" oder "/bestellungen/3"
if (inner === '') inner = '/';
const innerSeg = inner.replace(/^\//, '').split('/')[0];
const isLoginRoute = innerSeg === 'login';
const isLogoutRoute = innerSeg === 'logout';
const user = currentUser(request);
if (!user && !isLoginRoute) {
// Nicht eingeloggt -> Login-Seite rendern (HTTP 200).
if (locals) locals.__hdcAdminRewrite = true;
return context.rewrite('/admin/login?next=' + encodeURIComponent(path));
}
if (user && !isLoginRoute && !isLogoutRoute) {
const section = sectionOf(inner);
if (section !== 'dashboard' && section !== 'login' && section !== 'logout' && !canAccess(user.role, section)) {
return Response.redirect(new URL(landingFor(user.role), url), 302);
}
if (section === 'dashboard' && !canAccess(user.role, 'dashboard')) {
return Response.redirect(new URL(landingFor(user.role), url), 302);
}
}
// Auf physische /admin-Routen umschreiben.
if (custom) {
if (locals) locals.__hdcAdminRewrite = true;
const target = '/admin' + (inner === '/' ? '' : inner) + url.search;
return context.rewrite(target);
}
return next();
}
// First-Party Pageview-Tracking (nur Storefront-GET-Seiten)
if (request.method === 'GET' && !SKIP.some(s => path.startsWith(s))) {
try {
recordEvent({
+3 -1
View File
@@ -1,5 +1,7 @@
---
import Admin from '../../../layouts/Admin.astro';
import { adminBase } from '../../../lib/auth.js';
const base = adminBase();
import { analyticsSummary, formatPrice, getSetting } from '../../../lib/store.js';
const days = parseInt(new URL(Astro.request.url).searchParams.get('days') || '30') || 30;
const a = analyticsSummary(days);
@@ -16,7 +18,7 @@ const seriesJson = JSON.stringify(a.series);
---
<Admin title="Analytics" active="analytics" crumbs={[{ label: 'Analytics' }]}>
<div slot="actions" style="display:flex;gap:6px">
{[7, 30, 90].map((d) => (<a class={`s-btn s-btn-sm ${days === d ? 's-btn-primary' : ''}`} href={`/admin/analytics?days=${d}`}>{d} Tage</a>))}
{[7, 30, 90].map((d) => (<a class={`s-btn s-btn-sm ${days === d ? 's-btn-primary' : ''}`} href={`${base}/analytics?days=${d}`}>{d} Tage</a>))}
</div>
<div class="s-stack">
<div class="s-kpis">
+31
View File
@@ -0,0 +1,31 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listAudit } from '../../../lib/store.js';
const rows = listAudit(300);
const fmt = (s) => new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
const actionLabel = { create: ['green', 'Angelegt'], update: ['blue', 'Geändert'], delete: ['red', 'Gelöscht'], login: ['gray', 'Login'], password_change: ['amber', 'Passwort'], password_reset: ['amber', 'PW-Reset'] };
---
<Admin title="Aktivität (Audit)" active="audit" crumbs={[{ label: 'Audit' }]}>
<div class="s-card">
<div class="s-card-head">Letzte Aktivitäten</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Zeit</th><th>Nutzer</th><th>Aktion</th><th>Objekt</th></tr></thead>
<tbody>
{rows.length === 0 ? (<tr><td colspan="4" class="s-empty">Noch keine Aktivität aufgezeichnet</td></tr>) :
rows.map((r) => {
const a = actionLabel[r.action] || ['gray', r.action];
return (
<tr>
<td class="s-muted">{fmt(r.created_at)}</td>
<td><b>{r.user || '—'}</b></td>
<td><span class={`s-badge ${a[0]}`}>{a[1]}</span></td>
<td class="s-muted">{r.entity}{r.entity_id ? ' #' + r.entity_id : ''}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</Admin>
+6 -4
View File
@@ -1,21 +1,23 @@
---
import Admin from '../../../layouts/Admin.astro';
import { getOrderById, updateOrderStatus, formatPrice } from '../../../lib/store.js';
import { adminBase, currentUser } from '../../../lib/auth.js';
const base = adminBase();
import { getOrderById, updateOrderStatus, formatPrice, recordAudit } from '../../../lib/store.js';
const { id } = Astro.params;
let flash = '';
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
const status = form.get('status');
if (status) { updateOrderStatus(id, String(status)); flash = 'Status aktualisiert.'; }
if (status) { updateOrderStatus(id, String(status)); recordAudit({ user: currentUser(Astro.request)?.email, action: 'update', entity: 'order', entity_id: String(id) }); flash = 'Status aktualisiert.'; }
}
const order = getOrderById(id);
if (!order) return Astro.redirect('/admin/bestellungen');
if (!order) return Astro.redirect(base + '/bestellungen');
const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] };
const fmtDate = (s) => new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
const statuses = [['pending', 'Offen'], ['fulfilled', 'Erfüllt'], ['cancelled', 'Storniert'], ['refunded', 'Erstattet']];
---
<Admin title={`Bestellung ${order.number}`} active="bestellungen" crumbs={[{ label: 'Bestellungen', href: '/admin/bestellungen' }, { label: order.number }]}>
<Admin title={`Bestellung ${order.number}`} active="bestellungen" crumbs={[{ label: 'Bestellungen', href: base + '/bestellungen' }, { label: order.number }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<div class="s-two-col">
+4 -2
View File
@@ -1,5 +1,7 @@
---
import Admin from '../../../layouts/Admin.astro';
import { adminBase } from '../../../lib/auth.js';
const base = adminBase();
import { listOrders, formatPrice } from '../../../lib/store.js';
const orders = listOrders();
const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] };
@@ -11,9 +13,9 @@ const fmtDate = (s) => new Date(s).toLocaleDateString('de-DE', { day: '2-digit',
<table class="s-table">
<thead><tr><th>Bestellung</th><th>Datum</th><th>Kunde</th><th>Artikel</th><th>Status</th><th class="num">Betrag</th></tr></thead>
<tbody>
{orders.length === 0 ? (<tr><td colspan="6" class="s-empty">Noch keine Bestellungen</td></tr>) :
{orders.length === 0 ? (<tr><td colspan="6"><div class="s-emptystate"><div class="es-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4H6Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg></div><h3>Noch keine Bestellungen</h3><p>Sobald Kund:innen im Shop kaufen, erscheinen die Bestellungen hier.</p><a class="s-btn" href="/" target="_blank">Shop ansehen ↗</a></div></td></tr>) :
orders.map((o) => (
<tr class="clk" onclick={`location.href='/admin/bestellungen/${o.id}'`}>
<tr class="clk" onclick={`location.href='${base}/bestellungen/${o.id}'`}>
<td><b>{o.number}</b></td>
<td class="s-muted">{fmtDate(o.created_at)}</td>
<td>{o.customer_name || '—'}<div class="s-muted" style="font-size:12px">{o.email}</div></td>
+3 -1
View File
@@ -1,5 +1,7 @@
---
import Admin from '../../../layouts/Admin.astro';
import { adminBase } from '../../../lib/auth.js';
const base = adminBase();
import { getSettings, setSetting } from '../../../lib/store.js';
let flash = '';
@@ -66,7 +68,7 @@ const currencies = ['EUR', 'CHF', 'USD', 'GBP'];
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px">System</div>
<p class="s-help">Datenbank: SQLite (<b>DB_PATH</b>). Admin-Zugang über <b>ADMIN_USER</b> / <b>ADMIN_PASS</b>.</p>
<p class="s-help">Datenbank: SQLite (<b>DB_PATH</b>). Admin-Zugang über Session-Login; Initial-Owner aus <b>ADMIN_EMAIL</b> / <b>ADMIN_PASS</b>. Admin-Pfad über <b>ADMIN_PATH</b>. Nutzer & Rollen unter „Nutzer & Zugänge".</p>
</div>
</div>
</form>
+79 -40
View File
@@ -1,75 +1,114 @@
---
import Admin from '../../layouts/Admin.astro';
import { dashboard, formatPrice } from '../../lib/store.js';
import { adminBase, currentUser } from '../../lib/auth.js';
const base = adminBase();
const me = currentUser(Astro.request);
const d = dashboard();
const statusMap = { fulfilled: ['green', 'Erfüllt'], pending: ['amber', 'Offen'], cancelled: ['gray', 'Storniert'], refunded: ['red', 'Erstattet'] };
const fmtDate = (s) => new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
const fmtTime = (s) => new Date(s).toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
const kpis = [
{ label: 'Umsatz (gesamt)', val: formatPrice(d.revenueCents), sub: `${d.orderCount} Bestellungen` },
{ label: 'Umsatz (gesamt)', val: formatPrice(d.revenueCents), sub: `${d.orderCount} Bestellungen`, trend: d.revTrend, spark: d.spark },
{ label: 'Bestellungen', val: d.orderCount, sub: `${d.pending} offen` },
{ label: 'Produkte', val: d.productCount, sub: 'aktiv im Shop' },
{ label: 'Besucher (30 T.)', val: (d.visitors || 0).toLocaleString('de-DE'), sub: `${(d.conversion || 0).toFixed(1)} % Conversion` },
{ label: 'Kunden', val: d.customerCount, sub: 'registriert' },
];
const actionLabel = { create: 'angelegt', update: 'geändert', delete: 'gelöscht', login: 'Login', password_change: 'Passwort geändert', password_reset: 'PW zurückgesetzt' };
const sparkPath = (arr) => {
if (!arr || arr.length < 2) return '';
const max = Math.max(...arr, 1);
const w = 100, h = 30;
return arr.map((v, i) => `${(i / (arr.length - 1)) * w},${h - (v / max) * h}`).join(' ');
};
---
<Admin title="Dashboard" active="dashboard">
<a slot="actions" class="s-btn s-btn-primary" href="/admin/produkte/neu">+ Produkt</a>
<a slot="actions" class="s-btn s-btn-primary" href={base + "/produkte/neu"}>+ Produkt</a>
<div class="s-stack">
<div class="s-kpis">
{kpis.map((k) => (
<div class="s-kpi"><div class="s-kpi-label">{k.label}</div><div class="s-kpi-val">{k.val}</div><div class="s-kpi-sub">{k.sub}</div></div>
<div class="s-kpi">
<div class="s-kpi-label">{k.label}</div>
<div class="s-kpi-val">{k.val}</div>
{k.trend !== undefined ? (
<div class={`s-kpi-trend ${k.trend >= 0 ? 'up' : 'down'}`}>{k.trend >= 0 ? '▲' : '▼'} {Math.abs(k.trend)} %<span class="s-kpi-sub" style="margin:0 0 0 4px">14 T.</span></div>
) : (<div class="s-kpi-sub">{k.sub}</div>)}
{k.spark && k.spark.length > 1 && (
<svg class="s-kpi-spark" viewBox="0 0 100 30" preserveAspectRatio="none" width="100%"><polyline fill="none" stroke="var(--accent)" stroke-width="2" points={sparkPath(k.spark)} vector-effect="non-scaling-stroke"/></svg>
)}
{k.trend !== undefined && <div class="s-kpi-sub">{k.sub}</div>}
</div>
))}
</div>
<div class="s-card">
<div class="s-card-head">First-Party-Funnel (30 Tage)<a class="s-link" href="/admin/analytics">Details</a></div>
<div class="s-card-pad">
<div class="s-funnel-mini">
<div class="s-fm-step"><div class="v">{d.funnelMini.views}</div><div class="l">Aufrufe</div></div>
<div class="s-fm-arrow">→</div>
<div class="s-fm-step"><div class="v">{d.funnelMini.cart}</div><div class="l">In den Korb</div></div>
<div class="s-fm-arrow">→</div>
<div class="s-fm-step"><div class="v">{d.funnelMini.buy}</div><div class="l">Kauf</div></div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px;font-size:15px">Schnellaktionen</div>
<div class="s-quick">
<a href={base + "/produkte/neu"}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Neues Produkt</a>
<a href={base + "/inhalte?tab=pages&new=1"}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 4h16v4H4zM4 10h10v10H4z"/></svg>Neue Seite</a>
<a href={base + "/bestellungen"}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/></svg>Bestellungen</a>
<a href={base + "/analytics"}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 20V10m6 10V4m6 16v-7"/></svg>Analytics</a>
</div>
</div>
<div class="s-grid" style="grid-template-columns:1.4fr 1fr">
<div class="s-grid" style="grid-template-columns:1.5fr 1fr">
<div class="s-card">
<div class="s-card-head">Neueste Bestellungen<a class="s-link" href="/admin/bestellungen">Alle</a></div>
<div class="s-card-head">Neueste Bestellungen<a class="s-link" href={base + "/bestellungen"}>Alle</a></div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Bestellung</th><th>Kunde</th><th>Status</th><th class="num">Betrag</th></tr></thead>
<tbody>
{d.recentOrders.map((o) => (
<tr class="clk" onclick={`location.href='/admin/bestellungen/${o.id}'`}>
<td><b>{o.number}</b><div class="s-muted" style="font-size:12px">{fmtDate(o.created_at)}</div></td>
<td>{o.customer_name || '—'}</td>
<td><span class={`s-badge ${(statusMap[o.status]||['gray',o.status])[0]}`}>{(statusMap[o.status]||['',o.status])[1]}</span></td>
<td class="num">{formatPrice(o.total_cents)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="s-card">
<div class="s-card-head">Geringer Bestand</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Produkt</th><th class="num">Bestand</th></tr></thead>
<tbody>
{d.lowStock.length === 0 ? (<tr><td colspan="2" class="s-empty">Alles gut bestückt 👍</td></tr>) :
d.lowStock.map((p) => (
<tr class="clk" onclick={`location.href='/admin/produkte/${p.id}'`}>
<td><div class="s-prodcell">{p.cardImage && <img src={p.cardImage} alt="" />}<span class="nm">{p.shortName || p.name}</span></div></td>
<td class="num"><span class={`s-badge ${p.stock <= 10 ? 'red' : 'amber'}`}>{p.stock}</span></td>
{d.recentOrders.length === 0 ? (<tr><td colspan="4" class="s-empty">Noch keine Bestellungen</td></tr>) :
d.recentOrders.map((o) => (
<tr class="clk" onclick={`location.href='${base}/bestellungen/${o.id}'`}>
<td><b>{o.number}</b><div class="s-muted" style="font-size:12px">{fmtDate(o.created_at)}</div></td>
<td>{o.customer_name || '—'}</td>
<td><span class={`s-badge ${(statusMap[o.status]||['gray',o.status])[0]}`}>{(statusMap[o.status]||['',o.status])[1]}</span></td>
<td class="num">{formatPrice(o.total_cents)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div class="s-card">
<div class="s-card-head">Aktivität</div>
{(!d.feed || d.feed.length === 0) ? (
<div class="s-emptystate" style="padding:36px 20px">
<div class="es-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 8v4l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg></div>
<p>Hier erscheinen die letzten Änderungen im Shop.</p>
</div>
) : (
<div class="s-feed">
{d.feed.map((f) => (
<div class="s-feed-row">
<span class="s-feed-dot"></span>
<span><b>{f.user || 'System'}</b> · {f.entity} {actionLabel[f.action] || f.action}</span>
<span class="t">{fmtTime(f.created_at)}</span>
</div>
))}
</div>
)}
</div>
</div>
<div class="s-card">
<div class="s-card-head">Geringer Bestand</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Produkt</th><th class="num">Bestand</th></tr></thead>
<tbody>
{d.lowStock.length === 0 ? (<tr><td colspan="2" class="s-empty">Alles gut bestückt</td></tr>) :
d.lowStock.map((p) => (
<tr class="clk" onclick={`location.href='${base}/produkte/${p.id}'`}>
<td><div class="s-prodcell">{p.cardImage && <img src={p.cardImage} alt="" />}<span class="nm">{p.shortName || p.name}</span></div></td>
<td class="num"><span class={`s-badge ${p.stock <= 10 ? 'red' : 'amber'}`}>{p.stock}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</Admin>
+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>
+14 -10
View File
@@ -1,27 +1,30 @@
---
import Admin from '../../../layouts/Admin.astro';
import { adminBase, currentUser } from '../../../lib/auth.js';
const base = adminBase();
import {
listPages, createPage, updatePage, deletePage, getPageById,
listSlides, createSlide, updateSlide, deleteSlide, getSlideById,
listMedia,
listMedia, recordAudit,
} from '../../../lib/store.js';
let flash = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const _me = currentUser(Astro.request);
const action = f.get('_action');
if (action === 'page') {
const data = { slug: f.get('slug') || '', title: f.get('title') || '', body: f.get('body') || '', type: f.get('type') || 'content', active: f.get('active') === 'on', sort: parseInt(String(f.get('sort') || '99')) || 99 };
const id = f.get('id');
if (id) updatePage(id, data); else createPage(data);
return Astro.redirect('/admin/inhalte?tab=pages&saved=1');
} else if (action === 'delete-page') { deletePage(f.get('id')); return Astro.redirect('/admin/inhalte?tab=pages'); }
return Astro.redirect(base + '/inhalte?tab=pages&saved=1');
} else if (action === 'delete-page') { deletePage(f.get('id')); return Astro.redirect(base + '/inhalte?tab=pages'); }
else if (action === 'slide') {
const data = { image: f.get('image') || '', headline: f.get('headline') || '', subline: f.get('subline') || '', link: f.get('link') || '', sort: parseInt(String(f.get('sort') || '99')) || 99, active: f.get('active') === 'on' };
const id = f.get('id');
if (id) updateSlide(id, data); else createSlide(data);
return Astro.redirect('/admin/inhalte?tab=slider&saved=1');
} else if (action === 'delete-slide') { deleteSlide(f.get('id')); return Astro.redirect('/admin/inhalte?tab=slider'); }
return Astro.redirect(base + '/inhalte?tab=slider&saved=1');
} else if (action === 'delete-slide') { deleteSlide(f.get('id')); return Astro.redirect(base + '/inhalte?tab=slider'); }
}
const url = new URL(Astro.request.url);
@@ -42,7 +45,7 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media',
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<div class="s-tabs">
{tabs.map(([v, l]) => (<a class={`s-tab ${tab === v ? 'active' : ''}`} href={`/admin/inhalte?tab=${v}`}>{l}</a>))}
{tabs.map(([v, l]) => (<a class={`s-tab ${tab === v ? 'active' : ''}`} href={`${base}/inhalte?tab=${v}`}>{l}</a>))}
</div>
{tab === 'pages' && (
@@ -60,7 +63,8 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media',
<td><span class={`s-badge ${p.type === 'legal' ? 'blue' : 'gray'}`}>{p.type === 'legal' ? 'Rechtstext' : 'Inhalt'}</span></td>
<td>{p.active ? <span class="s-badge green">Aktiv</span> : <span class="s-badge gray">Aus</span>}</td>
<td class="num">
<a class="s-btn s-btn-sm" href={`/admin/inhalte?tab=pages&editpage=${p.id}`}>Bearbeiten</a>
<a class="s-btn s-btn-sm s-btn-primary" href={`${base}/inhalte/editor/${p.id}`}>Editor</a>
<a class="s-btn s-btn-sm" href={`${base}/inhalte?tab=pages&editpage=${p.id}`}>Bearbeiten</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Seite löschen?')"><input type="hidden" name="_action" value="delete-page" /><input type="hidden" name="id" value={p.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
</td>
</tr>
@@ -85,7 +89,7 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media',
<div class="s-field"><label class="s-label">Status</label><label class="s-check" style="margin-top:8px"><input type="checkbox" name="active" checked={!!pg.active} /> Aktiv</label></div>
</div>
<button class="s-btn s-btn-primary" type="submit" style="width:100%">{ep ? 'Speichern' : 'Anlegen'}</button>
{ep && <a class="s-btn" href="/admin/inhalte?tab=pages" style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
{ep && <a class="s-btn" href={base + "/inhalte?tab=pages"} style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
</form>
</div>
</div>
@@ -106,7 +110,7 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media',
<td class="s-muted">{s.sort}</td>
<td>{s.active ? <span class="s-badge green">Aktiv</span> : <span class="s-badge gray">Aus</span>}</td>
<td class="num">
<a class="s-btn s-btn-sm" href={`/admin/inhalte?tab=slider&editslide=${s.id}`}>Bearbeiten</a>
<a class="s-btn s-btn-sm" href={`${base}/inhalte?tab=slider&editslide=${s.id}`}>Bearbeiten</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Slide löschen?')"><input type="hidden" name="_action" value="delete-slide" /><input type="hidden" name="id" value={s.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
</td>
</tr>
@@ -129,7 +133,7 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media',
<div class="s-field"><label class="s-label">Status</label><label class="s-check" style="margin-top:8px"><input type="checkbox" name="active" checked={!!sl.active} /> Aktiv</label></div>
</div>
<button class="s-btn s-btn-primary" type="submit" style="width:100%">{es ? 'Speichern' : 'Anlegen'}</button>
{es && <a class="s-btn" href="/admin/inhalte?tab=slider" style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
{es && <a class="s-btn" href={base + "/inhalte?tab=slider"} style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
</form>
</div>
</div>
+41
View File
@@ -0,0 +1,41 @@
---
import Admin from '../../layouts/Admin.astro';
import { getUserById, verifyPassword, setUserPassword, recordAudit } from '../../lib/store.js';
import { currentUser, adminBase } from '../../lib/auth.js';
const base = adminBase();
const me = currentUser(Astro.request);
let flash = '', error = '';
if (Astro.request.method === 'POST' && me) {
const f = await Astro.request.formData();
const cur = String(f.get('current') || '');
const next = String(f.get('next') || '');
const conf = String(f.get('confirm') || '');
const fresh = getUserById(me.id);
if (!verifyPassword(cur, fresh.pass_hash, fresh.pass_salt)) error = 'Aktuelles Passwort ist falsch.';
else if (next.length < 6) error = 'Neues Passwort muss mindestens 6 Zeichen haben.';
else if (next !== conf) error = 'Die Passwörter stimmen nicht überein.';
else { setUserPassword(me.id, next); recordAudit({ user: me.email, action: 'password_change', entity: 'user', entity_id: String(me.id) }); flash = 'Passwort geändert.'; }
}
const roleLabel = { owner: 'Inhaber', redaktion: 'Redaktion', versand: 'Versand' }[me?.role] || me?.role;
---
<Admin title="Mein Konto" active="" crumbs={[{ label: 'Konto' }]}>
<div class="s-stack" style="max-width:520px">
{flash && <div class="s-flash">✓ {flash}</div>}
{error && <div class="login-error" style="margin-bottom:18px">{error}</div>}
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:12px;font-size:15px">Profil</div>
<p class="s-help" style="font-size:14px;color:var(--s-text)"><b>{me?.name}</b> · {me?.email}</p>
<p class="s-help" style="margin-top:6px">Rolle: <span class="s-badge blue">{roleLabel}</span></p>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:14px;font-size:15px">Passwort ändern</div>
<form method="POST">
<div class="s-field"><label class="s-label">Aktuelles Passwort</label><input class="s-input" name="current" type="password" required autocomplete="current-password" /></div>
<div class="s-field"><label class="s-label">Neues Passwort</label><input class="s-input" name="next" type="password" required autocomplete="new-password" /></div>
<div class="s-field"><label class="s-label">Neues Passwort bestätigen</label><input class="s-input" name="confirm" type="password" required autocomplete="new-password" /></div>
<button class="s-btn s-btn-primary" type="submit">Passwort speichern</button>
</form>
</div>
</div>
</Admin>
+2
View File
@@ -1,5 +1,7 @@
---
import Admin from '../../../layouts/Admin.astro';
import { adminBase } from '../../../lib/auth.js';
const base = adminBase();
import { listCustomers, formatPrice } from '../../../lib/store.js';
const customers = listCustomers().sort((a, b) => b.total_spent_cents - a.total_spent_cents);
const fmtDate = (s) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
+84
View File
@@ -0,0 +1,84 @@
---
import '@fontsource-variable/public-sans';
import '@fontsource-variable/fraunces';
import '../../styles/admin.css';
import { getSettings, getUserByEmail, verifyPassword, touchUserLogin, recordAudit } from '../../lib/store.js';
import { signSession, buildCookie, currentUser, adminBase, landingFor, rateLimited, registerFail, clearFails, clientIp } from '../../lib/auth.js';
const base = adminBase();
const settings = getSettings();
const shopName = settings.shop_name || 'hd-commerce';
const accent = settings.brand_accent || '#b8566a';
const accentDark = settings.brand_accent_dark || '#8d3f50';
const initial = (shopName.trim()[0] || 'H').toUpperCase();
const url = new URL(Astro.request.url);
let next = url.searchParams.get('next') || base;
// next muss innerhalb des Admin-Bereichs liegen
if (!next.startsWith(base)) next = base;
// Bereits eingeloggt? -> weiter
const existing = currentUser(Astro.request);
if (existing && Astro.request.method === 'GET') {
return Astro.redirect(next.startsWith('/admin') ? base : next);
}
let error = '';
if (Astro.request.method === 'POST') {
const ip = clientIp(Astro.request);
if (rateLimited(ip)) {
error = 'Zu viele Fehlversuche. Bitte 60 Sekunden warten.';
} else {
const f = await Astro.request.formData();
const email = String(f.get('email') || '').toLowerCase().trim();
const password = String(f.get('password') || '');
const remember = f.get('remember') === 'on';
const nx = String(f.get('next') || base);
const u = getUserByEmail(email);
if (u && u.active && verifyPassword(password, u.pass_hash, u.pass_salt)) {
clearFails(ip);
touchUserLogin(u.id);
recordAudit({ user: u.email, action: 'login', entity: 'session', entity_id: String(u.id) });
const token = signSession(u.id, remember ? 60 * 60 * 24 * 30 : 60 * 60 * 12);
const dest = (nx && nx.startsWith(base)) ? nx : landingFor(u.role);
return new Response(null, { status: 302, headers: { 'Location': dest, 'Set-Cookie': buildCookie(token, remember) } });
}
registerFail(ip);
error = 'E-Mail oder Passwort ist nicht korrekt.';
}
}
---
<!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>Anmelden · {shopName} Admin</title>
<style is:inline set:html={`:root{--accent:${accent};--accent-dark:${accentDark};}`}></style>
</head>
<body class="admin-body login-body">
<main class="login-wrap">
<form class="login-card" method="POST">
<div class="login-logo">{initial}</div>
<h1 class="login-title">{shopName}</h1>
<p class="login-sub">Anmeldung am Admin-Bereich</p>
{error && <div class="login-error">{error}</div>}
<input type="hidden" name="next" value={next} />
<div class="s-field">
<label class="s-label" for="email">E-Mail</label>
<input class="s-input" id="email" name="email" type="email" autocomplete="username" required autofocus />
</div>
<div class="s-field">
<label class="s-label" for="password">Passwort</label>
<input class="s-input" id="password" name="password" type="password" autocomplete="current-password" required />
</div>
<label class="s-check login-remember"><input type="checkbox" name="remember" /> Angemeldet bleiben</label>
<button class="s-btn s-btn-primary login-submit" type="submit">Anmelden</button>
<p class="login-foot">hd-commerce · sichere Sitzung</p>
</form>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
---
import { clearCookie, adminBase } from '../../lib/auth.js';
const base = adminBase();
return new Response(null, { status: 302, headers: { 'Location': base + '/login', 'Set-Cookie': clearCookie() } });
---
+10 -7
View File
@@ -1,10 +1,13 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listPopups, createPopup, updatePopup, deletePopup, getPopupById, setSetting, getSettings } from '../../../lib/store.js';
import { adminBase, currentUser } from '../../../lib/auth.js';
const base = adminBase();
import { listPopups, createPopup, updatePopup, deletePopup, getPopupById, setSetting, getSettings, recordAudit } from '../../../lib/store.js';
let flash = '';
if (Astro.request.method === 'POST') {
const f = await Astro.request.formData();
const _me = currentUser(Astro.request);
const action = f.get('_action');
if (action === 'announce') {
setSetting('announcement_text', f.get('announcement_text') || '');
@@ -12,7 +15,7 @@ if (Astro.request.method === 'POST') {
setSetting('announcement_active', f.get('announcement_active') === 'on' ? '1' : '0');
flash = 'Announcement-Bar gespeichert.';
} else if (action === 'delete-popup') {
deletePopup(f.get('id')); return Astro.redirect('/admin/marketing');
deletePopup(f.get('id')); return Astro.redirect(base + '/marketing');
} else if (action === 'popup') {
const data = {
title: f.get('title') || '', type: f.get('type') || 'newsletter', headline: f.get('headline') || '', body: f.get('body') || '',
@@ -22,8 +25,8 @@ if (Astro.request.method === 'POST') {
sort: parseInt(String(f.get('sort') || '99')) || 99,
};
const editId = f.get('id');
if (editId) { updatePopup(editId, data); flash = 'Popup gespeichert.'; }
else { createPopup(data); flash = 'Popup angelegt.'; }
if (editId) { updatePopup(editId, data); recordAudit({ user: _me?.email, action: 'update', entity: 'popup', entity_id: String(editId) }); flash = 'Popup gespeichert.'; }
else { const nid = createPopup(data); recordAudit({ user: _me?.email, action: 'create', entity: 'popup', entity_id: String(nid) }); flash = 'Popup angelegt.'; }
}
}
@@ -53,7 +56,7 @@ const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcem
<div class="s-two-col">
<div class="s-card">
<div class="s-card-head">Popups<a class="s-link" href="/admin/marketing">+ Neu</a></div>
<div class="s-card-head">Popups<a class="s-link" href={base + "/marketing"}>+ Neu</a></div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Titel</th><th>Typ</th><th>Trigger</th><th>Pfad</th><th>Status</th><th></th></tr></thead>
@@ -67,7 +70,7 @@ const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcem
<td class="s-muted">{pp.target_path}</td>
<td>{pp.active ? <span class="s-badge green">Aktiv</span> : <span class="s-badge gray">Inaktiv</span>}</td>
<td class="num">
<a class="s-btn s-btn-sm" href={`/admin/marketing?edit=${pp.id}`}>Bearbeiten</a>
<a class="s-btn s-btn-sm" href={`${base}/marketing?edit=${pp.id}`}>Bearbeiten</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Popup löschen?')"><input type="hidden" name="_action" value="delete-popup" /><input type="hidden" name="id" value={pp.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
</td>
</tr>
@@ -104,7 +107,7 @@ const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcem
<div class="s-field"><label class="s-label">Status</label><label class="s-check" style="margin-top:8px"><input type="checkbox" name="active" checked={!!e.active} /> Aktiv</label></div>
</div>
<button class="s-btn s-btn-primary" type="submit" style="width:100%">{editing ? 'Speichern' : 'Anlegen'}</button>
{editing && <a class="s-btn" href="/admin/marketing" style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
{editing && <a class="s-btn" href={base + "/marketing"} style="width:100%;justify-content:center;margin-top:8px">Abbrechen</a>}
</form>
</div>
</div>
+101
View File
@@ -0,0 +1,101 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listUsers, createUser, updateUserRole, setUserActive, deleteUser, setUserPassword, getUserByEmail, recordAudit } from '../../../lib/store.js';
import { currentUser, adminBase } from '../../../lib/auth.js';
const base = adminBase();
const me = currentUser(Astro.request);
let flash = '', error = '';
if (Astro.request.method === 'POST' && me?.role === 'owner') {
const f = await Astro.request.formData();
const action = f.get('_action');
try {
if (action === 'create') {
const email = String(f.get('email') || '').toLowerCase().trim();
if (getUserByEmail(email)) { error = 'E-Mail bereits vergeben.'; }
else {
const id = createUser({ name: f.get('name') || email, email, password: f.get('password') || '', role: f.get('role') || 'redaktion', active: true });
recordAudit({ user: me.email, action: 'create', entity: 'user', entity_id: String(id) });
return Astro.redirect(base + '/nutzer?saved=1');
}
} else if (action === 'role') {
const id = Number(f.get('id'));
if (id !== me.id) { updateUserRole(id, f.get('role')); recordAudit({ user: me.email, action: 'update', entity: 'user', entity_id: String(id) }); }
return Astro.redirect(base + '/nutzer?saved=1');
} else if (action === 'toggle') {
const id = Number(f.get('id'));
if (id !== me.id) { setUserActive(id, f.get('active') === '1'); recordAudit({ user: me.email, action: 'update', entity: 'user', entity_id: String(id) }); }
return Astro.redirect(base + '/nutzer?saved=1');
} else if (action === 'resetpw') {
const id = Number(f.get('id'));
setUserPassword(id, f.get('password') || 'changeme');
recordAudit({ user: me.email, action: 'password_reset', entity: 'user', entity_id: String(id) });
return Astro.redirect(base + '/nutzer?saved=1');
} else if (action === 'delete') {
const id = Number(f.get('id'));
if (id !== me.id) { deleteUser(id); recordAudit({ user: me.email, action: 'delete', entity: 'user', entity_id: String(id) }); }
return Astro.redirect(base + '/nutzer');
}
} catch (e) { error = String(e && e.message || e); }
}
const users = listUsers();
const roleLabels = { owner: 'Inhaber', redaktion: 'Redaktion', versand: 'Versand' };
const roles = [['owner', 'Inhaber (alles)'], ['redaktion', 'Redaktion (Produkte/Inhalte/Marketing)'], ['versand', 'Versand (nur Bestellungen)']];
const fmtDate = (s) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
---
<Admin title="Nutzer & Zugänge" active="nutzer" crumbs={[{ label: 'Nutzer & Zugänge' }]}>
<div class="s-two-col">
<div class="s-stack">
{error && <div class="login-error">{error}</div>}
<div class="s-card">
<div class="s-card-head">Nutzer ({users.length})</div>
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Name</th><th>Rolle</th><th>Status</th><th>Letzter Login</th><th></th></tr></thead>
<tbody>
{users.map((u) => (
<tr>
<td><b>{u.name}</b><div class="s-muted" style="font-size:12px">{u.email}{u.id === me.id ? ' · du' : ''}</div></td>
<td>
{u.id === me.id ? (<span class="s-badge blue">{roleLabels[u.role]}</span>) : (
<form method="POST" style="display:inline-flex;gap:6px;align-items:center">
<input type="hidden" name="_action" value="role" /><input type="hidden" name="id" value={u.id} />
<select class="s-select" name="role" style="padding:5px 26px 5px 9px;font-size:12px" onchange="this.form.submit()">
{roles.map(([v, l]) => (<option value={v} selected={u.role === v}>{roleLabels[v]}</option>))}
</select>
</form>
)}
</td>
<td>{u.active ? <span class="s-badge green">Aktiv</span> : <span class="s-badge gray">Deaktiviert</span>}</td>
<td class="s-muted">{fmtDate(u.last_login)}</td>
<td class="num">
{u.id !== me.id && (
<form method="POST" style="display:inline"><input type="hidden" name="_action" value="toggle" /><input type="hidden" name="id" value={u.id} /><input type="hidden" name="active" value={u.active ? '0' : '1'} /><button class="s-btn s-btn-sm">{u.active ? 'Deaktivieren' : 'Aktivieren'}</button></form>
)}
{u.id !== me.id && (
<form method="POST" style="display:inline" onsubmit="return confirm('Nutzer wirklich löschen?')"><input type="hidden" name="_action" value="delete" /><input type="hidden" name="id" value={u.id} /><button class="s-btn s-btn-sm s-btn-danger">Löschen</button></form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div class="s-card s-card-pad">
<div class="s-section-title" style="margin-bottom:14px">Nutzer hinzufügen</div>
<form method="POST">
<input type="hidden" name="_action" value="create" />
<div class="s-field"><label class="s-label">Name</label><input class="s-input" name="name" required /></div>
<div class="s-field"><label class="s-label">E-Mail</label><input class="s-input" name="email" type="email" required /></div>
<div class="s-field"><label class="s-label">Rolle</label><select class="s-select" name="role">{roles.map(([v, l]) => (<option value={v}>{l}</option>))}</select></div>
<div class="s-field"><label class="s-label">Initial-Passwort</label><input class="s-input" name="password" type="text" required minlength="6" /></div>
<button class="s-btn s-btn-primary" type="submit" style="width:100%">Nutzer anlegen</button>
</form>
</div>
</div>
</Admin>
+9 -6
View File
@@ -1,6 +1,8 @@
---
import Admin from '../../../layouts/Admin.astro';
import { getProductById, createProduct, updateProduct, listCategories } from '../../../lib/store.js';
import { adminBase, currentUser } from '../../../lib/auth.js';
const base = adminBase();
import { getProductById, createProduct, updateProduct, listCategories, recordAudit } from '../../../lib/store.js';
const { id } = Astro.params;
const isNew = id === 'neu';
@@ -26,18 +28,19 @@ if (Astro.request.method === 'POST') {
sort: parseInt(String(f.get('sort') || '99')) || 99,
desc: String(f.get('desc') || ''),
};
if (isNew) { const newId = createProduct(data); return Astro.redirect(`/admin/produkte/${newId}?saved=1`); }
else { updateProduct(id, data); flash = 'Produkt gespeichert.'; }
const _me = currentUser(Astro.request);
if (isNew) { const newId = createProduct(data); recordAudit({ user: _me?.email, action: 'create', entity: 'product', entity_id: String(newId) }); return Astro.redirect(`${base}/produkte/${newId}?saved=1`); }
else { updateProduct(id, data); recordAudit({ user: _me?.email, action: 'update', entity: 'product', entity_id: String(id) }); flash = 'Produkt gespeichert.'; }
}
const product = isNew ? null : getProductById(id);
if (!isNew && !product) return Astro.redirect('/admin/produkte');
if (!isNew && !product) return Astro.redirect(base + '/produkte');
if (new URL(Astro.request.url).searchParams.get('saved')) flash = 'Produkt angelegt.';
const cats = listCategories();
const p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '' };
const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',') : '';
---
<Admin title={isNew ? 'Neues Produkt' : (product.shortName || product.name)} active="produkte" crumbs={[{ label: 'Produkte', href: '/admin/produkte' }, { label: isNew ? 'Neu' : (product.shortName || product.name) }]}>
<Admin title={isNew ? 'Neues Produkt' : (product.shortName || product.name)} active="produkte" crumbs={[{ label: 'Produkte', href: base + '/produkte' }, { label: isNew ? 'Neu' : (product.shortName || product.name) }]}>
<div class="s-stack">
{flash && <div class="s-flash">✓ {flash}</div>}
<form method="POST" class="s-two-col">
@@ -70,7 +73,7 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
<div class="s-stack">
<div class="s-card s-card-pad">
<button class="s-btn s-btn-primary" type="submit" style="width:100%;margin-bottom:8px">{isNew ? 'Produkt anlegen' : 'Speichern'}</button>
<a class="s-btn" href="/admin/produkte" style="width:100%;justify-content:center">Zurück</a>
<a class="s-btn" href={base + "/produkte"} style="width:100%;justify-content:center">Zurück</a>
</div>
<div class="s-card s-card-pad">
<div class="s-field"><label class="s-label">Preis (€)</label><input class="s-input" name="price" value={priceStr} placeholder="0,00" required /></div>
+8 -6
View File
@@ -1,23 +1,25 @@
---
import Admin from '../../../layouts/Admin.astro';
import { listProducts, deleteProduct, formatPrice } from '../../../lib/store.js';
import { adminBase, currentUser } from '../../../lib/auth.js';
const base = adminBase();
import { listProducts, deleteProduct, formatPrice, recordAudit } from '../../../lib/store.js';
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
if (form.get('_action') === 'delete' && form.get('id')) { deleteProduct(form.get('id')); return Astro.redirect('/admin/produkte'); }
if (form.get('_action') === 'delete' && form.get('id')) { deleteProduct(form.get('id')); return Astro.redirect(base + '/produkte'); }
}
const products = listProducts();
---
<Admin title="Produkte" active="produkte" crumbs={[{ label: 'Produkte' }]}>
<a slot="actions" class="s-btn s-btn-primary" href="/admin/produkte/neu">+ Produkt anlegen</a>
<a slot="actions" class="s-btn s-btn-primary" href={base + "/produkte/neu"}>+ Produkt anlegen</a>
<div class="s-card">
<div class="s-table-wrap">
<table class="s-table">
<thead><tr><th>Produkt</th><th>Kategorie</th><th>Bestand</th><th>Featured</th><th class="num">Preis</th><th></th></tr></thead>
<tbody>
{products.length === 0 ? (<tr><td colspan="6" class="s-empty">Noch keine Produkte</td></tr>) :
{products.length === 0 ? (<tr><td colspan="6"><div class="s-emptystate"><div class="es-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 7.3 12 2 3.5 7.3 12 12.6l8.5-5.3ZM3 9v8l8 5v-8L3 9Zm10 13 8-5V9l-8 5v8Z"/></svg></div><h3>Noch keine Produkte</h3><p>Lege dein erstes Produkt an — Name, Preis und ein Bild genügen für den Start.</p><a class="s-btn s-btn-primary" href={base + "/produkte/neu"}>+ Produkt anlegen</a></div></td></tr>) :
products.map((p) => (
<tr>
<td class="clk" onclick={`location.href='/admin/produkte/${p.id}'`}>
<td class="clk" onclick={`location.href='${base}/produkte/${p.id}'`}>
<div class="s-prodcell">{p.cardImage && <img src={p.cardImage} alt="" />}<div><div class="nm">{p.shortName || p.name}</div>{p.badge && <span class="s-muted" style="font-size:12px">{p.badge}</span>}</div></div>
</td>
<td class="s-muted">{p.category || '—'}</td>
@@ -25,7 +27,7 @@ const products = listProducts();
<td>{p.featured ? <span class="s-badge blue">Ja</span> : <span class="s-muted">—</span>}</td>
<td class="num"><b>{formatPrice(p.priceCents)}</b></td>
<td class="num">
<a class="s-btn s-btn-sm" href={`/admin/produkte/${p.id}`}>Bearbeiten</a>
<a class="s-btn s-btn-sm" href={`${base}/produkte/${p.id}`}>Bearbeiten</a>
<form method="POST" style="display:inline" onsubmit="return confirm('Produkt wirklich löschen?')">
<input type="hidden" name="_action" value="delete" /><input type="hidden" name="id" value={p.id} />
<button class="s-btn s-btn-sm s-btn-danger" type="submit">Löschen</button>
+33
View File
@@ -0,0 +1,33 @@
import { manifest } from '../lib/admin-api.js';
export const prerender = false;
export async function GET({ request }) {
const origin = new URL(request.url).origin;
const m = manifest(origin);
const lines = [];
lines.push('# hd-commerce — KI-Admin-Manifest');
lines.push('# Maschinenlesbare Beschreibung der Admin-API für LLMs/Agenten.');
lines.push('');
lines.push('Auth: ' + m.auth);
lines.push('Base-URL: ' + (m.base_url || origin));
lines.push('Version: ' + m.version);
lines.push('');
lines.push('## Ressourcen');
for (const [name, def] of Object.entries(m.resources)) {
lines.push(`- ${name} (${def.rw ? 'lesen+schreiben' : 'nur lesen'}): ${def.fields.join(', ')}`);
}
lines.push('');
lines.push('## Block-Typen (pages.blocks)');
for (const b of m.block_types) {
lines.push(`- ${b.key} (${b.label}): ${b.fields.map(f => f.name + ':' + f.type).join(', ') || '—'}`);
}
lines.push('');
lines.push('## Endpunkte');
for (const e of m.endpoints) lines.push(`${e.method} ${e.path}${e.desc}`);
lines.push('');
lines.push('## Hinweise');
for (const n of m.notes) lines.push('- ' + n);
lines.push('');
lines.push('JSON-Manifest: GET /api/admin (Bearer-Token erforderlich)');
return new Response(lines.join('\n'), { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
+19
View File
@@ -0,0 +1,19 @@
// Session-geschützter Endpoint zum Speichern der Block-Struktur einer Seite (vom Visual-Builder).
import { updatePageBlocks, getPageById, recordAudit } from '../../lib/store.js';
import { currentUser, canAccess } from '../../lib/auth.js';
export const prerender = false;
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
export async function POST({ request }) {
const user = currentUser(request);
if (!user) return json({ ok: false, error: 'Nicht angemeldet' }, 401);
if (!canAccess(user.role, 'inhalte')) return json({ ok: false, error: 'Keine Berechtigung' }, 403);
let body;
try { body = await request.json(); } catch { return json({ ok: false, error: 'Bad request' }, 400); }
const id = Number(body.id);
if (!id || !getPageById(id)) return json({ ok: false, error: 'Seite nicht gefunden' }, 404);
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
updatePageBlocks(id, blocks);
recordAudit({ user: user.email, action: 'update', entity: 'page_blocks', entity_id: String(id) });
return json({ ok: true, count: blocks.length });
}
+63
View File
@@ -0,0 +1,63 @@
import {
authOk, json, RESOURCES, listResource, getResource, upsertResource, deleteResource, updatePageBlocks, recordAudit,
} from '../../../lib/admin-api.js';
export const prerender = false;
function parse(path) {
const parts = String(path || '').split('/').filter(Boolean);
return { name: parts[0], id: parts[1], sub: parts[2] };
}
export async function GET({ params, request }) {
if (!authOk(request)) return json({ error: 'Unauthorized' }, 401);
const { name, id } = parse(params.path);
if (!RESOURCES[name]) return json({ error: 'Unbekannte Ressource: ' + name }, 404);
if (id) {
const item = getResource(name, id);
if (!item) return json({ error: 'Nicht gefunden' }, 404);
return json({ data: item });
}
return json({ data: listResource(name) });
}
export async function POST({ params, request }) {
if (!authOk(request)) return json({ error: 'Unauthorized' }, 401);
const { name, id, sub } = parse(params.path);
if (!RESOURCES[name]) return json({ error: 'Unbekannte Ressource: ' + name }, 404);
if (!RESOURCES[name].rw) return json({ error: 'Ressource ist nur lesbar' }, 405);
let body;
try { body = await request.json(); } catch { return json({ error: 'Ungültiges JSON' }, 400); }
// Sonderfall: /api/admin/pages/{id}/blocks
if (name === 'pages' && id && sub === 'blocks') {
const blocks = Array.isArray(body) ? body : (Array.isArray(body.blocks) ? body.blocks : null);
if (!blocks) return json({ error: 'Erwarte Array oder { blocks: [...] }' }, 400);
const updated = updatePageBlocks(id, blocks);
if (!updated) return json({ error: 'Seite nicht gefunden' }, 404);
recordAudit({ user: 'api', action: 'update', entity: 'page_blocks', entity_id: String(id) });
return json({ data: updated });
}
try {
if (id && !body.id) body.id = id;
const result = upsertResource(name, body);
recordAudit({ user: 'api', action: body.id ? 'update' : 'create', entity: name, entity_id: String(body.id || (result && result.id) || '') });
return json({ data: result }, body.id ? 200 : 201);
} catch (e) {
return json({ error: String(e && e.message || e) }, 400);
}
}
export async function DELETE({ params, request }) {
if (!authOk(request)) return json({ error: 'Unauthorized' }, 401);
const { name, id } = parse(params.path);
if (!RESOURCES[name]) return json({ error: 'Unbekannte Ressource: ' + name }, 404);
if (!id) return json({ error: 'ID erforderlich' }, 400);
try {
deleteResource(name, id);
recordAudit({ user: 'api', action: 'delete', entity: name, entity_id: String(id) });
return json({ ok: true });
} catch (e) {
return json({ error: String(e && e.message || e) }, 400);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { authOk, json, manifest } from '../../../lib/admin-api.js';
export const prerender = false;
export async function GET({ request }) {
// Manifest ist auch ohne Token lesbar wäre praktisch — wir verlangen es jedoch konsistent.
if (!authOk(request)) return json({ error: 'Unauthorized', hint: 'Authorization: Bearer <HDC_API_TOKEN>' }, 401);
const origin = new URL(request.url).origin;
return json(manifest(origin));
}
+12 -6
View File
@@ -1,16 +1,22 @@
---
import Base from '../../layouts/Base.astro';
import BlockRenderer from '../../components/BlockRenderer.astro';
import { getPageBySlug } from '../../lib/store.js';
const { slug } = Astro.params;
const page = getPageBySlug(slug);
if (!page || !page.active) return Astro.redirect('/');
const hasBlocks = Array.isArray(page.blocks) && page.blocks.length > 0;
---
<Base title={page.title}>
<div class="wrap">
<article class="prose">
<h1>{page.title}</h1>
<div set:html={page.body}></div>
</article>
</div>
{hasBlocks ? (
<BlockRenderer blocks={page.blocks} />
) : (
<div class="wrap">
<article class="prose">
<h1>{page.title}</h1>
<div set:html={page.body}></div>
</article>
</div>
)}
</Base>
+72
View File
@@ -165,3 +165,75 @@
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
@media(max-width:860px){.admin-shell{grid-template-columns:1fr}.s-side{position:static;height:auto}.s-nav{flex-direction:row;flex-wrap:wrap}.s-nav a.active::before{display:none}.s-kpis{grid-template-columns:1fr 1fr}.s-form-grid{grid-template-columns:1fr}.s-two-col{grid-template-columns:1fr}}
/* ===== v2: Login ===== */
.login-body{display:flex;min-height:100vh;align-items:center;justify-content:center;background:
radial-gradient(1200px 600px at 50% -10%, color-mix(in srgb,var(--accent) 9%, transparent), transparent 60%),
var(--s-bg)}
.login-wrap{width:100%;max-width:420px;padding:24px}
.login-card{background:var(--s-surface);border:1px solid var(--s-border);border-radius:18px;padding:34px 32px 26px;box-shadow:var(--s-shadow-pop);text-align:left}
.login-logo{width:52px;height:52px;border-radius:14px;background:var(--accent);color:#fff;display:grid;place-items:center;font-weight:800;font-size:23px;margin:0 auto 16px;box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 6px 18px -8px color-mix(in srgb,var(--accent) 70%, transparent)}
.login-title{font-family:var(--s-display);font-weight:560;font-size:25px;color:var(--s-ink);text-align:center;margin:0;letter-spacing:-.02em}
.login-sub{text-align:center;color:var(--s-subtle);font-size:13.5px;margin:4px 0 22px}
.login-error{background:var(--s-red);color:var(--s-red-t);border:1px solid color-mix(in srgb,var(--s-red-t) 26%, #fff);padding:10px 14px;border-radius:10px;font-weight:600;font-size:13px;margin-bottom:16px;display:flex;align-items:center;gap:8px}
.login-error::before{content:'!';display:inline-grid;place-items:center;width:18px;height:18px;border-radius:50%;background:var(--s-red-t);color:#fff;font-size:12px;font-weight:800;flex:none}
.login-remember{margin:2px 0 18px;font-size:13.5px;color:var(--s-text)}
.login-submit{width:100%;justify-content:center;padding:11px;font-size:14px}
.login-foot{text-align:center;color:var(--s-faint);font-size:11.5px;margin:18px 0 0}
/* ===== v2: Account-Menü + ⌘K-Trigger ===== */
.s-cmdk-trigger{gap:9px;padding:7px 10px}
.s-kbd{font-size:11px;font-weight:700;color:var(--s-subtle);background:var(--s-sunken);border:1px solid var(--s-border);border-radius:6px;padding:1px 6px;letter-spacing:.02em}
.s-account{position:relative}
.s-account-btn{display:flex;align-items:center;gap:9px;background:var(--s-surface);border:1px solid var(--s-border-2);border-radius:var(--s-radius-sm);padding:5px 11px 5px 6px;cursor:pointer;font-family:inherit;transition:background .15s,box-shadow .15s,transform .15s}
.s-account-btn:hover{background:var(--s-bg);box-shadow:var(--s-shadow);transform:translateY(-1px)}
.s-acct-av{width:28px;height:28px;border-radius:8px;background:var(--accent);color:#fff;display:grid;place-items:center;font-weight:800;font-size:13px;flex:none}
.s-acct-meta{display:flex;flex-direction:column;line-height:1.15;text-align:left}
.s-acct-name{font-size:13px;font-weight:600;color:var(--s-ink);max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.s-acct-role{font-size:11px;color:var(--s-faint)}
.s-account-menu{position:absolute;right:0;top:calc(100% + 8px);background:var(--s-surface);border:1px solid var(--s-border);border-radius:12px;box-shadow:var(--s-shadow-pop);padding:6px;min-width:170px;z-index:40;display:flex;flex-direction:column;gap:2px}
.s-account-menu a{padding:9px 12px;border-radius:8px;font-size:13.5px;font-weight:500;color:var(--s-text);transition:background .12s}
.s-account-menu a:hover{background:var(--s-bg);color:var(--s-ink)}
.s-account-menu a.danger{color:var(--s-red-t)}
.s-account-menu a.danger:hover{background:var(--s-red)}
/* ===== v2: Command-Palette ===== */
.s-cmdk{position:fixed;inset:0;z-index:100;display:flex;align-items:flex-start;justify-content:center;padding-top:14vh}
.s-cmdk[hidden]{display:none}
.s-cmdk-backdrop{position:absolute;inset:0;background:rgba(43,38,32,.34);backdrop-filter:blur(2px);animation:fade .15s var(--s-ease)}
.s-cmdk-panel{position:relative;width:100%;max-width:560px;background:var(--s-surface);border:1px solid var(--s-border);border-radius:16px;box-shadow:var(--s-shadow-pop);overflow:hidden;animation:pop .16s var(--s-ease)}
.s-cmdk-input{width:100%;border:none;border-bottom:1px solid var(--s-line-soft);padding:17px 20px;font:inherit;font-size:16px;color:var(--s-ink);background:transparent;outline:none}
.s-cmdk-input::placeholder{color:var(--s-faint)}
.s-cmdk-list{list-style:none;margin:0;padding:8px;max-height:46vh;overflow:auto}
.s-cmdk-item{display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-radius:10px;cursor:pointer;font-size:14px;color:var(--s-text)}
.s-cmdk-item em{font-style:normal;font-size:11px;color:var(--s-faint);text-transform:uppercase;letter-spacing:.05em;font-weight:700}
.s-cmdk-item.active{background:var(--s-acc-l);color:var(--accent-dark)}
.s-cmdk-item.active em{color:color-mix(in srgb,var(--accent-dark) 70%, transparent)}
/* ===== v2: Toasts ===== */
.s-toasts{position:fixed;right:20px;bottom:20px;z-index:120;display:flex;flex-direction:column;gap:10px;align-items:flex-end}
.s-toast{background:var(--s-ink);color:#fff;padding:12px 18px;border-radius:11px;font-size:13.5px;font-weight:600;box-shadow:var(--s-shadow-pop);opacity:0;transform:translateY(8px);transition:opacity .25s var(--s-ease),transform .25s var(--s-ease);max-width:340px}
.s-toast.show{opacity:1;transform:translateY(0)}
.s-toast.ok{background:#2f6b4f}
.s-toast.err{background:var(--s-red-t)}
/* ===== v2: Empty-States, Skeleton, KPI-Trend ===== */
.s-emptystate{text-align:center;padding:54px 24px;display:flex;flex-direction:column;align-items:center;gap:12px}
.s-emptystate .es-icon{width:56px;height:56px;border-radius:16px;background:var(--s-acc-l);color:var(--accent-dark);display:grid;place-items:center}
.s-emptystate .es-icon svg{width:28px;height:28px}
.s-emptystate h3{font-family:var(--s-display);font-weight:560;font-size:18px;color:var(--s-ink);margin:0}
.s-emptystate p{color:var(--s-subtle);font-size:13.5px;margin:0;max-width:360px}
.s-kpi-trend{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
.s-kpi-trend.up{color:var(--s-green-t)}.s-kpi-trend.down{color:var(--s-red-t)}
.s-kpi-spark{height:30px;margin-top:8px}
.s-quick{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.s-quick a{display:flex;align-items:center;gap:11px;padding:14px 16px;border:1px solid var(--s-border);border-radius:12px;background:var(--s-surface);box-shadow:var(--s-shadow);font-weight:600;color:var(--s-ink);transition:transform .15s var(--s-ease),box-shadow .15s}
.s-quick a:hover{transform:translateY(-2px);box-shadow:var(--s-shadow-pop)}
.s-quick a svg{width:20px;height:20px;color:var(--accent)}
.s-feed{display:flex;flex-direction:column}
.s-feed-row{display:flex;align-items:center;gap:12px;padding:11px 22px;border-bottom:1px solid var(--s-line-soft);font-size:13px}
.s-feed-row:last-child{border-bottom:none}
.s-feed-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);flex:none}
.s-feed-row .t{color:var(--s-faint);margin-left:auto;font-size:12px;white-space:nowrap}
@keyframes fade{from{opacity:0}to{opacity:1}}
@keyframes pop{from{opacity:0;transform:translateY(-6px) scale(.98)}to{opacity:1;transform:none}}
+35
View File
@@ -197,3 +197,38 @@ p{margin:0 0 1rem}
.form-grid{grid-template-columns:1fr}
}
@media(max-width:560px){.foot-grid{grid-template-columns:1fr}}
/* ===== v2: Block-Renderer (Visual-Builder-Ausgabe) ===== */
.blk{position:relative}
.blk-h2{text-align:center;margin-bottom:32px}
.blk-hero{padding:84px 0;background:var(--sunken);text-align:center}
.blk-hero.align-left{text-align:left}
.blk-hero.has-img{background-image:linear-gradient(rgba(20,15,10,.42),rgba(20,15,10,.42)),var(--hero-img);background-size:cover;background-position:center;color:#fff}
.blk-hero.has-img h1{color:#fff}
.blk-hero-inner{max-width:760px}
.blk-hero.align-left .blk-hero-inner{margin-left:0}
.blk-hero-sub{font-size:1.15rem;color:inherit;opacity:.92;margin:14px 0 26px}
.blk-hero.has-img .blk-hero-sub{color:#fff}
.blk-rich{padding:8px 0}
.blk-rich .prose{padding:24px 0}
.blk-image{padding:24px 0}
.blk-image .img-narrow{max-width:680px}.blk-image .img-full{max-width:none;padding:0}
.blk-image img{width:100%;border-radius:var(--radius);box-shadow:var(--shadow)}
.blk-cap{text-align:center;color:var(--subtle);font-size:14px;margin-top:10px}
.blk-gallery{padding:24px 0}
.blk-gal-grid{display:grid;gap:14px}
.blk-gal-grid img{width:100%;height:100%;aspect-ratio:1/1;object-fit:cover;border-radius:var(--radius-sm)}
.blk-features{padding:56px 0}
.blk-feat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}
.blk-feat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:26px;box-shadow:var(--shadow)}
.blk-feat h3{margin-bottom:8px}
.blk-feat p{margin:0;color:var(--subtle)}
.blk-products{padding:48px 0}
.blk-cta{padding:48px 0}
.blk-cta-box{background:var(--accent);color:#fff;border-radius:var(--radius);padding:54px 32px;text-align:center}
.blk-cta-box h2{color:#fff}
.blk-cta-box p{opacity:.92;max-width:520px;margin:12px auto 24px}
.blk-cta-box .btn-primary{background:#fff;color:var(--accent)}
.blk-cta-box .btn-primary:hover{background:rgba(255,255,255,.9)}
.blk-html{padding:8px 0}
@media(max-width:760px){.blk-feat-grid{grid-template-columns:1fr}.blk-gal-grid{grid-template-columns:repeat(2,1fr)!important}}