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:
+19
-3
@@ -1,11 +1,27 @@
|
||||
# hd-commerce — Umgebungsvariablen
|
||||
|
||||
# --- Datenbank ---
|
||||
# Pfad zur SQLite-Datenbank (wird automatisch angelegt)
|
||||
DB_PATH=./data/hdc.db
|
||||
|
||||
# Admin-Zugang (Basic-Auth fuer /admin)
|
||||
ADMIN_USER=admin
|
||||
# --- Admin-Zugang (Session-Login) ---
|
||||
# Initial-Owner, der beim ERSTEN Start angelegt wird (danach im Admin verwaltbar)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASS=admin
|
||||
# Unter welchem Pfad der Admin erreichbar ist (z.B. "admin" -> /admin, "intern" -> /intern)
|
||||
ADMIN_PATH=admin
|
||||
# Geheimnis zum Signieren der Session-Cookies (HMAC). In Produktion UNBEDINGT setzen!
|
||||
SESSION_SECRET=bitte-langes-zufaelliges-geheimnis-setzen
|
||||
|
||||
# Stripe (optional). Ohne echte Keys laeuft der Demo-Checkout-Fallback.
|
||||
# --- KI-/MCP-Admin-API ---
|
||||
# Bearer-Token für /api/admin/* (getrennt von der Session). Leer => API gesperrt.
|
||||
HDC_API_TOKEN=
|
||||
|
||||
# --- Stripe (optional) ---
|
||||
# Ohne echte Keys läuft der Demo-Checkout-Fallback.
|
||||
STRIPE_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# --- MCP-Server (mcp/) ---
|
||||
# HDC_BASE_URL=https://shop.example.com
|
||||
# HDC_API_TOKEN= (dasselbe Token wie oben)
|
||||
|
||||
@@ -5,3 +5,4 @@ data
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
public/styles/global.css
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
# hd-commerce
|
||||
|
||||
**hd-commerce** ist ein eigenständiges, brand-neutrales E-Commerce-Backend von Heidrich Digital: eine wiederverwendbare Astro-SSR-Anwendung mit Commerce-Engine (SQLite), Shopify-artigem Admin, JSON-API und einem schlanken, neutralen Demo-Storefront.
|
||||
**hd-commerce** ist ein eigenständiges, brand-neutrales E-Commerce-Backend von Heidrich Digital: eine wiederverwendbare Astro-SSR-Anwendung mit Commerce-Engine (SQLite), Session-gesichertem Admin, Visual-Block-Builder, KI-/MCP-Editierbarkeit, JSON-API und einem schlanken, neutralen Demo-Storefront.
|
||||
|
||||
Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähbedarf) und dient nur als Beispiel. Name, Akzentfarbe, Texte und Logo-Wortmarke sind über die Einstellungen frei anpassbar — derselbe Code läuft für beliebige Shops.
|
||||
|
||||
## Features
|
||||
|
||||
- **Storefront** (hell, editorial, neutral): Startseite mit Announcement-Bar, Slider, Kategorien, Featured-Produkten und Newsletter; Shop-Katalog mit Kategorie-Filter; Produktdetailseiten; Warenkorb (localStorage); Checkout; Rechts-/Inhaltsseiten aus der DB.
|
||||
- **Admin** (Shopify-Stil): Dashboard mit KPIs & Funnel, Bestellungen mit Status-Workflow, Produkt-Editor (CRUD), Kunden, eigene Analytics (First-Party, Chart.js), Marketing (Popups & Announcement-Bar), Inhalte (Seiten, Slider, Medien-Upload) und Einstellungen.
|
||||
- **Storefront** (hell, editorial, neutral): Startseite mit Announcement-Bar, Slider, Kategorien, Featured-Produkten und Newsletter; Shop-Katalog; Produktdetailseiten; Warenkorb (localStorage); Checkout; Inhaltsseiten aus der DB — wahlweise klassisch oder über den **Block-Builder** gestaltet.
|
||||
- **Admin** (Premium, „Warmth & Approachability"): Session-Login statt Browser-Basic-Auth, Rollen (Owner/Redaktion/Versand), Command-Palette (⌘K), Toasts, aufgewertetes Dashboard mit KPI-Trends, Sparkline, Aktivitäts-Feed und Schnellaktionen.
|
||||
- **Visual-Block-Builder**: Vollbild-Editor mit Block-Liste (Drag/▲▼/duplizieren/löschen), Live-Vorschau (Desktop/Mobil) und Block-Einstellungen. Block-Typen: Hero, Rich-Text, Bild, Galerie, Slider, Feature-Grid, Produkt-Grid, CTA-Banner, Abstand, Roh-HTML.
|
||||
- **KI-Editierbarkeit**: token-gesicherte Admin-JSON-API (`/api/admin/*`) plus maschinenlesbares Manifest (`/api/admin`, `/ai-admin.txt`) und ein **MCP-Server** (`mcp/`).
|
||||
- **Engine**: synchron via `better-sqlite3` (WAL), automatisches Seeding beim ersten Start.
|
||||
- **API**: `/api/checkout` (Stripe Hosted Checkout oder Demo-Fallback), `/api/track`, `/api/subscribe`, `/api/upload`, `/uploads/[file]`.
|
||||
- **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst, keine personenbezogenen Rohdaten (Session = täglich rollender Hash aus IP+UA+Tag).
|
||||
- **Branding konfigurierbar**: Shop-Name, Akzentfarbe, Währung u. a. in einer `settings`-Tabelle; Akzentfarbe wird als CSS-Variable in Storefront **und** Admin injiziert.
|
||||
- **Self-hosted Fonts** (Fraunces + Public Sans), kein Google-CDN.
|
||||
- **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst (Session = täglich rollender Hash).
|
||||
- **Branding konfigurierbar**: Shop-Name, Akzentfarbe, Währung u. a. in einer `settings`-Tabelle.
|
||||
- **Self-hosted Fonts** (Fraunces + Public Sans), kein Google-CDN. Chart.js via cdnjs.
|
||||
|
||||
## Authentifizierung & Rollen
|
||||
|
||||
- **Session-Login** per HTML-Formular (signiertes HMAC-Cookie, „Angemeldet bleiben" = 30 Tage). Passwörter werden mit `node:crypto.scryptSync` + zufälligem Salt gehasht.
|
||||
- **Initial-Owner** wird beim ersten Boot aus `ADMIN_EMAIL` / `ADMIN_PASS` angelegt; weitere Nutzer im Admin unter **Nutzer & Zugänge** (Owner-only).
|
||||
- **Rollen**: `owner` (alles), `redaktion` (Produkte/Inhalte/Marketing/Analytics), `versand` (nur Bestellungen). Navigation und Seiten werden serverseitig nach Rolle gegated.
|
||||
- **Konfigurierbarer Admin-Pfad** über `ADMIN_PATH` (Default `admin`, z. B. `intern` → Admin unter `/intern`). Direkter Zugriff auf `/admin` wird bei abweichendem Pfad mit 404 blockiert.
|
||||
- **Audit-Log** (Tabelle `audit`) protokolliert Create/Update/Delete; Ansicht unter **Aktivität (Audit)** (Owner). Login-Rate-Limit: nach 5 Fehlversuchen 60 s Sperre pro IP.
|
||||
|
||||
## Umgebungsvariablen (ENV)
|
||||
|
||||
| Variable | Beschreibung | Default |
|
||||
|---|---|---|
|
||||
| `DB_PATH` | Pfad zur SQLite-Datenbank (wird angelegt) | `./data/hdc.db` |
|
||||
| `ADMIN_USER` | Basic-Auth-Benutzer für `/admin` | `admin` |
|
||||
| `ADMIN_PASS` | Basic-Auth-Passwort für `/admin` | `admin` |
|
||||
| `ADMIN_EMAIL` | Initial-Owner-E-Mail (erster Boot) | `admin@example.com` |
|
||||
| `ADMIN_PASS` | Initial-Owner-Passwort (erster Boot) | `admin` |
|
||||
| `ADMIN_PATH` | Pfad-Segment des Admin-Bereichs | `admin` |
|
||||
| `SESSION_SECRET` | HMAC-Geheimnis für Session-Cookies | interner Fallback (in Prod setzen!) |
|
||||
| `HDC_API_TOKEN` | Bearer-Token für `/api/admin/*`. Leer ⇒ API gesperrt | – |
|
||||
| `STRIPE_PUBLIC_KEY` | Stripe Publishable Key (optional) | – |
|
||||
| `STRIPE_SECRET_KEY` | Stripe Secret Key (`sk_test_…`/`sk_live_…`). Ohne echten Key läuft der Demo-Checkout. | – |
|
||||
| `STRIPE_SECRET_KEY` | Stripe Secret Key. Ohne echten Key läuft der Demo-Checkout. | – |
|
||||
|
||||
Siehe `.env.example`.
|
||||
|
||||
@@ -30,27 +42,55 @@ Siehe `.env.example`.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Entwicklungsserver auf http://localhost:4321
|
||||
npm run dev # http://localhost:4321
|
||||
# oder produktiv:
|
||||
npm run build
|
||||
node ./dist/server/entry.mjs
|
||||
```
|
||||
|
||||
Storefront: `/` · Admin: `/admin` (Standard `admin` / `admin`).
|
||||
Storefront: `/` · Admin: `/admin` (bzw. `/${ADMIN_PATH}`). Erst-Login mit `ADMIN_EMAIL` / `ADMIN_PASS`.
|
||||
|
||||
## Block-Builder
|
||||
|
||||
Jede Seite (`pages`) hat ein Feld `blocks` (JSON-Array). Der Vollbild-Editor liegt unter `/${ADMIN_PATH}/inhalte/editor/<id>` (Button „Editor" in der Seitenliste). Gespeicherte Blöcke werden vom **Storefront-Block-Renderer** (`src/components/BlockRenderer.astro`) auf `/seite/<slug>` ausgegeben.
|
||||
|
||||
## KI-Editierbarkeit (API)
|
||||
|
||||
Token-gesicherte JSON-API unter `/api/admin/*` (Header `Authorization: Bearer <HDC_API_TOKEN>`):
|
||||
|
||||
- `GET /api/admin` — maschinenlesbares Manifest (Ressourcen, Felder, Block-Typen, Endpunkte).
|
||||
- `GET /ai-admin.txt` — dieselbe Beschreibung als Klartext für LLMs.
|
||||
- `GET /api/admin/{resource}` · `GET /api/admin/{resource}/{id}` — lesen.
|
||||
- `POST /api/admin/{resource}` — Upsert (mit `id` oder `slug` ⇒ Update, sonst Create).
|
||||
- `DELETE /api/admin/{resource}/{id}` — löschen.
|
||||
- `POST /api/admin/pages/{id}/blocks` — Block-Array einer Seite setzen.
|
||||
|
||||
Schreibbar: `products`, `pages`, `slides`, `popups`, `settings`. Nur lesbar: `orders`, `customers`. Preise in Cent.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $HDC_API_TOKEN" https://shop.example.com/api/admin/products
|
||||
curl -H "Authorization: Bearer $HDC_API_TOKEN" -X POST https://shop.example.com/api/admin/products \
|
||||
-H 'Content-Type: application/json' -d '{"name":"Neues Produkt","priceCents":1990,"category":"Test"}'
|
||||
```
|
||||
|
||||
## MCP-Server
|
||||
|
||||
Unter `mcp/` liegt ein eigenständiger **Model-Context-Protocol-Server** (stdio), mit dem ein LLM/Agent den Shop über die Admin-API bearbeitet. Tools u. a.: `list_products`, `upsert_product`, `get_page`, `update_page_blocks`, `list_orders`, `update_settings`, `create_page`. Installation, ENV (`HDC_BASE_URL`, `HDC_API_TOKEN`) und Registrierung in Claude/Cowork: siehe [`mcp/README.md`](mcp/README.md).
|
||||
|
||||
## Docker / Coolify
|
||||
|
||||
```bash
|
||||
docker build -t hd-commerce .
|
||||
docker run -p 4321:4321 -v hdc-data:/data \
|
||||
-e ADMIN_USER=admin -e ADMIN_PASS=geheim hd-commerce
|
||||
-e ADMIN_EMAIL=admin@example.com -e ADMIN_PASS=geheim \
|
||||
-e SESSION_SECRET=langes-geheimnis -e HDC_API_TOKEN=token hd-commerce
|
||||
```
|
||||
|
||||
Das `Dockerfile` (node:22-slim) baut `better-sqlite3` nativ, legt `/data` an und setzt `DB_PATH=/data/hdc.db`. Auf Coolify ein persistentes Volume auf `/data` mounten, damit Datenbank und Uploads erhalten bleiben. HEALTHCHECK prüft `/`.
|
||||
Das `Dockerfile` (node:22-slim) baut `better-sqlite3` nativ, legt `/data` an und setzt `DB_PATH=/data/hdc.db`. Auf Coolify ein persistentes Volume auf `/data` mounten. HEALTHCHECK prüft `/`.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
`settings`, `products`, `orders`, `customers`, `slides`, `pages`, `popups`, `subscribers`, `events`, `media` — alles seed-bar und im Admin pflegbar.
|
||||
`settings`, `products`, `orders`, `customers`, `slides`, `pages` (inkl. `blocks`), `popups`, `subscribers`, `events`, `media`, `users`, `audit` — alles seed-bar und im Admin pflegbar.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,64 @@
|
||||
# hd-commerce — MCP-Server
|
||||
|
||||
Ein [Model Context Protocol](https://modelcontextprotocol.io)-Server (stdio), mit dem ein LLM/Agent
|
||||
einen hd-commerce-Shop bearbeiten kann. Der Server ruft die token-gesicherte Admin-API
|
||||
(`/api/admin/*`) des Shops auf.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Laufende hd-commerce-Instanz mit gesetztem `HDC_API_TOKEN`.
|
||||
- Node 18+ (für globales `fetch`).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd mcp
|
||||
npm install
|
||||
```
|
||||
|
||||
## Konfiguration (ENV)
|
||||
|
||||
| Variable | Beschreibung | Beispiel |
|
||||
|---|---|---|
|
||||
| `HDC_BASE_URL` | Basis-URL des Shops | `https://shop.example.com` |
|
||||
| `HDC_API_TOKEN` | gleiches Token wie in der Shop-Instanz | `geheimes-token` |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Zweck |
|
||||
|---|---|
|
||||
| `list_products` / `get_product` / `upsert_product` / `delete_product` | Produkte verwalten |
|
||||
| `list_pages` / `get_page` / `create_page` | Seiten lesen/anlegen |
|
||||
| `update_page_blocks` | Block-Struktur einer Seite (Visual-Builder) setzen |
|
||||
| `list_slides` / `upsert_slide` | Slider verwalten |
|
||||
| `list_orders` | Bestellungen lesen |
|
||||
| `get_settings` / `update_settings` | Shop-Einstellungen (Name, Farben …) |
|
||||
| `get_manifest` | Vollständiges API-Manifest |
|
||||
|
||||
## In Claude Desktop / Cowork registrieren
|
||||
|
||||
`claude_desktop_config.json` (bzw. die MCP-Konfiguration):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hd-commerce": {
|
||||
"command": "node",
|
||||
"args": ["/absoluter/pfad/zu/hd-commerce/mcp/server.js"],
|
||||
"env": {
|
||||
"HDC_BASE_URL": "https://shop.example.com",
|
||||
"HDC_API_TOKEN": "geheimes-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Danach den Client neu starten. Der Server meldet sich als `hd-commerce` mit obigen Tools.
|
||||
|
||||
## Schneller Start-Check
|
||||
|
||||
```bash
|
||||
HDC_BASE_URL=http://localhost:4321 HDC_API_TOKEN=testtoken node server.js
|
||||
# Gibt auf stderr "bereit" aus und wartet auf stdio-Anfragen (mit Ctrl-C beenden).
|
||||
```
|
||||
Generated
+1171
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "hd-commerce-mcp",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MCP-Server für hd-commerce — bearbeitet Produkte, Seiten, Slides, Popups & Einstellungen über die Admin-API.",
|
||||
"bin": { "hd-commerce-mcp": "./server.js" },
|
||||
"scripts": { "start": "node server.js" },
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
// hd-commerce MCP-Server (stdio).
|
||||
// Spricht die token-gesicherte Admin-API (/api/admin) an.
|
||||
// ENV: HDC_BASE_URL (z.B. https://shop.example.com), HDC_API_TOKEN.
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const BASE = (process.env.HDC_BASE_URL || 'http://localhost:4321').replace(/\/+$/, '');
|
||||
const TOKEN = process.env.HDC_API_TOKEN || '';
|
||||
|
||||
async function api(method, path, body) {
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers: { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data; try { data = JSON.parse(text); } catch { data = text; }
|
||||
if (!res.ok) throw new Error('API ' + res.status + ': ' + (data && data.error ? data.error : text));
|
||||
return data;
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{ name: 'list_products', description: 'Alle Produkte auflisten.', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'get_product', description: 'Ein Produkt per ID oder Slug holen.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Produkt-ID' } }, required: ['id'] } },
|
||||
{ name: 'upsert_product', description: 'Produkt anlegen/aktualisieren. Mit id oder slug => Update, sonst Create. Preis in Cent (priceCents).', inputSchema: { type: 'object', properties: { product: { type: 'object', description: 'Produktfelder: name, slug, priceCents, category, desc, cardImage, images[], stock, featured, badge, sizes[], features[]' } }, required: ['product'] } },
|
||||
{ name: 'delete_product', description: 'Produkt löschen (per ID).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
||||
{ name: 'list_pages', description: 'Alle Seiten auflisten.', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'get_page', description: 'Seite per ID oder Slug (inkl. blocks).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
||||
{ name: 'create_page', description: 'Neue Seite anlegen (slug, title, body, type, blocks[]).', inputSchema: { type: 'object', properties: { page: { type: 'object' } }, required: ['page'] } },
|
||||
{ name: 'update_page_blocks', description: 'Block-Array einer Seite setzen (Visual-Builder-Struktur).', inputSchema: { type: 'object', properties: { id: { type: 'string' }, blocks: { type: 'array' } }, required: ['id', 'blocks'] } },
|
||||
{ name: 'list_orders', description: 'Bestellungen auflisten (nur lesen).', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'list_slides', description: 'Slider-Slides auflisten.', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'upsert_slide', description: 'Slide anlegen/aktualisieren.', inputSchema: { type: 'object', properties: { slide: { type: 'object' } }, required: ['slide'] } },
|
||||
{ name: 'get_settings', description: 'Shop-Einstellungen (Key/Value) holen.', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'update_settings', description: 'Shop-Einstellungen aktualisieren (Key/Value-Map, z.B. shop_name, brand_accent).', inputSchema: { type: 'object', properties: { settings: { type: 'object' } }, required: ['settings'] } },
|
||||
{ name: 'get_manifest', description: 'API-Manifest (alle Ressourcen, Felder, Block-Typen).', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
const server = new Server({ name: 'hd-commerce', version: '2.0.0' }, { capabilities: { tools: {} } });
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
||||
const { name, arguments: a = {} } = req.params;
|
||||
let out;
|
||||
try {
|
||||
switch (name) {
|
||||
case 'list_products': out = await api('GET', '/api/admin/products'); break;
|
||||
case 'get_product': out = await api('GET', '/api/admin/products/' + encodeURIComponent(a.id)); break;
|
||||
case 'upsert_product': out = await api('POST', '/api/admin/products', a.product); break;
|
||||
case 'delete_product': out = await api('DELETE', '/api/admin/products/' + encodeURIComponent(a.id)); break;
|
||||
case 'list_pages': out = await api('GET', '/api/admin/pages'); break;
|
||||
case 'get_page': out = await api('GET', '/api/admin/pages/' + encodeURIComponent(a.id)); break;
|
||||
case 'create_page': out = await api('POST', '/api/admin/pages', a.page); break;
|
||||
case 'update_page_blocks': out = await api('POST', '/api/admin/pages/' + encodeURIComponent(a.id) + '/blocks', { blocks: a.blocks }); break;
|
||||
case 'list_orders': out = await api('GET', '/api/admin/orders'); break;
|
||||
case 'list_slides': out = await api('GET', '/api/admin/slides'); break;
|
||||
case 'upsert_slide': out = await api('POST', '/api/admin/slides', a.slide); break;
|
||||
case 'get_settings': out = await api('GET', '/api/admin/settings'); break;
|
||||
case 'update_settings': out = await api('POST', '/api/admin/settings', a.settings); break;
|
||||
case 'get_manifest': out = await api('GET', '/api/admin'); break;
|
||||
default: throw new Error('Unbekanntes Tool: ' + name);
|
||||
}
|
||||
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: 'Fehler: ' + (e && e.message || e) }], isError: true };
|
||||
}
|
||||
});
|
||||
|
||||
async function main() {
|
||||
if (!TOKEN) console.error('[hd-commerce-mcp] Warnung: HDC_API_TOKEN ist nicht gesetzt.');
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('[hd-commerce-mcp] bereit. Base: ' + BASE);
|
||||
}
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Generated
+6391
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -7,7 +7,8 @@
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"start": "node ./dist/server/entry.mjs"
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"prebuild": "node ./scripts/sync-css.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.6.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Hält public/styles/global.css mit src/styles/global.css synchron (für die Editor-iframe-Vorschau).
|
||||
import { copyFileSync, mkdirSync } from 'node:fs';
|
||||
try {
|
||||
mkdirSync('./public/styles', { recursive: true });
|
||||
copyFileSync('./src/styles/global.css', './public/styles/global.css');
|
||||
console.log('[sync-css] public/styles/global.css aktualisiert');
|
||||
} catch (e) { console.warn('[sync-css] übersprungen:', e.message); }
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
import { listFeatured, listProducts, listActiveSlides, formatPrice } from '../lib/store.js';
|
||||
|
||||
export interface Props { blocks?: any[] }
|
||||
const { blocks = [] } = Astro.props;
|
||||
const list = Array.isArray(blocks) ? blocks : [];
|
||||
|
||||
const slides = listActiveSlides();
|
||||
function productsFor(b) {
|
||||
const limit = Math.max(1, Math.min(12, Number(b.limit) || 4));
|
||||
let items = [];
|
||||
if (b.source === 'all') items = listProducts();
|
||||
else if (b.source === 'category' && b.category) items = listProducts().filter(p => p.category === b.category);
|
||||
else items = listFeatured();
|
||||
if (!items.length) items = listFeatured();
|
||||
return items.slice(0, limit);
|
||||
}
|
||||
const spacerPx = { small: 28, medium: 56, large: 96 };
|
||||
const galCols = (b) => Math.max(2, Math.min(4, Number(b.columns) || 3));
|
||||
---
|
||||
{list.map((b) => (
|
||||
<>
|
||||
{b.type === 'hero' && (
|
||||
<section class={`blk blk-hero ${b.image ? 'has-img' : ''} align-${b.align || 'center'}`} style={b.image ? `--hero-img:url('${b.image}')` : ''}>
|
||||
<div class="wrap blk-hero-inner">
|
||||
{b.headline && <h1>{b.headline}</h1>}
|
||||
{b.subline && <p class="blk-hero-sub">{b.subline}</p>}
|
||||
{b.cta_text && <a class="btn btn-primary btn-lg" href={b.cta_url || '#'}>{b.cta_text}</a>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{b.type === 'richtext' && (
|
||||
<section class="blk blk-rich"><div class="wrap prose" set:html={b.html || ''}></div></section>
|
||||
)}
|
||||
|
||||
{b.type === 'image' && (
|
||||
<section class="blk blk-image">
|
||||
<div class={`wrap img-${b.width || 'wide'}`}>
|
||||
{b.image && <img src={b.image} alt={b.caption || ''} loading="lazy" />}
|
||||
{b.caption && <p class="blk-cap">{b.caption}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{b.type === 'gallery' && (
|
||||
<section class="blk blk-gallery"><div class="wrap">
|
||||
<div class="blk-gal-grid" style={`grid-template-columns:repeat(${galCols(b)},1fr)`}>
|
||||
{(b.images || []).map((src) => (<img src={src} alt="" loading="lazy" />))}
|
||||
</div>
|
||||
</div></section>
|
||||
)}
|
||||
|
||||
{b.type === 'slider' && slides.length > 0 && (
|
||||
<section class="blk blk-sliderref"><div class="wrap">
|
||||
<div class="slider" id="hdcSlider">
|
||||
<div class="slides" id="hdcSlides">
|
||||
{slides.map((s) => (
|
||||
<div class="slide">
|
||||
{s.image && <img src={s.image} alt={s.headline} loading="lazy" />}
|
||||
<div class="slide-cap"><h2>{s.headline}</h2>{s.subline && <p>{s.subline}</p>}{s.link && <a class="btn btn-primary btn-lg" href={s.link}>Jetzt entdecken</a>}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
)}
|
||||
|
||||
{b.type === 'features' && (
|
||||
<section class="blk blk-features"><div class="wrap">
|
||||
{b.headline && <h2 class="blk-h2">{b.headline}</h2>}
|
||||
<div class="blk-feat-grid">
|
||||
{(b.items || []).map((it) => (
|
||||
<div class="blk-feat"><h3>{it.title}</h3><p>{it.text}</p></div>
|
||||
))}
|
||||
</div>
|
||||
</div></section>
|
||||
)}
|
||||
|
||||
{b.type === 'productgrid' && (
|
||||
<section class="blk blk-products"><div class="wrap">
|
||||
{b.headline && <h2 class="blk-h2">{b.headline}</h2>}
|
||||
<div class="prod-grid">
|
||||
{productsFor(b).map((p) => (
|
||||
<a class="prod-card" href={`/produkt/${p.slug}`}>
|
||||
<div class="prod-media">{p.cardImage && <img src={p.cardImage} alt={p.name} loading="lazy" />}{p.badge && <span class="prod-badge">{p.badge}</span>}</div>
|
||||
<div class="prod-info"><span class="prod-cat">{p.category}</span><span class="prod-name">{p.shortName || p.name}</span><span class="prod-price">{formatPrice(p.priceCents)}</span></div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div></section>
|
||||
)}
|
||||
|
||||
{b.type === 'cta' && (
|
||||
<section class="blk blk-cta"><div class="wrap"><div class="blk-cta-box">
|
||||
{b.headline && <h2>{b.headline}</h2>}
|
||||
{b.text && <p>{b.text}</p>}
|
||||
{b.cta_text && <a class="btn btn-primary btn-lg" href={b.cta_url || '#'}>{b.cta_text}</a>}
|
||||
</div></div></section>
|
||||
)}
|
||||
|
||||
{b.type === 'spacer' && (<div class="blk-spacer" style={`height:${spacerPx[b.size] || 56}px`}></div>)}
|
||||
|
||||
{b.type === 'html' && (<section class="blk blk-html"><div set:html={b.code || ''}></div></section>)}
|
||||
</>
|
||||
))}
|
||||
+134
-11
@@ -3,6 +3,7 @@ import '@fontsource-variable/public-sans';
|
||||
import '@fontsource-variable/fraunces';
|
||||
import '../styles/admin.css';
|
||||
import { getSettings } from '../lib/store.js';
|
||||
import { currentUser, adminBase, allowedSections } from '../lib/auth.js';
|
||||
|
||||
export interface Props { title: string; active?: string; crumbs?: { label: string; href?: string }[]; }
|
||||
const { title, active = '', crumbs = [] } = Astro.props;
|
||||
@@ -13,16 +14,40 @@ const accent = settings.brand_accent || '#b8566a';
|
||||
const accentDark = settings.brand_accent_dark || '#8d3f50';
|
||||
const initial = (shopName.trim()[0] || 'H').toUpperCase();
|
||||
|
||||
const nav = [
|
||||
{ key:'dashboard', label:'Dashboard', href:'/admin', icon:'M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z' },
|
||||
{ key:'bestellungen', label:'Bestellungen', href:'/admin/bestellungen', icon:'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4H6Zm.5 2h11l1.5 2H5l1.5-2ZM5 8h14v12H5V8Zm4 2a3 3 0 0 0 6 0' },
|
||||
{ key:'produkte', label:'Produkte', href:'/admin/produkte', icon:'M20.5 7.3 12 2 3.5 7.3 12 12.6l8.5-5.3ZM3 9v8l8 5v-8L3 9Zm10 13 8-5V9l-8 5v8Z' },
|
||||
{ key:'kunden', label:'Kunden', href:'/admin/kunden', icon:'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-8 2-8 5v1h16v-1c0-3-4-5-8-5Z' },
|
||||
{ key:'analytics', label:'Analytics', href:'/admin/analytics', icon:'M4 20V10m6 10V4m6 16v-7m4 7H2' },
|
||||
{ key:'marketing', label:'Marketing', href:'/admin/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' },
|
||||
{ key:'inhalte', label:'Inhalte', href:'/admin/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' },
|
||||
{ key:'einstellungen', label:'Einstellungen', href:'/admin/einstellungen', icon:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 4-2 .5.4 2-1.7 1.3-1.7-1.2-1.8.8-.3 2H10l-.3-2-1.8-.8-1.7 1.2L4.5 14.5 5 12.5 3 12l.5-2 2-.5-.4-2L6.8 6.2l1.7 1.2 1.8-.8.3-2h2.8l.3 2 1.8.8 1.7-1.2 1.7 1.3-.4 2 2 .5-.5 2Z' },
|
||||
const base = adminBase();
|
||||
const user = currentUser(Astro.request);
|
||||
const role = user?.role || 'owner';
|
||||
const sections = allowedSections(role);
|
||||
const roleLabel = { owner: 'Inhaber', redaktion: 'Redaktion', versand: 'Versand' }[role] || role;
|
||||
const userInitial = (user?.name?.trim()?.[0] || user?.email?.trim()?.[0] || 'A').toUpperCase();
|
||||
|
||||
const allNav = [
|
||||
{ key:'dashboard', label:'Dashboard', href: base, icon:'M3 13h8V3H3v10Zm0 8h8v-6H3v6Zm10 0h8V11h-8v10Zm0-18v6h8V3h-8Z' },
|
||||
{ key:'bestellungen', label:'Bestellungen', href: base + '/bestellungen', icon:'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4H6Zm.5 2h11l1.5 2H5l1.5-2ZM5 8h14v12H5V8Zm4 2a3 3 0 0 0 6 0' },
|
||||
{ key:'produkte', label:'Produkte', href: base + '/produkte', icon:'M20.5 7.3 12 2 3.5 7.3 12 12.6l8.5-5.3ZM3 9v8l8 5v-8L3 9Zm10 13 8-5V9l-8 5v8Z' },
|
||||
{ key:'kunden', label:'Kunden', href: base + '/kunden', icon:'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0 2c-4 0-8 2-8 5v1h16v-1c0-3-4-5-8-5Z' },
|
||||
{ key:'analytics', label:'Analytics', href: base + '/analytics', icon:'M4 20V10m6 10V4m6 16v-7m4 7H2' },
|
||||
{ key:'marketing', label:'Marketing', href: base + '/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' },
|
||||
{ key:'inhalte', label:'Inhalte', href: base + '/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' },
|
||||
];
|
||||
const ownerNav = [
|
||||
{ key:'nutzer', label:'Nutzer & Zugänge', href: base + '/nutzer', icon:'M16 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 2c-3 0-6 1.5-6 4v1h7M16 13c-3.3 0-6 1.7-6 4.2V19h12v-1.8c0-2.5-2.7-4.2-6-4.2Z' },
|
||||
{ key:'audit', label:'Aktivität (Audit)', href: base + '/audit', icon:'M12 8v4l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z' },
|
||||
{ key:'einstellungen', label:'Einstellungen', href: base + '/einstellungen', icon:'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm9 4-2 .5.4 2-1.7 1.3-1.7-1.2-1.8.8-.3 2H10l-.3-2-1.8-.8-1.7 1.2L4.5 14.5 5 12.5 3 12l.5-2 2-.5-.4-2L6.8 6.2l1.7 1.2 1.8-.8.3-2h2.8l.3 2 1.8.8 1.7-1.2 1.7 1.3-.4 2 2 .5-.5 2Z' },
|
||||
];
|
||||
const nav = allNav.filter(n => sections.includes(n.key));
|
||||
const ownerItems = ownerNav.filter(n => sections.includes(n.key));
|
||||
|
||||
// Command-Palette-Items (nur erlaubte)
|
||||
const paletteItems = [
|
||||
...nav.map(n => ({ label: n.label, href: n.href, kind: 'Navigation' })),
|
||||
...ownerItems.map(n => ({ label: n.label, href: n.href, kind: 'Navigation' })),
|
||||
];
|
||||
if (sections.includes('produkte')) paletteItems.push({ label: 'Neues Produkt anlegen', href: base + '/produkte/neu', kind: 'Aktion' });
|
||||
if (sections.includes('inhalte')) paletteItems.push({ label: 'Neue Seite anlegen', href: base + '/inhalte?tab=pages&new=1', kind: 'Aktion' });
|
||||
paletteItems.push({ label: 'Shop ansehen', href: '/', kind: 'Aktion' });
|
||||
paletteItems.push({ label: 'Abmelden', href: base + '/logout', kind: 'Aktion' });
|
||||
const paletteJson = JSON.stringify(paletteItems);
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
@@ -47,6 +72,13 @@ const nav = [
|
||||
{n.label}
|
||||
</a>
|
||||
))}
|
||||
{ownerItems.length > 0 && <div class="s-nav-sec">Verwaltung</div>}
|
||||
{ownerItems.map((n) => (
|
||||
<a href={n.href} class={active === n.key ? 'active' : ''}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={n.icon}/></svg>
|
||||
{n.label}
|
||||
</a>
|
||||
))}
|
||||
<div class="s-nav-sec">Vertriebskanal</div>
|
||||
<a href="/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
|
||||
@@ -60,15 +92,106 @@ const nav = [
|
||||
<header class="s-topbar">
|
||||
<div>
|
||||
<div class="s-crumbs">
|
||||
<a href="/admin">Admin</a>
|
||||
<a href={base}>Admin</a>
|
||||
{crumbs.map((c) => (<><span>/</span>{c.href ? <a href={c.href}>{c.label}</a> : <span>{c.label}</span>}</>))}
|
||||
</div>
|
||||
<div class="s-title">{title}</div>
|
||||
</div>
|
||||
<div class="s-actions"><slot name="actions" /></div>
|
||||
<div class="s-actions">
|
||||
<slot name="actions" />
|
||||
<button type="button" class="s-btn s-cmdk-trigger" id="cmdkOpen" title="Befehle (⌘K)">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<span class="s-kbd">⌘K</span>
|
||||
</button>
|
||||
<div class="s-account">
|
||||
<button type="button" class="s-account-btn" id="acctBtn">
|
||||
<span class="s-acct-av">{userInitial}</span>
|
||||
<span class="s-acct-meta"><span class="s-acct-name">{user?.name || user?.email || 'Konto'}</span><span class="s-acct-role">{roleLabel}</span></span>
|
||||
</button>
|
||||
<div class="s-account-menu" id="acctMenu" hidden>
|
||||
<a href={base + '/konto'}>Mein Konto</a>
|
||||
<a href={base + '/logout'} class="danger">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="s-content"><slot /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command Palette -->
|
||||
<div class="s-cmdk" id="cmdk" hidden>
|
||||
<div class="s-cmdk-backdrop" data-close></div>
|
||||
<div class="s-cmdk-panel" role="dialog" aria-label="Befehle">
|
||||
<input type="text" class="s-cmdk-input" id="cmdkInput" placeholder="Suchen oder Aktion wählen …" autocomplete="off" />
|
||||
<ul class="s-cmdk-list" id="cmdkList"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toasts -->
|
||||
<div class="s-toasts" id="toasts" aria-live="polite"></div>
|
||||
|
||||
<script is:inline define:vars={{ paletteJson }}>
|
||||
(function () {
|
||||
// Toasts (global)
|
||||
window.hdcToast = function (msg, kind) {
|
||||
var c = document.getElementById('toasts'); if (!c) return;
|
||||
var t = document.createElement('div');
|
||||
t.className = 's-toast ' + (kind || 'ok');
|
||||
t.textContent = msg;
|
||||
c.appendChild(t);
|
||||
requestAnimationFrame(function () { t.classList.add('show'); });
|
||||
setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 250); }, 3200);
|
||||
};
|
||||
try {
|
||||
var p = new URLSearchParams(location.search);
|
||||
if (p.get('saved')) window.hdcToast('Gespeichert.', 'ok');
|
||||
} catch (e) {}
|
||||
|
||||
// Account-Menü
|
||||
var ab = document.getElementById('acctBtn'), am = document.getElementById('acctMenu');
|
||||
if (ab && am) {
|
||||
ab.addEventListener('click', function (e) { e.stopPropagation(); am.hidden = !am.hidden; });
|
||||
document.addEventListener('click', function () { am.hidden = true; });
|
||||
}
|
||||
|
||||
// Command Palette
|
||||
var items = JSON.parse(paletteJson);
|
||||
var cmdk = document.getElementById('cmdk'), input = document.getElementById('cmdkInput'),
|
||||
list = document.getElementById('cmdkList'), sel = 0, filtered = items.slice();
|
||||
function render() {
|
||||
list.innerHTML = '';
|
||||
filtered.forEach(function (it, i) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 's-cmdk-item' + (i === sel ? ' active' : '');
|
||||
li.innerHTML = '<span>' + it.label + '</span><em>' + it.kind + '</em>';
|
||||
li.addEventListener('click', function () { go(it); });
|
||||
li.addEventListener('mousemove', function () { sel = i; paint(); });
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
function paint() { Array.prototype.forEach.call(list.children, function (li, i) { li.classList.toggle('active', i === sel); }); }
|
||||
function go(it) { if (it && it.href) location.href = it.href; }
|
||||
function open() { cmdk.hidden = false; input.value=''; filtered = items.slice(); sel = 0; render(); setTimeout(function(){input.focus();}, 30); }
|
||||
function close() { cmdk.hidden = true; }
|
||||
function filter() {
|
||||
var q = input.value.toLowerCase().trim();
|
||||
filtered = items.filter(function (it) { return it.label.toLowerCase().indexOf(q) > -1 || it.kind.toLowerCase().indexOf(q) > -1; });
|
||||
sel = 0; render();
|
||||
}
|
||||
var openBtn = document.getElementById('cmdkOpen');
|
||||
if (openBtn) openBtn.addEventListener('click', open);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); cmdk.hidden ? open() : close(); return; }
|
||||
if (cmdk.hidden) return;
|
||||
if (e.key === 'Escape') { close(); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); sel = Math.min(sel + 1, filtered.length - 1); paint(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = Math.max(sel - 1, 0); paint(); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); go(filtered[sel]); }
|
||||
});
|
||||
if (input) input.addEventListener('input', filter);
|
||||
if (cmdk) cmdk.addEventListener('click', function (e) { if (e.target.hasAttribute('data-close')) close(); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// hd-commerce — Token-gesicherte Admin-JSON-API (für KI/MCP).
|
||||
// Bearer-Token aus ENV HDC_API_TOKEN (getrennt von der Session-Auth).
|
||||
import * as store from './store.js';
|
||||
import { BLOCK_TYPES } from './blocks.js';
|
||||
|
||||
export function json(obj, status = 200) {
|
||||
return new Response(JSON.stringify(obj, null, 2), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
|
||||
}
|
||||
export function authOk(request) {
|
||||
const token = (process.env.HDC_API_TOKEN || '').trim();
|
||||
if (!token) return false; // ohne konfiguriertes Token bleibt die API gesperrt
|
||||
const hdr = request.headers.get('authorization') || '';
|
||||
const m = hdr.match(/^Bearer\s+(.+)$/i);
|
||||
return !!m && m[1].trim() === token;
|
||||
}
|
||||
|
||||
// ---- Ressourcen-Definitionen für das Manifest ----
|
||||
export const RESOURCES = {
|
||||
products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] },
|
||||
pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] },
|
||||
slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] },
|
||||
popups: { rw: true, fields: ['title', 'type', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort'] },
|
||||
settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] },
|
||||
orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'items[]', 'address', 'created_at'] },
|
||||
customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] },
|
||||
};
|
||||
|
||||
export function listResource(name) {
|
||||
switch (name) {
|
||||
case 'products': return store.listProducts();
|
||||
case 'pages': return store.listPages();
|
||||
case 'slides': return store.listSlides();
|
||||
case 'popups': return store.listPopups();
|
||||
case 'orders': return store.listOrders();
|
||||
case 'customers': return store.listCustomers();
|
||||
case 'settings': return store.getSettings();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
export function getResource(name, id) {
|
||||
switch (name) {
|
||||
case 'products': return store.getProductById(id);
|
||||
case 'pages': return /^\d+$/.test(String(id)) ? store.getPageById(id) : store.getPageBySlug(id);
|
||||
case 'slides': return store.getSlideById(id);
|
||||
case 'popups': return store.getPopupById(id);
|
||||
case 'orders': return store.getOrderById(id);
|
||||
case 'customers': return store.getCustomerById(id);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// upsert: bei id -> update, sonst create. Für products/pages erlaubt auch slug als Schlüssel.
|
||||
export function upsertResource(name, body) {
|
||||
if (name === 'products') {
|
||||
if (body.id) { store.updateProduct(body.id, body); return { id: Number(body.id), ...store.getProductById(body.id) }; }
|
||||
if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); return store.getProductById(ex.id); } }
|
||||
const id = store.createProduct(body); return store.getProductById(id);
|
||||
}
|
||||
if (name === 'pages') {
|
||||
if (body.id) { store.updatePage(body.id, body); return store.getPageById(body.id); }
|
||||
if (body.slug) { const ex = store.getPageBySlug(body.slug); if (ex) { store.updatePage(ex.id, { ...ex, ...body }); return store.getPageById(ex.id); } }
|
||||
const id = store.createPage(body); return store.getPageById(id);
|
||||
}
|
||||
if (name === 'slides') {
|
||||
if (body.id) { store.updateSlide(body.id, body); return store.getSlideById(body.id); }
|
||||
const id = store.createSlide(body); return store.getSlideById(id);
|
||||
}
|
||||
if (name === 'popups') {
|
||||
if (body.id) { store.updatePopup(body.id, body); return store.getPopupById(body.id); }
|
||||
const id = store.createPopup(body); return store.getPopupById(id);
|
||||
}
|
||||
if (name === 'settings') {
|
||||
const entries = body && typeof body === 'object' ? Object.entries(body) : [];
|
||||
for (const [k, v] of entries) store.setSetting(k, v);
|
||||
return store.getSettings();
|
||||
}
|
||||
throw new Error('Ressource nicht schreibbar: ' + name);
|
||||
}
|
||||
|
||||
export function deleteResource(name, id) {
|
||||
switch (name) {
|
||||
case 'products': store.deleteProduct(id); return true;
|
||||
case 'pages': store.deletePage(id); return true;
|
||||
case 'slides': store.deleteSlide(id); return true;
|
||||
case 'popups': store.deletePopup(id); return true;
|
||||
default: throw new Error('Ressource nicht löschbar: ' + name);
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePageBlocks(id, blocks) { store.updatePageBlocks(id, blocks); return store.getPageById(id); }
|
||||
export function recordAudit(o) { store.recordAudit(o); }
|
||||
export function blockTypes() { return BLOCK_TYPES.map(b => ({ key: b.key, label: b.label, fields: b.fields.map(f => ({ name: f.name, type: f.type })) })); }
|
||||
|
||||
export function manifest(origin) {
|
||||
const ep = [];
|
||||
ep.push({ method: 'GET', path: '/api/admin', desc: 'Dieses Manifest' });
|
||||
for (const [name, def] of Object.entries(RESOURCES)) {
|
||||
ep.push({ method: 'GET', path: `/api/admin/${name}`, desc: `Liste ${name}` });
|
||||
ep.push({ method: 'GET', path: `/api/admin/${name}/{id}`, desc: `Einzelnes ${name}` });
|
||||
if (def.rw) {
|
||||
ep.push({ method: 'POST', path: `/api/admin/${name}`, desc: `Upsert ${name} (id oder slug => Update, sonst Create)` });
|
||||
if (name !== 'settings') ep.push({ method: 'DELETE', path: `/api/admin/${name}/{id}`, desc: `Löschen ${name}` });
|
||||
}
|
||||
}
|
||||
ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' });
|
||||
return {
|
||||
name: 'hd-commerce Admin API',
|
||||
version: '2.0.0',
|
||||
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
|
||||
base_url: origin || '',
|
||||
resources: RESOURCES,
|
||||
block_types: blockTypes(),
|
||||
endpoints: ep,
|
||||
notes: [
|
||||
'Preise in Cent (priceCents/total_cents).',
|
||||
'orders und customers sind nur lesbar.',
|
||||
'settings ist eine Key/Value-Map; POST mit beliebigen Keys aktualisiert sie.',
|
||||
'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.',
|
||||
],
|
||||
};
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// hd-commerce — Session-Auth (stateless signiertes Cookie), Rollen-Gate, Rate-Limit.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { getUserById } from './store.js';
|
||||
|
||||
const SECRET = process.env.SESSION_SECRET || 'hd-commerce-dev-secret-change-me';
|
||||
export const COOKIE_NAME = 'hdc_session';
|
||||
|
||||
// --- konfigurierbarer Admin-Pfad ---
|
||||
function rawAdminPath() {
|
||||
let p = (process.env.ADMIN_PATH || 'admin').trim().replace(/^\/+|\/+$/g, '');
|
||||
if (!p) p = 'admin';
|
||||
return p;
|
||||
}
|
||||
export const adminBase = () => '/' + rawAdminPath(); // z.B. "/login" oder "/admin"
|
||||
export const adminPathSegment = () => rawAdminPath(); // "login"
|
||||
export const isCustomAdminPath = () => rawAdminPath() !== 'admin';
|
||||
// Hilfsfunktion für Links in Astro-Seiten:
|
||||
export const ab = (suffix = '') => adminBase() + (suffix || '');
|
||||
|
||||
// --- Cookie-Signatur ---
|
||||
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
||||
function b64urlDecode(str) { return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
|
||||
|
||||
export function signSession(uid, maxAgeSeconds) {
|
||||
const exp = Math.floor(Date.now() / 1000) + (maxAgeSeconds || 60 * 60 * 12);
|
||||
const payload = b64url(JSON.stringify({ uid: Number(uid), exp }));
|
||||
const sig = b64url(createHmac('sha256', SECRET).update(payload).digest());
|
||||
return payload + '.' + sig;
|
||||
}
|
||||
|
||||
export function verifySession(token) {
|
||||
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
|
||||
const [payload, sig] = token.split('.');
|
||||
if (!payload || !sig) return null;
|
||||
const expected = b64url(createHmac('sha256', SECRET).update(payload).digest());
|
||||
try {
|
||||
const a = Buffer.from(sig), b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
} catch { return null; }
|
||||
let data;
|
||||
try { data = JSON.parse(b64urlDecode(payload).toString('utf8')); } catch { return null; }
|
||||
if (!data || !data.uid || !data.exp) return null;
|
||||
if (data.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function buildCookie(token, remember) {
|
||||
const parts = [`${COOKIE_NAME}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
||||
if (remember) parts.push('Max-Age=' + (60 * 60 * 24 * 30));
|
||||
return parts.join('; ');
|
||||
}
|
||||
export function clearCookie() {
|
||||
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||
}
|
||||
|
||||
export function parseCookies(request) {
|
||||
const h = request.headers.get('cookie') || '';
|
||||
const out = {};
|
||||
h.split(';').forEach(p => {
|
||||
const i = p.indexOf('=');
|
||||
if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function currentUser(request) {
|
||||
const token = parseCookies(request)[COOKIE_NAME];
|
||||
const sess = verifySession(token);
|
||||
if (!sess) return null;
|
||||
const u = getUserById(sess.uid);
|
||||
if (!u || !u.active) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
// --- Rollen-Gate ---
|
||||
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
|
||||
const ROLE_SECTIONS = {
|
||||
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'inhalte', 'einstellungen', 'nutzer', 'audit'],
|
||||
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'analytics'],
|
||||
versand: ['bestellungen'],
|
||||
};
|
||||
export function canAccess(role, section) {
|
||||
const allowed = ROLE_SECTIONS[role] || ROLE_SECTIONS.redaktion;
|
||||
return allowed.includes(section);
|
||||
}
|
||||
export function allowedSections(role) {
|
||||
return ROLE_SECTIONS[role] || ROLE_SECTIONS.redaktion;
|
||||
}
|
||||
export function landingFor(role) {
|
||||
if (role === 'versand') return adminBase() + '/bestellungen';
|
||||
return adminBase();
|
||||
}
|
||||
|
||||
// --- Login-Rate-Limit (In-Memory) ---
|
||||
const attempts = new Map(); // ip -> { count, until }
|
||||
export function rateLimited(ip) {
|
||||
const r = attempts.get(ip);
|
||||
if (r && r.until && Date.now() < r.until) return true;
|
||||
return false;
|
||||
}
|
||||
export function registerFail(ip) {
|
||||
const r = attempts.get(ip) || { count: 0, until: 0 };
|
||||
r.count += 1;
|
||||
if (r.count >= 5) { r.until = Date.now() + 60 * 1000; r.count = 0; }
|
||||
attempts.set(ip, r);
|
||||
}
|
||||
export function clearFails(ip) { attempts.delete(ip); }
|
||||
|
||||
export function clientIp(request) {
|
||||
return (request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'local').split(',')[0].trim();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// hd-commerce — Block-Definitionen für den Visual-Builder.
|
||||
// Jeder Block-Typ: key, label, icon (svg path), defaults, fields (für Settings-Panel).
|
||||
export const BLOCK_TYPES = [
|
||||
{
|
||||
key: 'hero', label: 'Hero', icon: 'M3 5h18v14H3z M3 11h18',
|
||||
defaults: { headline: 'Willkommen', subline: 'Ein starker Untertitel', image: '', cta_text: 'Jetzt entdecken', cta_url: '/shop', align: 'center' },
|
||||
fields: [
|
||||
{ name: 'headline', label: 'Headline', type: 'text' },
|
||||
{ name: 'subline', label: 'Subline', type: 'textarea' },
|
||||
{ name: 'image', label: 'Hintergrundbild', type: 'image' },
|
||||
{ name: 'cta_text', label: 'Button-Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button-Link', type: 'text' },
|
||||
{ name: 'align', label: 'Ausrichtung', type: 'select', options: ['left', 'center'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'richtext', label: 'Rich-Text', icon: 'M4 6h16M4 12h16M4 18h10',
|
||||
defaults: { html: '<p>Dein Text hier. <strong>HTML</strong> ist erlaubt.</p>' },
|
||||
fields: [{ name: 'html', label: 'Inhalt (HTML)', type: 'textarea' }],
|
||||
},
|
||||
{
|
||||
key: 'image', label: 'Bild', icon: 'M3 5h18v14H3z M3 16l5-5 4 4 3-3 6 6',
|
||||
defaults: { image: '', caption: '', width: 'wide' },
|
||||
fields: [
|
||||
{ name: 'image', label: 'Bild', type: 'image' },
|
||||
{ name: 'caption', label: 'Bildunterschrift', type: 'text' },
|
||||
{ name: 'width', label: 'Breite', type: 'select', options: ['narrow', 'wide', 'full'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'gallery', label: 'Galerie', icon: 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
|
||||
defaults: { images: [], columns: 3 },
|
||||
fields: [
|
||||
{ name: 'images', label: 'Bilder (eine URL pro Zeile)', type: 'imagelist' },
|
||||
{ name: 'columns', label: 'Spalten', type: 'select', options: ['2', '3', '4'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'slider', label: 'Slider-Referenz', icon: 'M2 12h20 M7 7l-5 5 5 5 M17 7l5 5-5 5',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
key: 'features', label: 'Feature-Grid', icon: 'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z',
|
||||
defaults: {
|
||||
headline: 'Unsere Vorteile',
|
||||
items: [
|
||||
{ title: 'Schnell', text: 'Blitzschneller Versand.' },
|
||||
{ title: 'Sicher', text: 'Geschützte Bezahlung.' },
|
||||
{ title: 'Fair', text: 'Transparente Preise.' },
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'headline', label: 'Überschrift', type: 'text' },
|
||||
{ name: 'items', label: 'Features (3)', type: 'features' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'productgrid', label: 'Produkt-Grid', icon: 'M4 4h7v7H4z M13 4h7v7h-7z M4 13h7v7H4z M13 13h7v7h-7z',
|
||||
defaults: { headline: 'Beliebte Produkte', source: 'featured', category: '', limit: 4 },
|
||||
fields: [
|
||||
{ name: 'headline', label: 'Überschrift', type: 'text' },
|
||||
{ name: 'source', label: 'Quelle', type: 'select', options: ['featured', 'category', 'all'] },
|
||||
{ name: 'category', label: 'Kategorie (bei „category")', type: 'text' },
|
||||
{ name: 'limit', label: 'Anzahl', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cta', label: 'CTA-Banner', icon: 'M3 7h18v10H3z M8 12h8',
|
||||
defaults: { headline: 'Bereit loszulegen?', text: 'Stöbere jetzt im Shop.', cta_text: 'Zum Shop', cta_url: '/shop' },
|
||||
fields: [
|
||||
{ name: 'headline', label: 'Headline', type: 'text' },
|
||||
{ name: 'text', label: 'Text', type: 'textarea' },
|
||||
{ name: 'cta_text', label: 'Button-Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button-Link', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'spacer', label: 'Abstand', icon: 'M12 4v16 M6 8l6-4 6 4 M6 16l6 4 6-4',
|
||||
defaults: { size: 'medium' },
|
||||
fields: [{ name: 'size', label: 'Größe', type: 'select', options: ['small', 'medium', 'large'] }],
|
||||
},
|
||||
{
|
||||
key: 'html', label: 'Roh-HTML', icon: 'M8 6l-5 6 5 6 M16 6l5 6-5 6',
|
||||
defaults: { code: '<div style="padding:2rem;text-align:center">Eigenes HTML</div>' },
|
||||
fields: [{ name: 'code', label: 'HTML-Code', type: 'textarea' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const blockMeta = (type) => BLOCK_TYPES.find(b => b.key === type) || null;
|
||||
export function blockDefaults(type) {
|
||||
const m = blockMeta(type);
|
||||
return m ? JSON.parse(JSON.stringify(m.defaults)) : {};
|
||||
}
|
||||
+135
-12
@@ -10,6 +10,12 @@ const DB_PATH = process.env.DB_PATH || './data/hdc.db';
|
||||
try { mkdirSync(dirname(DB_PATH), { recursive: true }); } catch {}
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
function ensureColumn(table, col, ddl) {
|
||||
try {
|
||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||
if (!cols.includes(col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -37,7 +43,16 @@ CREATE TABLE IF NOT EXISTS slides (
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT, body TEXT,
|
||||
type TEXT DEFAULT 'content', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99
|
||||
type TEXT DEFAULT 'content', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99, blocks TEXT DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT UNIQUE NOT NULL,
|
||||
pass_hash TEXT, pass_salt TEXT, role TEXT DEFAULT 'owner', active INTEGER DEFAULT 1,
|
||||
created_at TEXT, last_login TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, action TEXT, entity TEXT,
|
||||
entity_id TEXT, created_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS popups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, type TEXT DEFAULT 'newsletter',
|
||||
@@ -58,7 +73,9 @@ CREATE TABLE IF NOT EXISTS media (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit(created_at);
|
||||
`);
|
||||
ensureColumn('pages', 'blocks', "blocks TEXT DEFAULT '[]'");
|
||||
|
||||
// ---------- mappers ----------
|
||||
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured });
|
||||
@@ -170,10 +187,17 @@ export const listFeatured = () => db.prepare('SELECT * FROM products WHERE featu
|
||||
export const getProductBySlug = (slug) => P(db.prepare('SELECT * FROM products WHERE slug=?').get(slug));
|
||||
export const getProductById = (id) => P(db.prepare('SELECT * FROM products WHERE id=?').get(Number(id)));
|
||||
export const listCategories = () => [...new Set(db.prepare("SELECT category FROM products WHERE category IS NOT NULL AND category<>'' ORDER BY sort").all().map(r => r.category))];
|
||||
function slugify(str) {
|
||||
return String(str || '').toLowerCase()
|
||||
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
|
||||
.replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
|
||||
}
|
||||
function normProduct(d) {
|
||||
const cardImage = d.cardImage || (Array.isArray(d.images) && d.images[0]) || '';
|
||||
const slug = (d.slug && String(d.slug).trim()) ? slugify(d.slug) : slugify(d.name || 'produkt');
|
||||
return {
|
||||
slug: d.slug, name: d.name, shortName: d.shortName || d.name, priceCents: Math.round(Number(d.priceCents) || 0), category: d.category || '',
|
||||
slug, name: d.name, shortName: d.shortName || d.name, priceCents: Math.round(Number(d.priceCents) || 0), category: d.category || '',
|
||||
sizes: JSON.stringify(d.sizes && d.sizes.length ? d.sizes : ['One Size']), images: JSON.stringify(d.images || []), cardImage,
|
||||
badge: d.badge || '', stock: (d.stock === '' || d.stock == null) ? null : Math.round(Number(d.stock)), material: d.material || '',
|
||||
features: JSON.stringify(d.features || []), featured: d.featured ? 1 : 0, sort: Number(d.sort) || 99, desc: d.desc || '',
|
||||
@@ -236,18 +260,35 @@ export function updateSlide(id, d) {
|
||||
export const deleteSlide = (id) => db.prepare('DELETE FROM slides WHERE id=?').run(Number(id));
|
||||
|
||||
// ---------- pages ----------
|
||||
export const listPages = () => db.prepare('SELECT * FROM pages ORDER BY sort, id').all();
|
||||
export const listActivePages = () => db.prepare('SELECT * FROM pages WHERE active=1 ORDER BY sort, id').all();
|
||||
export const listLegalPages = () => db.prepare("SELECT * FROM pages WHERE active=1 AND type='legal' ORDER BY sort, id").all();
|
||||
export const getPageBySlug = (slug) => db.prepare('SELECT * FROM pages WHERE slug=?').get(slug);
|
||||
export const getPageById = (id) => db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id));
|
||||
function PG(r) {
|
||||
if (!r) return r;
|
||||
let blocks = [];
|
||||
try { blocks = JSON.parse(r.blocks || '[]'); if (!Array.isArray(blocks)) blocks = []; } catch { blocks = []; }
|
||||
return { ...r, blocks };
|
||||
}
|
||||
export const listPages = () => db.prepare('SELECT * FROM pages ORDER BY sort, id').all().map(PG);
|
||||
export const listActivePages = () => db.prepare('SELECT * FROM pages WHERE active=1 ORDER BY sort, id').all().map(PG);
|
||||
export const listLegalPages = () => db.prepare("SELECT * FROM pages WHERE active=1 AND type='legal' ORDER BY sort, id").all().map(PG);
|
||||
export const getPageBySlug = (slug) => PG(db.prepare('SELECT * FROM pages WHERE slug=?').get(slug));
|
||||
export const getPageById = (id) => PG(db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id)));
|
||||
function normBlocks(b) {
|
||||
if (typeof b === 'string') { try { b = JSON.parse(b); } catch { b = []; } }
|
||||
return JSON.stringify(Array.isArray(b) ? b : []);
|
||||
}
|
||||
export function createPage(d) {
|
||||
return db.prepare('INSERT INTO pages (slug,title,body,type,active,sort) VALUES (?,?,?,?,?,?)')
|
||||
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99).lastInsertRowid;
|
||||
return db.prepare('INSERT INTO pages (slug,title,body,type,active,sort,blocks) VALUES (?,?,?,?,?,?,?)')
|
||||
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99, normBlocks(d.blocks)).lastInsertRowid;
|
||||
}
|
||||
export function updatePage(id, d) {
|
||||
db.prepare('UPDATE pages SET slug=?,title=?,body=?,type=?,active=?,sort=? WHERE id=?')
|
||||
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99, Number(id));
|
||||
const cur = db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id)) || {};
|
||||
const blocks = (d.blocks !== undefined) ? normBlocks(d.blocks) : (cur.blocks || '[]');
|
||||
db.prepare('UPDATE pages SET slug=?,title=?,body=?,type=?,active=?,sort=?,blocks=? WHERE id=?')
|
||||
.run(d.slug ?? cur.slug, d.title ?? cur.title ?? '', d.body ?? cur.body ?? '', d.type ?? cur.type ?? 'content',
|
||||
(d.active !== undefined ? (d.active ? 1 : 0) : cur.active), Number(d.sort ?? cur.sort) || 99, blocks, Number(id));
|
||||
return id;
|
||||
}
|
||||
export function updatePageBlocks(id, blocks) {
|
||||
db.prepare('UPDATE pages SET blocks=? WHERE id=?').run(normBlocks(blocks), Number(id));
|
||||
return id;
|
||||
}
|
||||
export const deletePage = (id) => db.prepare('DELETE FROM pages WHERE id=?').run(Number(id));
|
||||
@@ -358,5 +399,87 @@ export function dashboard() {
|
||||
const recentOrders = db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC LIMIT 6').all().map(O);
|
||||
const lowStock = db.prepare('SELECT * FROM products WHERE stock IS NOT NULL AND stock <= 35 ORDER BY stock ASC LIMIT 6').all().map(P);
|
||||
const a = analyticsSummary(30);
|
||||
return { revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock, funnelMini: { views: a.pageviews, cart: a.addToCart, buy: a.purchases } };
|
||||
// 14-Tage Umsatz-Spark + Trend (zweite Hälfte vs. erste Hälfte)
|
||||
const spark = a.series.slice(-14).map(d => d.revenue);
|
||||
const half = Math.floor(spark.length / 2) || 1;
|
||||
const first = spark.slice(0, half).reduce((x, y) => x + y, 0) || 0;
|
||||
const second = spark.slice(half).reduce((x, y) => x + y, 0) || 0;
|
||||
const revTrend = first ? Math.round(((second - first) / first) * 100) : (second ? 100 : 0);
|
||||
const feed = recentAudit(8);
|
||||
return {
|
||||
revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock,
|
||||
funnelMini: { views: a.pageviews, cart: a.addToCart, buy: a.purchases },
|
||||
spark, revTrend, visitors: a.visitors, conversion: a.conversion, feed,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- users / auth ----------
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
const s = salt || randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(String(password), s, 64).toString('hex');
|
||||
return { pass_hash: hash, pass_salt: s };
|
||||
}
|
||||
export function verifyPassword(password, hash, salt) {
|
||||
if (!hash || !salt) return false;
|
||||
try {
|
||||
const cand = scryptSync(String(password), salt, 64);
|
||||
const ref = Buffer.from(hash, 'hex');
|
||||
return cand.length === ref.length && timingSafeEqual(cand, ref);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const U = (r) => r && ({ ...r, active: !!r.active });
|
||||
|
||||
export const listUsers = () => db.prepare('SELECT * FROM users ORDER BY id').all().map(U);
|
||||
export const getUserById = (id) => U(db.prepare('SELECT * FROM users WHERE id=?').get(Number(id)));
|
||||
export const getUserByEmail = (email) => U(db.prepare('SELECT * FROM users WHERE email=?').get(String(email || '').toLowerCase().trim()));
|
||||
export const countUsers = () => db.prepare('SELECT COUNT(*) c FROM users').get().c;
|
||||
|
||||
const ROLES = ['owner', 'redaktion', 'versand'];
|
||||
export function createUser({ name, email, password, role = 'owner', active = true }) {
|
||||
const e = String(email || '').toLowerCase().trim();
|
||||
if (!e) throw new Error('E-Mail erforderlich');
|
||||
if (!ROLES.includes(role)) role = 'redaktion';
|
||||
const { pass_hash, pass_salt } = hashPassword(password || randomBytes(8).toString('hex'));
|
||||
const r = db.prepare('INSERT INTO users (name,email,pass_hash,pass_salt,role,active,created_at) VALUES (?,?,?,?,?,?,?)')
|
||||
.run(name || e, e, pass_hash, pass_salt, role, active ? 1 : 0, new Date().toISOString());
|
||||
return r.lastInsertRowid;
|
||||
}
|
||||
export function updateUserRole(id, role) {
|
||||
if (!ROLES.includes(role)) return;
|
||||
db.prepare('UPDATE users SET role=? WHERE id=?').run(role, Number(id));
|
||||
}
|
||||
export function setUserActive(id, active) {
|
||||
db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, Number(id));
|
||||
}
|
||||
export function setUserPassword(id, password) {
|
||||
const { pass_hash, pass_salt } = hashPassword(password);
|
||||
db.prepare('UPDATE users SET pass_hash=?,pass_salt=? WHERE id=?').run(pass_hash, pass_salt, Number(id));
|
||||
}
|
||||
export function deleteUser(id) {
|
||||
db.prepare('DELETE FROM users WHERE id=?').run(Number(id));
|
||||
}
|
||||
export function touchUserLogin(id) {
|
||||
db.prepare('UPDATE users SET last_login=? WHERE id=?').run(new Date().toISOString(), Number(id));
|
||||
}
|
||||
|
||||
// Seed the initial owner from ENV on first boot
|
||||
export function seedAdminUser() {
|
||||
if (countUsers() > 0) return;
|
||||
const email = (process.env.ADMIN_EMAIL || 'admin@example.com').toLowerCase().trim();
|
||||
const pass = process.env.ADMIN_PASS || 'admin';
|
||||
try { createUser({ name: 'Administrator', email, password: pass, role: 'owner', active: true }); } catch {}
|
||||
}
|
||||
seedAdminUser();
|
||||
|
||||
// ---------- audit ----------
|
||||
export function recordAudit({ user = '', action = '', entity = '', entity_id = '' }) {
|
||||
try {
|
||||
db.prepare('INSERT INTO audit (user,action,entity,entity_id,created_at) VALUES (?,?,?,?,?)')
|
||||
.run(String(user || ''), String(action || ''), String(entity || ''), String(entity_id || ''), new Date().toISOString());
|
||||
} catch {}
|
||||
}
|
||||
export const listAudit = (limit = 200) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 200);
|
||||
export const recentAudit = (limit = 8) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 8);
|
||||
|
||||
+62
-18
@@ -1,8 +1,8 @@
|
||||
import { recordEvent, getSetting } from './lib/store.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const USER = process.env.ADMIN_USER || 'admin';
|
||||
const PASS = process.env.ADMIN_PASS || 'admin';
|
||||
import {
|
||||
currentUser, adminBase, adminPathSegment, isCustomAdminPath, canAccess, landingFor,
|
||||
} from './lib/auth.js';
|
||||
|
||||
const SKIP = ['/api/', '/uploads/', '/_astro', '/favicon', '/_image', '/robots.txt'];
|
||||
|
||||
@@ -13,26 +13,70 @@ function sessionHash(request) {
|
||||
return createHash('sha256').update(ip + ua + day).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
export function onRequest({ request }, next) {
|
||||
function sectionOf(adminInner) {
|
||||
const seg = adminInner.replace(/^\//, '').split('/')[0] || 'dashboard';
|
||||
const map = {
|
||||
'': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden',
|
||||
'analytics': 'analytics', 'marketing': 'marketing', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen',
|
||||
'nutzer': 'nutzer', 'audit': 'audit', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout',
|
||||
};
|
||||
return map[seg] || 'dashboard';
|
||||
}
|
||||
|
||||
export async function onRequest(context, next) {
|
||||
const { request, locals } = context;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const base = adminBase(); // "/login" oder "/admin"
|
||||
const custom = isCustomAdminPath();
|
||||
|
||||
// --- Admin Basic-Auth ---
|
||||
if (path.startsWith('/admin')) {
|
||||
const hdr = request.headers.get('authorization') || '';
|
||||
if (hdr.startsWith('Basic ')) {
|
||||
let dec = ''; try { dec = atob(hdr.slice(6)); } catch {}
|
||||
const i = dec.indexOf(':');
|
||||
if (i > -1 && dec.slice(0, i) === USER && dec.slice(i + 1) === PASS) return next();
|
||||
}
|
||||
const shop = getSetting('shop_name', 'hd-commerce');
|
||||
return new Response('Authentifizierung erforderlich', {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': `Basic realm="${shop} Admin", charset="UTF-8"` },
|
||||
});
|
||||
// Interner Rewrite-Durchlauf (auf physische /admin-Routen) -> einfach durchreichen.
|
||||
if (locals && locals.__hdcAdminRewrite) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// --- First-Party Pageview-Tracking (nur Storefront-GET-Seiten) ---
|
||||
// Custom-Admin-Pfad: direkter Zugriff auf physische /admin-Routen blocken (404).
|
||||
if (custom && (path === '/admin' || path.startsWith('/admin/'))) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Admin-Bereich unter konfiguriertem Pfad
|
||||
const isAdmin = path === base || path.startsWith(base + '/');
|
||||
if (isAdmin) {
|
||||
let inner = path.slice(base.length); // "" oder "/bestellungen/3"
|
||||
if (inner === '') inner = '/';
|
||||
const innerSeg = inner.replace(/^\//, '').split('/')[0];
|
||||
const isLoginRoute = innerSeg === 'login';
|
||||
const isLogoutRoute = innerSeg === 'logout';
|
||||
|
||||
const user = currentUser(request);
|
||||
|
||||
if (!user && !isLoginRoute) {
|
||||
// Nicht eingeloggt -> Login-Seite rendern (HTTP 200).
|
||||
if (locals) locals.__hdcAdminRewrite = true;
|
||||
return context.rewrite('/admin/login?next=' + encodeURIComponent(path));
|
||||
}
|
||||
|
||||
if (user && !isLoginRoute && !isLogoutRoute) {
|
||||
const section = sectionOf(inner);
|
||||
if (section !== 'dashboard' && section !== 'login' && section !== 'logout' && !canAccess(user.role, section)) {
|
||||
return Response.redirect(new URL(landingFor(user.role), url), 302);
|
||||
}
|
||||
if (section === 'dashboard' && !canAccess(user.role, 'dashboard')) {
|
||||
return Response.redirect(new URL(landingFor(user.role), url), 302);
|
||||
}
|
||||
}
|
||||
|
||||
// Auf physische /admin-Routen umschreiben.
|
||||
if (custom) {
|
||||
if (locals) locals.__hdcAdminRewrite = true;
|
||||
const target = '/admin' + (inner === '/' ? '' : inner) + url.search;
|
||||
return context.rewrite(target);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// First-Party Pageview-Tracking (nur Storefront-GET-Seiten)
|
||||
if (request.method === 'GET' && !SKIP.some(s => path.startsWith(s))) {
|
||||
try {
|
||||
recordEvent({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { manifest } from '../lib/admin-api.js';
|
||||
export const prerender = false;
|
||||
|
||||
export async function GET({ request }) {
|
||||
const origin = new URL(request.url).origin;
|
||||
const m = manifest(origin);
|
||||
const lines = [];
|
||||
lines.push('# hd-commerce — KI-Admin-Manifest');
|
||||
lines.push('# Maschinenlesbare Beschreibung der Admin-API für LLMs/Agenten.');
|
||||
lines.push('');
|
||||
lines.push('Auth: ' + m.auth);
|
||||
lines.push('Base-URL: ' + (m.base_url || origin));
|
||||
lines.push('Version: ' + m.version);
|
||||
lines.push('');
|
||||
lines.push('## Ressourcen');
|
||||
for (const [name, def] of Object.entries(m.resources)) {
|
||||
lines.push(`- ${name} (${def.rw ? 'lesen+schreiben' : 'nur lesen'}): ${def.fields.join(', ')}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## Block-Typen (pages.blocks)');
|
||||
for (const b of m.block_types) {
|
||||
lines.push(`- ${b.key} (${b.label}): ${b.fields.map(f => f.name + ':' + f.type).join(', ') || '—'}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## Endpunkte');
|
||||
for (const e of m.endpoints) lines.push(`${e.method} ${e.path} — ${e.desc}`);
|
||||
lines.push('');
|
||||
lines.push('## Hinweise');
|
||||
for (const n of m.notes) lines.push('- ' + n);
|
||||
lines.push('');
|
||||
lines.push('JSON-Manifest: GET /api/admin (Bearer-Token erforderlich)');
|
||||
return new Response(lines.join('\n'), { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Session-geschützter Endpoint zum Speichern der Block-Struktur einer Seite (vom Visual-Builder).
|
||||
import { updatePageBlocks, getPageById, recordAudit } from '../../lib/store.js';
|
||||
import { currentUser, canAccess } from '../../lib/auth.js';
|
||||
export const prerender = false;
|
||||
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
|
||||
|
||||
export async function POST({ request }) {
|
||||
const user = currentUser(request);
|
||||
if (!user) return json({ ok: false, error: 'Nicht angemeldet' }, 401);
|
||||
if (!canAccess(user.role, 'inhalte')) return json({ ok: false, error: 'Keine Berechtigung' }, 403);
|
||||
let body;
|
||||
try { body = await request.json(); } catch { return json({ ok: false, error: 'Bad request' }, 400); }
|
||||
const id = Number(body.id);
|
||||
if (!id || !getPageById(id)) return json({ ok: false, error: 'Seite nicht gefunden' }, 404);
|
||||
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
|
||||
updatePageBlocks(id, blocks);
|
||||
recordAudit({ user: user.email, action: 'update', entity: 'page_blocks', entity_id: String(id) });
|
||||
return json({ ok: true, count: blocks.length });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
authOk, json, RESOURCES, listResource, getResource, upsertResource, deleteResource, updatePageBlocks, recordAudit,
|
||||
} from '../../../lib/admin-api.js';
|
||||
export const prerender = false;
|
||||
|
||||
function parse(path) {
|
||||
const parts = String(path || '').split('/').filter(Boolean);
|
||||
return { name: parts[0], id: parts[1], sub: parts[2] };
|
||||
}
|
||||
|
||||
export async function GET({ params, request }) {
|
||||
if (!authOk(request)) return json({ error: 'Unauthorized' }, 401);
|
||||
const { name, id } = parse(params.path);
|
||||
if (!RESOURCES[name]) return json({ error: 'Unbekannte Ressource: ' + name }, 404);
|
||||
if (id) {
|
||||
const item = getResource(name, id);
|
||||
if (!item) return json({ error: 'Nicht gefunden' }, 404);
|
||||
return json({ data: item });
|
||||
}
|
||||
return json({ data: listResource(name) });
|
||||
}
|
||||
|
||||
export async function POST({ params, request }) {
|
||||
if (!authOk(request)) return json({ error: 'Unauthorized' }, 401);
|
||||
const { name, id, sub } = parse(params.path);
|
||||
if (!RESOURCES[name]) return json({ error: 'Unbekannte Ressource: ' + name }, 404);
|
||||
if (!RESOURCES[name].rw) return json({ error: 'Ressource ist nur lesbar' }, 405);
|
||||
let body;
|
||||
try { body = await request.json(); } catch { return json({ error: 'Ungültiges JSON' }, 400); }
|
||||
|
||||
// Sonderfall: /api/admin/pages/{id}/blocks
|
||||
if (name === 'pages' && id && sub === 'blocks') {
|
||||
const blocks = Array.isArray(body) ? body : (Array.isArray(body.blocks) ? body.blocks : null);
|
||||
if (!blocks) return json({ error: 'Erwarte Array oder { blocks: [...] }' }, 400);
|
||||
const updated = updatePageBlocks(id, blocks);
|
||||
if (!updated) return json({ error: 'Seite nicht gefunden' }, 404);
|
||||
recordAudit({ user: 'api', action: 'update', entity: 'page_blocks', entity_id: String(id) });
|
||||
return json({ data: updated });
|
||||
}
|
||||
|
||||
try {
|
||||
if (id && !body.id) body.id = id;
|
||||
const result = upsertResource(name, body);
|
||||
recordAudit({ user: 'api', action: body.id ? 'update' : 'create', entity: name, entity_id: String(body.id || (result && result.id) || '') });
|
||||
return json({ data: result }, body.id ? 200 : 201);
|
||||
} catch (e) {
|
||||
return json({ error: String(e && e.message || e) }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE({ params, request }) {
|
||||
if (!authOk(request)) return json({ error: 'Unauthorized' }, 401);
|
||||
const { name, id } = parse(params.path);
|
||||
if (!RESOURCES[name]) return json({ error: 'Unbekannte Ressource: ' + name }, 404);
|
||||
if (!id) return json({ error: 'ID erforderlich' }, 400);
|
||||
try {
|
||||
deleteResource(name, id);
|
||||
recordAudit({ user: 'api', action: 'delete', entity: name, entity_id: String(id) });
|
||||
return json({ ok: true });
|
||||
} catch (e) {
|
||||
return json({ error: String(e && e.message || e) }, 400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { authOk, json, manifest } from '../../../lib/admin-api.js';
|
||||
export const prerender = false;
|
||||
|
||||
export async function GET({ request }) {
|
||||
// Manifest ist auch ohne Token lesbar wäre praktisch — wir verlangen es jedoch konsistent.
|
||||
if (!authOk(request)) return json({ error: 'Unauthorized', hint: 'Authorization: Bearer <HDC_API_TOKEN>' }, 401);
|
||||
const origin = new URL(request.url).origin;
|
||||
return json(manifest(origin));
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import BlockRenderer from '../../components/BlockRenderer.astro';
|
||||
import { getPageBySlug } from '../../lib/store.js';
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const page = getPageBySlug(slug);
|
||||
if (!page || !page.active) return Astro.redirect('/');
|
||||
const hasBlocks = Array.isArray(page.blocks) && page.blocks.length > 0;
|
||||
---
|
||||
<Base title={page.title}>
|
||||
<div class="wrap">
|
||||
<article class="prose">
|
||||
<h1>{page.title}</h1>
|
||||
<div set:html={page.body}></div>
|
||||
</article>
|
||||
</div>
|
||||
{hasBlocks ? (
|
||||
<BlockRenderer blocks={page.blocks} />
|
||||
) : (
|
||||
<div class="wrap">
|
||||
<article class="prose">
|
||||
<h1>{page.title}</h1>
|
||||
<div set:html={page.body}></div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</Base>
|
||||
|
||||
@@ -165,3 +165,75 @@
|
||||
|
||||
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||
@media(max-width:860px){.admin-shell{grid-template-columns:1fr}.s-side{position:static;height:auto}.s-nav{flex-direction:row;flex-wrap:wrap}.s-nav a.active::before{display:none}.s-kpis{grid-template-columns:1fr 1fr}.s-form-grid{grid-template-columns:1fr}.s-two-col{grid-template-columns:1fr}}
|
||||
|
||||
/* ===== v2: Login ===== */
|
||||
.login-body{display:flex;min-height:100vh;align-items:center;justify-content:center;background:
|
||||
radial-gradient(1200px 600px at 50% -10%, color-mix(in srgb,var(--accent) 9%, transparent), transparent 60%),
|
||||
var(--s-bg)}
|
||||
.login-wrap{width:100%;max-width:420px;padding:24px}
|
||||
.login-card{background:var(--s-surface);border:1px solid var(--s-border);border-radius:18px;padding:34px 32px 26px;box-shadow:var(--s-shadow-pop);text-align:left}
|
||||
.login-logo{width:52px;height:52px;border-radius:14px;background:var(--accent);color:#fff;display:grid;place-items:center;font-weight:800;font-size:23px;margin:0 auto 16px;box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 6px 18px -8px color-mix(in srgb,var(--accent) 70%, transparent)}
|
||||
.login-title{font-family:var(--s-display);font-weight:560;font-size:25px;color:var(--s-ink);text-align:center;margin:0;letter-spacing:-.02em}
|
||||
.login-sub{text-align:center;color:var(--s-subtle);font-size:13.5px;margin:4px 0 22px}
|
||||
.login-error{background:var(--s-red);color:var(--s-red-t);border:1px solid color-mix(in srgb,var(--s-red-t) 26%, #fff);padding:10px 14px;border-radius:10px;font-weight:600;font-size:13px;margin-bottom:16px;display:flex;align-items:center;gap:8px}
|
||||
.login-error::before{content:'!';display:inline-grid;place-items:center;width:18px;height:18px;border-radius:50%;background:var(--s-red-t);color:#fff;font-size:12px;font-weight:800;flex:none}
|
||||
.login-remember{margin:2px 0 18px;font-size:13.5px;color:var(--s-text)}
|
||||
.login-submit{width:100%;justify-content:center;padding:11px;font-size:14px}
|
||||
.login-foot{text-align:center;color:var(--s-faint);font-size:11.5px;margin:18px 0 0}
|
||||
|
||||
/* ===== v2: Account-Menü + ⌘K-Trigger ===== */
|
||||
.s-cmdk-trigger{gap:9px;padding:7px 10px}
|
||||
.s-kbd{font-size:11px;font-weight:700;color:var(--s-subtle);background:var(--s-sunken);border:1px solid var(--s-border);border-radius:6px;padding:1px 6px;letter-spacing:.02em}
|
||||
.s-account{position:relative}
|
||||
.s-account-btn{display:flex;align-items:center;gap:9px;background:var(--s-surface);border:1px solid var(--s-border-2);border-radius:var(--s-radius-sm);padding:5px 11px 5px 6px;cursor:pointer;font-family:inherit;transition:background .15s,box-shadow .15s,transform .15s}
|
||||
.s-account-btn:hover{background:var(--s-bg);box-shadow:var(--s-shadow);transform:translateY(-1px)}
|
||||
.s-acct-av{width:28px;height:28px;border-radius:8px;background:var(--accent);color:#fff;display:grid;place-items:center;font-weight:800;font-size:13px;flex:none}
|
||||
.s-acct-meta{display:flex;flex-direction:column;line-height:1.15;text-align:left}
|
||||
.s-acct-name{font-size:13px;font-weight:600;color:var(--s-ink);max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.s-acct-role{font-size:11px;color:var(--s-faint)}
|
||||
.s-account-menu{position:absolute;right:0;top:calc(100% + 8px);background:var(--s-surface);border:1px solid var(--s-border);border-radius:12px;box-shadow:var(--s-shadow-pop);padding:6px;min-width:170px;z-index:40;display:flex;flex-direction:column;gap:2px}
|
||||
.s-account-menu a{padding:9px 12px;border-radius:8px;font-size:13.5px;font-weight:500;color:var(--s-text);transition:background .12s}
|
||||
.s-account-menu a:hover{background:var(--s-bg);color:var(--s-ink)}
|
||||
.s-account-menu a.danger{color:var(--s-red-t)}
|
||||
.s-account-menu a.danger:hover{background:var(--s-red)}
|
||||
|
||||
/* ===== v2: Command-Palette ===== */
|
||||
.s-cmdk{position:fixed;inset:0;z-index:100;display:flex;align-items:flex-start;justify-content:center;padding-top:14vh}
|
||||
.s-cmdk[hidden]{display:none}
|
||||
.s-cmdk-backdrop{position:absolute;inset:0;background:rgba(43,38,32,.34);backdrop-filter:blur(2px);animation:fade .15s var(--s-ease)}
|
||||
.s-cmdk-panel{position:relative;width:100%;max-width:560px;background:var(--s-surface);border:1px solid var(--s-border);border-radius:16px;box-shadow:var(--s-shadow-pop);overflow:hidden;animation:pop .16s var(--s-ease)}
|
||||
.s-cmdk-input{width:100%;border:none;border-bottom:1px solid var(--s-line-soft);padding:17px 20px;font:inherit;font-size:16px;color:var(--s-ink);background:transparent;outline:none}
|
||||
.s-cmdk-input::placeholder{color:var(--s-faint)}
|
||||
.s-cmdk-list{list-style:none;margin:0;padding:8px;max-height:46vh;overflow:auto}
|
||||
.s-cmdk-item{display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-radius:10px;cursor:pointer;font-size:14px;color:var(--s-text)}
|
||||
.s-cmdk-item em{font-style:normal;font-size:11px;color:var(--s-faint);text-transform:uppercase;letter-spacing:.05em;font-weight:700}
|
||||
.s-cmdk-item.active{background:var(--s-acc-l);color:var(--accent-dark)}
|
||||
.s-cmdk-item.active em{color:color-mix(in srgb,var(--accent-dark) 70%, transparent)}
|
||||
|
||||
/* ===== v2: Toasts ===== */
|
||||
.s-toasts{position:fixed;right:20px;bottom:20px;z-index:120;display:flex;flex-direction:column;gap:10px;align-items:flex-end}
|
||||
.s-toast{background:var(--s-ink);color:#fff;padding:12px 18px;border-radius:11px;font-size:13.5px;font-weight:600;box-shadow:var(--s-shadow-pop);opacity:0;transform:translateY(8px);transition:opacity .25s var(--s-ease),transform .25s var(--s-ease);max-width:340px}
|
||||
.s-toast.show{opacity:1;transform:translateY(0)}
|
||||
.s-toast.ok{background:#2f6b4f}
|
||||
.s-toast.err{background:var(--s-red-t)}
|
||||
|
||||
/* ===== v2: Empty-States, Skeleton, KPI-Trend ===== */
|
||||
.s-emptystate{text-align:center;padding:54px 24px;display:flex;flex-direction:column;align-items:center;gap:12px}
|
||||
.s-emptystate .es-icon{width:56px;height:56px;border-radius:16px;background:var(--s-acc-l);color:var(--accent-dark);display:grid;place-items:center}
|
||||
.s-emptystate .es-icon svg{width:28px;height:28px}
|
||||
.s-emptystate h3{font-family:var(--s-display);font-weight:560;font-size:18px;color:var(--s-ink);margin:0}
|
||||
.s-emptystate p{color:var(--s-subtle);font-size:13.5px;margin:0;max-width:360px}
|
||||
.s-kpi-trend{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
|
||||
.s-kpi-trend.up{color:var(--s-green-t)}.s-kpi-trend.down{color:var(--s-red-t)}
|
||||
.s-kpi-spark{height:30px;margin-top:8px}
|
||||
.s-quick{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
|
||||
.s-quick a{display:flex;align-items:center;gap:11px;padding:14px 16px;border:1px solid var(--s-border);border-radius:12px;background:var(--s-surface);box-shadow:var(--s-shadow);font-weight:600;color:var(--s-ink);transition:transform .15s var(--s-ease),box-shadow .15s}
|
||||
.s-quick a:hover{transform:translateY(-2px);box-shadow:var(--s-shadow-pop)}
|
||||
.s-quick a svg{width:20px;height:20px;color:var(--accent)}
|
||||
.s-feed{display:flex;flex-direction:column}
|
||||
.s-feed-row{display:flex;align-items:center;gap:12px;padding:11px 22px;border-bottom:1px solid var(--s-line-soft);font-size:13px}
|
||||
.s-feed-row:last-child{border-bottom:none}
|
||||
.s-feed-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);flex:none}
|
||||
.s-feed-row .t{color:var(--s-faint);margin-left:auto;font-size:12px;white-space:nowrap}
|
||||
@keyframes fade{from{opacity:0}to{opacity:1}}
|
||||
@keyframes pop{from{opacity:0;transform:translateY(-6px) scale(.98)}to{opacity:1;transform:none}}
|
||||
|
||||
@@ -197,3 +197,38 @@ p{margin:0 0 1rem}
|
||||
.form-grid{grid-template-columns:1fr}
|
||||
}
|
||||
@media(max-width:560px){.foot-grid{grid-template-columns:1fr}}
|
||||
|
||||
/* ===== v2: Block-Renderer (Visual-Builder-Ausgabe) ===== */
|
||||
.blk{position:relative}
|
||||
.blk-h2{text-align:center;margin-bottom:32px}
|
||||
.blk-hero{padding:84px 0;background:var(--sunken);text-align:center}
|
||||
.blk-hero.align-left{text-align:left}
|
||||
.blk-hero.has-img{background-image:linear-gradient(rgba(20,15,10,.42),rgba(20,15,10,.42)),var(--hero-img);background-size:cover;background-position:center;color:#fff}
|
||||
.blk-hero.has-img h1{color:#fff}
|
||||
.blk-hero-inner{max-width:760px}
|
||||
.blk-hero.align-left .blk-hero-inner{margin-left:0}
|
||||
.blk-hero-sub{font-size:1.15rem;color:inherit;opacity:.92;margin:14px 0 26px}
|
||||
.blk-hero.has-img .blk-hero-sub{color:#fff}
|
||||
.blk-rich{padding:8px 0}
|
||||
.blk-rich .prose{padding:24px 0}
|
||||
.blk-image{padding:24px 0}
|
||||
.blk-image .img-narrow{max-width:680px}.blk-image .img-full{max-width:none;padding:0}
|
||||
.blk-image img{width:100%;border-radius:var(--radius);box-shadow:var(--shadow)}
|
||||
.blk-cap{text-align:center;color:var(--subtle);font-size:14px;margin-top:10px}
|
||||
.blk-gallery{padding:24px 0}
|
||||
.blk-gal-grid{display:grid;gap:14px}
|
||||
.blk-gal-grid img{width:100%;height:100%;aspect-ratio:1/1;object-fit:cover;border-radius:var(--radius-sm)}
|
||||
.blk-features{padding:56px 0}
|
||||
.blk-feat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}
|
||||
.blk-feat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:26px;box-shadow:var(--shadow)}
|
||||
.blk-feat h3{margin-bottom:8px}
|
||||
.blk-feat p{margin:0;color:var(--subtle)}
|
||||
.blk-products{padding:48px 0}
|
||||
.blk-cta{padding:48px 0}
|
||||
.blk-cta-box{background:var(--accent);color:#fff;border-radius:var(--radius);padding:54px 32px;text-align:center}
|
||||
.blk-cta-box h2{color:#fff}
|
||||
.blk-cta-box p{opacity:.92;max-width:520px;margin:12px auto 24px}
|
||||
.blk-cta-box .btn-primary{background:#fff;color:var(--accent)}
|
||||
.blk-cta-box .btn-primary:hover{background:rgba(255,255,255,.9)}
|
||||
.blk-html{padding:8px 0}
|
||||
@media(max-width:760px){.blk-feat-grid{grid-template-columns:1fr}.blk-gal-grid{grid-template-columns:repeat(2,1fr)!important}}
|
||||
|
||||
Reference in New Issue
Block a user