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
+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>