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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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' }) : '—';
|
||||
|
||||
@@ -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>
|
||||
@@ -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() } });
|
||||
---
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user