Add login-protected admin editor for content and design

Adds a self-service editor at admin.larpaso.de so texts, images, links,
Impressum/Datenschutz, fonts, font sizes and colors can be changed without
a developer. All homepage/legal content moves into content/site.json and
a new content/theme.json drives every CSS color/font/size token; the
Astro pages read from these at build time.

The admin/ service (Node/Express) provides bcrypt+session login with
rate limiting, a schema-driven form UI (including a hex/RGB/alpha color
picker), image upload, and a publish action that runs the Astro build
and republishes into a volume shared with the nginx website container -
so edits go live without redeploying that container.

See DEPLOYMENT_ADMIN.md for the server-side setup steps.
This commit is contained in:
Pascal Scholz 2026-09-21 21:35:09 +02:00
parent 478e032b0b
commit 93178a24e3
33 changed files with 3686 additions and 322 deletions

View file

@ -1,3 +1,6 @@
node_modules
**/node_modules
dist
.git
admin/.env
public/uploads/*

4
.gitignore vendored
View file

@ -14,6 +14,10 @@ npm-debug.log*
.env
.env.production
# images uploaded via the admin editor (local dev only; production uses a volume)
public/uploads/*
!public/uploads/.gitkeep
# macOS-specific files
.DS_Store

197
DEPLOYMENT_ADMIN.md Normal file
View file

@ -0,0 +1,197 @@
# DEPLOYMENT_ADMIN.md – Login-Editor für larpaso.de
Ergänzt DEPLOYMENT.md um einen eigenen, öffentlich erreichbaren, aber login-geschützten
Editor unter **`https://admin.larpaso.de`**. Dort können Texte, Bilder, Links, Impressum,
Datenschutz sowie Schriftarten/-größen und alle Farben der Seite geändert werden.
„Speichern & veröffentlichen" baut die Seite neu und die Änderung ist sofort auf
`larpaso.de` sichtbar – ohne weiteren Server-Zugriff nötig.
Wie in DEPLOYMENT.md: **Teil A** ist bereits im Repo erledigt (Claude Code) und lokal
getestet. **Teil B** unten führst du selbst per SSH auf dem Server aus – das ist eine
vollständige Schritt-für-Schritt-Anleitung zum Copy-Pasten. Claude Code hat zu keinem
Zeitpunkt Zugriff auf Benutzername/Passwort/Secrets oder den Server selbst.
---
## Was der Editor kann
- **Texte**: alle Überschriften, Absätze, Listen, FAQ, Preis-Punkte, Mappe-Einträge usw.
der Startseite – inklusive Impressum und Datenschutzerklärung.
- **Bilder**: Logo, Mappe-Screenshots, Team-Foto, plus optional eigene Bilder anstelle der
eingebauten Google-Vorschau und des PDF/Tabellen-Vergleichs.
- **Links**: Menüpunkte, E-Mail-Adresse, Kontakt-Angaben.
- **Design**: Schriftart für Überschriften/Fließtext (Auswahl aus gängigen Google Fonts
oder Systemschrift), Schriftgrößen (7 Stufen, per Schieberegler), und *alle* Farben der
Seite einzeln über Hex-Feld, RGB-Schieberegler und Transparenz-Regler.
---
## Was sich technisch ändert
- Neuer Dienst `admin` (Node/Express, `admin/`) mit Login, Bearbeitungsoberfläche und
einem „Veröffentlichen"-Knopf, der intern `npm run build` ausführt.
- Der bestehende `website`-Dienst (nginx) serviert nicht mehr aus dem Image gebackene
Dateien, sondern aus einem **gemeinsamen Volume** (`site-dist`), das der `admin`-Dienst
nach jedem „Veröffentlichen" neu befüllt. Kein Neustart von `website` nötig.
- Bearbeitete Inhalte (`content/site.json`, `content/theme.json`) und hochgeladene Bilder
(`public/uploads/`) liegen auf eigenen Volumes, damit sie ein Image-Update überleben.
- Login: fester Benutzername + Passwort (bcrypt-Hash), Session-Cookie (12h, httpOnly,
Secure, SameSite=Strict), Rate-Limit (5 Fehlversuche → 15 Min. Sperre pro IP).
---
## TEIL A – im Repo (bereits erledigt)
- `admin/` – Editor-Backend + -Oberfläche (Login, Formulare, Bild-Upload, Farb-/Font-/
Größen-Editor, Build-Trigger).
- `content/site.json`, `content/theme.json` – alle editierbaren Inhalte bzw. Design-Werte.
- `Dockerfile.admin` – Image für den `admin`-Dienst.
- `Dockerfile` (angepasst) – `website` seedet ein leeres `site-dist`-Volume beim ersten
Start aus dem gebackenen Build, statt Dateien fest einzubacken.
- `docker-entrypoint-seed.sh`, `docker-entrypoint-admin-seed.sh` – Seed-Skripte für die
Volumes.
- Lokal geprüft: `npm run build` läuft fehlerfrei; Login, Rate-Limit-Sperre, Speichern
(Texte, Bilder, Impressum/Datenschutz, Farben, Schriftarten, Schriftgrößen),
Bild-Upload und Veröffentlichen wurden lokal gegen den Admin-Server End-to-End getestet.
---
## TEIL B – Schritt für Schritt per SSH (machst du)
Alles unten in dieser Reihenfolge ausführen. Wo `<...>` steht, durch echte Werte ersetzen.
### B0. Voraussetzung: Code muss auf dem Server ankommen können
Damit `git pull` in B1 etwas findet, muss dieser Stand vorher nach
`git.larpaso.de/pascal/larpaso-website` gepusht sein. Frag im Chat nach, ob das schon
passiert ist, falls du direkt startest.
### B1. Repo aktualisieren
```bash
ssh deploy@<server>
cd ~/sites/larpaso-website
git pull
```
### B2. Login-Zugangsdaten erzeugen
```bash
cd ~/sites/larpaso-website/admin
npm install # einmalig, installiert die Admin-Abhängigkeiten
node hash-password.js "dein-neues-passwort"
```
Gibt einen bcrypt-Hash aus (beginnt mit `$2a$` oder `$2b$`) – merken/kopieren.
Zufälliges Session-Secret erzeugen:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Auch kopieren.
### B3. `~/stack/admin.env` anlegen (NICHT ins Git-Repo!)
```bash
cat > ~/stack/admin.env <<'EOF'
NODE_ENV=production
ADMIN_USERNAME=pascal
ADMIN_PASSWORD_HASH=<bcrypt-Hash aus B2 hier einfügen>
SESSION_SECRET=<Hex-String aus B2 hier einfügen>
EOF
chmod 600 ~/stack/admin.env
```
### B4. Dienste in `~/stack/docker-compose.yml` ergänzen
```bash
nano ~/stack/docker-compose.yml
```
Beim bestehenden `website`-Dienst ein `volumes:` ergänzen:
```yaml
website:
build:
context: /home/deploy/sites/larpaso-website
restart: unless-stopped
networks: [web]
volumes:
- site-dist:/usr/share/nginx/html
```
Neuen `admin`-Dienst unter `services:` ergänzen:
```yaml
admin:
build:
context: /home/deploy/sites/larpaso-website
dockerfile: Dockerfile.admin
restart: unless-stopped
networks: [web]
env_file:
- /home/deploy/stack/admin.env
volumes:
- site-dist:/site-dist
- admin-content:/app/content
- admin-uploads:/app/public/uploads
```
(Kein `ports:` bei beiden Diensten – Caddy erreicht sie intern über den Container-Namen.)
Im bestehenden **obersten** `volumes:`-Block der Datei ergänzen (nicht neu anlegen):
```yaml
volumes:
site-dist:
admin-content:
admin-uploads:
```
### B5. Caddy-Block ergänzen
```bash
nano ~/stack/caddy/Caddyfile
```
Hinzufügen:
```caddyfile
admin.larpaso.de {
reverse_proxy admin:4321
}
```
### B6. DNS bei IONOS
- Neuer A-Eintrag: Hostname `admin` → Server-IPv4 (gleiche IP wie `@`).
- Kurz warten/prüfen: `nslookup admin.larpaso.de` muss die Server-IP liefern, bevor B7
läuft (sonst bekommt Caddy kein Zertifikat).
### B7. Bauen & starten
```bash
cd ~/stack
docker compose build website admin
docker compose up -d website admin
docker compose restart caddy
docker compose logs -f caddy # auf "certificate obtained successfully" für admin.larpaso.de warten, dann Strg+C
```
### B8. Prüfen
1. `https://admin.larpaso.de/admin/login` öffnen, mit den Zugangsdaten aus B2/B3 anmelden.
2. Eine Kleinigkeit ändern (z. B. Telefonnummer im Abschnitt „Kontakt" oder eine Farbe im
Abschnitt „Design – Farben"), **Speichern & veröffentlichen** klicken.
3. Build-Ausgabe sollte mit „Complete!" enden.
4. `https://larpaso.de` neu laden (ggf. Hard-Reload / Cache leeren) – Änderung sollte
sofort da sein.
Fertig. Ab jetzt kannst du alle Texte, Bilder, Links und das Design selbst über
`admin.larpaso.de` pflegen, ohne mich oder SSH zu brauchen.
---
## Künftige Updates am Code (Admin-Backend selbst)
Ändert sich `admin/` oder das Editor-Frontend am Code (nicht über die Editor-Oberfläche,
sondern durch mich im Chat), reicht danach:
```bash
cd ~/sites/larpaso-website && git pull
cd ~/stack && docker compose build admin && docker compose up -d admin
```
`content/site.json`, `content/theme.json` und hochgeladene Bilder bleiben unangetastet
(liegen auf eigenen Volumes, nicht im Image).
## Bekannte Grenzen
- Nur ein Login (kein Mehrbenutzer-System, keine Passwort-Reset-Funktion in der
Oberfläche – neues Passwort = neuer Hash in `admin.env` + `docker compose up -d admin`).
- Sessions sind In-Memory: Ein Neustart des `admin`-Containers meldet alle ab.
- Es gibt keine Versionshistorie der Inhalte oder des Designs – beides liegt aber auf dem
Server und kann bei Bedarf manuell gesichert werden (z. B. regelmäßiges Backup der
Volumes `admin-content` und `admin-uploads` einplanen, wie für die anderen Volumes auch).
- Schriftarten sind auf eine kuratierte Liste gängiger Google Fonts + Systemschrift
begrenzt (keine eigenen Font-Dateien hochladbar) – bei Bedarf im Chat erweiterbar.

View file

@ -8,6 +8,12 @@ RUN npm run build
# --- Serve-Stage ---
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
# Baked-in default build. /usr/share/nginx/html itself is normally a shared
# volume (see DEPLOYMENT_ADMIN.md) so the admin editor can publish new builds
# without redeploying this container; docker-entrypoint.d seeds it from here
# on first start so the site isn't empty before anyone publishes.
COPY --from=build /app/dist /usr/share/nginx/html-default
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint-seed.sh /docker-entrypoint.d/50-seed-content.sh
RUN chmod +x /docker-entrypoint.d/50-seed-content.sh
EXPOSE 80

32
Dockerfile.admin Normal file
View file

@ -0,0 +1,32 @@
# Runs the login-protected editor (admin/) alongside a full copy of the
# Astro project, so it can run `npm run build` itself when publishing edits.
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY admin/package*.json admin/
RUN npm --prefix admin ci
COPY . .
# content/ (site.json + theme.json) and public/uploads are normally mounted
# as volumes (see DEPLOYMENT_ADMIN.md) so edits persist across redeploys.
# Keep an untouched copy of the defaults so a fresh volume can be seeded.
RUN mkdir -p /app/content.default \
&& cp /app/content/site.json /app/content.default/site.json \
&& cp /app/content/theme.json /app/content.default/theme.json
ENV NODE_ENV=production \
PROJECT_DIR=/app \
CONTENT_PATH=/app/content/site.json \
THEME_PATH=/app/content/theme.json \
UPLOAD_DIR=/app/public/uploads \
DIST_TARGET_DIR=/site-dist
EXPOSE 4321
COPY docker-entrypoint-admin-seed.sh /usr/local/bin/docker-entrypoint-admin-seed.sh
RUN chmod +x /usr/local/bin/docker-entrypoint-admin-seed.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint-admin-seed.sh"]
CMD ["node", "admin/server.js"]

19
admin/.env.example Normal file
View file

@ -0,0 +1,19 @@
# Kopieren nach admin/.env (lokal) bzw. als echte Umgebungsvariablen auf dem Server setzen.
# NIEMALS mit echten Werten committen.
PORT=4321
NODE_ENV=production
# Login
ADMIN_USERNAME=pascal
# Erzeugen mit: node admin/hash-password.js "dein-passwort"
ADMIN_PASSWORD_HASH=
# Zufälligen Wert erzeugen z. B. mit: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=
# Pfade (im Container i.d.R. nicht ändern, siehe DEPLOYMENT_ADMIN.md)
PROJECT_DIR=/app
CONTENT_PATH=/app/content/site.json
THEME_PATH=/app/content/theme.json
UPLOAD_DIR=/app/public/uploads
DIST_TARGET_DIR=/site-dist

9
admin/hash-password.js Normal file
View file

@ -0,0 +1,9 @@
const bcrypt = require('bcryptjs');
const password = process.argv[2];
if (!password) {
console.error('Verwendung: node hash-password.js <passwort>');
process.exit(1);
}
console.log(bcrypt.hashSync(password, 12));

148
admin/lib/auth.js Normal file
View file

@ -0,0 +1,148 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const cookie = require('cookie');
const SESSION_COOKIE = 'admin_session';
const CSRF_COOKIE = 'admin_csrf';
const SESSION_TTL_MS = 12 * 60 * 60 * 1000; // 12h
const MAX_LOGIN_ATTEMPTS = 5;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_LOCKOUT_MS = 15 * 60 * 1000;
const sessions = new Map(); // token -> expiresAt
const loginAttempts = new Map(); // ip -> { count, windowStart, lockedUntil }
function isSecureCookieEnv() {
return process.env.NODE_ENV === 'production' || process.env.FORCE_SECURE_COOKIES === '1';
}
function baseCookieOptions() {
return {
httpOnly: true,
sameSite: 'strict',
secure: isSecureCookieEnv(),
path: '/',
};
}
function createSession() {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, Date.now() + SESSION_TTL_MS);
return token;
}
function destroySession(token) {
if (token) sessions.delete(token);
}
function isValidSession(token) {
if (!token) return false;
const expiresAt = sessions.get(token);
if (!expiresAt) return false;
if (expiresAt < Date.now()) {
sessions.delete(token);
return false;
}
return true;
}
function cleanupSessions() {
const now = Date.now();
for (const [token, expiresAt] of sessions) {
if (expiresAt < now) sessions.delete(token);
}
}
setInterval(cleanupSessions, 10 * 60 * 1000).unref();
function requestIp(req) {
return req.ip || req.socket.remoteAddress || 'unknown';
}
function isLockedOut(req) {
const entry = loginAttempts.get(requestIp(req));
if (!entry) return false;
if (entry.lockedUntil && entry.lockedUntil > Date.now()) return true;
return false;
}
function recordFailedLogin(req) {
const ip = requestIp(req);
const now = Date.now();
let entry = loginAttempts.get(ip);
if (!entry || now - entry.windowStart > LOGIN_WINDOW_MS) {
entry = { count: 0, windowStart: now, lockedUntil: 0 };
}
entry.count += 1;
if (entry.count >= MAX_LOGIN_ATTEMPTS) {
entry.lockedUntil = now + LOGIN_LOCKOUT_MS;
}
loginAttempts.set(ip, entry);
}
function recordSuccessfulLogin(req) {
loginAttempts.delete(requestIp(req));
}
function verifyPassword(username, password) {
const expectedUser = process.env.ADMIN_USERNAME || '';
const expectedHash = process.env.ADMIN_PASSWORD_HASH || '';
if (!expectedUser || !expectedHash) return false;
// Always run bcrypt so response timing doesn't reveal whether the username was correct.
const passwordOk = bcrypt.compareSync(password || '', expectedHash);
return username === expectedUser && passwordOk;
}
function getCookies(req) {
return cookie.parse(req.headers.cookie || '');
}
function requireAuth(req, res, next) {
const cookies = getCookies(req);
if (isValidSession(cookies[SESSION_COOKIE])) {
return next();
}
// req.originalUrl (unlike req.path) always reflects the full request path,
// even inside middleware mounted with a path prefix.
if (req.originalUrl.startsWith('/admin/api/')) {
return res.status(401).json({ error: 'Nicht angemeldet.' });
}
return res.redirect('/admin/login');
}
/** Defense-in-depth CSRF check for state-changing JSON API calls. */
function requireAdminHeader(req, res, next) {
if (req.get('X-Admin-Request') !== '1') {
return res.status(403).json({ error: 'Ungültige Anfrage.' });
}
next();
}
function issueCsrfToken(res) {
const token = crypto.randomBytes(24).toString('hex');
res.cookie(CSRF_COOKIE, token, { sameSite: 'strict', secure: isSecureCookieEnv(), path: '/admin/login' });
return token;
}
function verifyCsrfToken(req) {
const cookies = getCookies(req);
return Boolean(cookies[CSRF_COOKIE]) && cookies[CSRF_COOKIE] === req.body.csrf;
}
module.exports = {
SESSION_COOKIE,
CSRF_COOKIE,
baseCookieOptions,
createSession,
destroySession,
isValidSession,
isLockedOut,
recordFailedLogin,
recordSuccessfulLogin,
verifyPassword,
getCookies,
requireAuth,
requireAdminHeader,
issueCsrfToken,
verifyCsrfToken,
};

62
admin/lib/build.js Normal file
View file

@ -0,0 +1,62 @@
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const PROJECT_DIR = process.env.PROJECT_DIR || path.resolve(__dirname, '..', '..');
const DIST_TARGET_DIR = process.env.DIST_TARGET_DIR || '';
function copyDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/** Runs `npm run build` in the Astro project and, if configured, publishes
* the resulting dist/ into the shared volume nginx serves from. */
function runBuild() {
return new Promise((resolve) => {
let child;
try {
child = spawn('npm', ['run', 'build'], { cwd: PROJECT_DIR, shell: true });
} catch (err) {
resolve({ ok: false, output: '', error: `Build konnte nicht gestartet werden: ${err.message}` });
return;
}
let output = '';
child.stdout.on('data', (chunk) => (output += chunk.toString()));
child.stderr.on('data', (chunk) => (output += chunk.toString()));
child.on('error', (err) => {
resolve({ ok: false, output, error: `Build konnte nicht gestartet werden: ${err.message}` });
});
child.on('close', (code) => {
if (code !== 0) {
resolve({ ok: false, output, error: `Build fehlgeschlagen (Exit-Code ${code}).` });
return;
}
if (DIST_TARGET_DIR) {
try {
const distDir = path.join(PROJECT_DIR, 'dist');
copyDirRecursive(distDir, DIST_TARGET_DIR);
} catch (err) {
resolve({ ok: false, output, error: `Build ok, aber Kopieren fehlgeschlagen: ${err.message}` });
return;
}
}
resolve({ ok: true, output });
});
});
}
module.exports = { runBuild };

View file

@ -0,0 +1,26 @@
const fs = require('fs');
const path = require('path');
const PROJECT_DIR = process.env.PROJECT_DIR || path.resolve(__dirname, '..', '..');
const CONTENT_PATH = process.env.CONTENT_PATH || path.join(PROJECT_DIR, 'content', 'site.json');
const THEME_PATH = process.env.THEME_PATH || path.join(PROJECT_DIR, 'content', 'theme.json');
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, data) {
const tmpPath = `${filePath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, filePath);
}
module.exports = {
readContent: () => readJson(CONTENT_PATH),
writeContent: (data) => writeJson(CONTENT_PATH, data),
readTheme: () => readJson(THEME_PATH),
writeTheme: (data) => writeJson(THEME_PATH, data),
CONTENT_PATH,
THEME_PATH,
PROJECT_DIR,
};

25
admin/lib/fonts.js Normal file
View file

@ -0,0 +1,25 @@
/**
* Curated font list for the "Design" tab's font pickers. Kept in sync by
* hand with src/lib/fonts.ts (duplicated because the Astro site is ESM/TS
* and this backend is CommonJS) — only the `name` values matter here, the
* astro site owns the fallback stacks and Google Fonts URL building.
*/
const FONT_NAMES = [
'Playfair Display',
'Lora',
'Merriweather',
'Libre Baskerville',
'DM Serif Display',
'Georgia (Systemschrift)',
'Inter',
'Roboto',
'Open Sans',
'Poppins',
'Montserrat',
'Nunito',
'Work Sans',
'Source Sans 3',
'System UI (Systemschrift)',
];
module.exports = { FONT_NAMES };

341
admin/lib/schema.js Normal file
View file

@ -0,0 +1,341 @@
const { FONT_NAMES } = require('./fonts');
/**
* Describes the editable shape of content/site.json (file: 'content', the
* default) and content/theme.json (file: 'theme') for the admin UI.
*
* Field types: text, textarea, richtext, email, boolean, image,
* list-string, list-richtext, list-object, group, select, size, color.
* "richtext" fields support a tiny **bold** / *italic* subset (see src/lib/content.ts).
* "group" is a single nested object (not repeatable) described by itemFields.
* "color" fields store { r, g, b, a }. "size" fields store a CSS length (e.g. "1.25rem").
*/
module.exports = [
{
key: 'meta',
label: 'SEO / Meta',
fields: [{ key: 'description', label: 'Meta-Beschreibung', type: 'textarea' }],
},
{
key: 'nav',
label: 'Navigation',
fields: [
{ key: 'logo', label: 'Logo', type: 'image' },
{ key: 'logoAlt', label: 'Logo Alt-Text', type: 'text' },
{ key: 'ctaLabel', label: 'Button-Text', type: 'text' },
{
key: 'links',
label: 'Menüpunkte',
type: 'list-object',
itemLabel: 'Menüpunkt',
itemFields: [
{ key: 'label', label: 'Text', type: 'text' },
{ key: 'anchor', label: 'Sprungziel (Abschnitt-ID, ohne #)', type: 'text' },
],
},
],
},
{
key: 'hero',
label: 'Hero (ganz oben)',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'titleBefore', label: 'Titel – Teil 1', type: 'text' },
{ key: 'titleEmphasis', label: 'Titel – hervorgehobenes Wort', type: 'text' },
{ key: 'titleAfter', label: 'Titel – Teil 2', type: 'text' },
{ key: 'lead', label: 'Einleitungstext', type: 'richtext' },
{ key: 'ctaLabel', label: 'Button-Text', type: 'text' },
{
key: 'visual',
label: 'Google-Vorschau (rechts neben dem Hero-Text)',
type: 'group',
itemFields: [
{
key: 'customImage',
label: 'Eigenes Bild statt der Vorschau (leer = eingebaute Vorschau anzeigen)',
type: 'image',
},
{ key: 'label', label: 'Beschriftung oben', type: 'text' },
{ key: 'todayTag', label: 'Karte 1 – Tag-Label', type: 'text' },
{ key: 'todayUrl', label: 'Karte 1 – URL-Zeile', type: 'text' },
{ key: 'todayTitle', label: 'Karte 1 – Titel', type: 'text' },
{ key: 'todayDesc', label: 'Karte 1 – Beschreibung', type: 'textarea' },
{ key: 'dividerText', label: 'Trenntext zwischen den Karten', type: 'text' },
{ key: 'possibleTag', label: 'Karte 2 – Tag-Label', type: 'text' },
{ key: 'possibleUrl', label: 'Karte 2 – URL-Zeile', type: 'text' },
{ key: 'possibleTitle', label: 'Karte 2 – Titel', type: 'text' },
{ key: 'possibleDesc', label: 'Karte 2 – Beschreibung', type: 'richtext' },
{ key: 'possibleMeta', label: 'Karte 2 – Bewertungszeile', type: 'text' },
],
},
],
},
{
key: 'warum',
label: 'Abschnitt 02 – Warum uns',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Text', type: 'richtext' },
{
key: 'readability',
label: 'PDF-vs-Tabelle-Vergleich',
type: 'group',
itemFields: [
{
key: 'customImage',
label: 'Eigenes Bild statt des Vergleichs (leer = eingebauten Vergleich anzeigen)',
type: 'image',
},
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Text', type: 'richtext' },
{ key: 'beforeLabel', label: 'Label links ("vorher")', type: 'text' },
{ key: 'pdfName', label: 'Dateiname im PDF-Beispiel', type: 'text' },
{ key: 'pdfMeta', label: 'Zusatzzeile im PDF-Beispiel', type: 'text' },
{ key: 'afterLabel', label: 'Label rechts ("nachher")', type: 'text' },
{ key: 'hoursTitle', label: 'Titel der Tabelle', type: 'text' },
{
key: 'hoursRows',
label: 'Tabellenzeilen',
type: 'list-object',
itemLabel: 'Zeile',
itemFields: [
{ key: 'day', label: 'Tag', type: 'text' },
{ key: 'time', label: 'Uhrzeit', type: 'text' },
],
},
],
},
],
},
{
key: 'preis',
label: 'Abschnitt 01 – Preis',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'amount', label: 'Preis', type: 'text' },
{ key: 'amountNote', label: 'Preis-Zusatz', type: 'text' },
{ key: 'lead', label: 'Einleitungstext', type: 'richtext' },
{ key: 'slotsNote', label: 'Plätze-Hinweis', type: 'text' },
{
key: 'features',
label: 'Leistungspunkte',
type: 'list-object',
itemLabel: 'Punkt',
itemFields: [
{ key: 'text', label: 'Text', type: 'text' },
{ key: 'excluded', label: 'Als "nicht enthalten" markieren', type: 'boolean' },
],
},
{ key: 'asideTitle', label: 'Kasten-Überschrift', type: 'text' },
{ key: 'asideParagraph1', label: 'Kasten-Absatz 1', type: 'richtext' },
{ key: 'asideParagraph2', label: 'Kasten-Absatz 2', type: 'richtext' },
{ key: 'ctaLabel', label: 'Button-Text', type: 'text' },
],
},
{
key: 'highlights',
label: 'Abschnitt 02 – Die drei Punkte',
type: 'array-root',
itemLabel: 'Punkt',
itemFields: [
{ key: 'num', label: 'Kennung (z. B. I, II, III)', type: 'text' },
{ key: 'title', label: 'Titel', type: 'text' },
{ key: 'text', label: 'Text', type: 'textarea' },
],
},
{
key: 'leistungen',
label: 'Abschnitt 03 – Leistungen',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Text', type: 'richtext' },
{
key: 'items',
label: 'Leistungen',
type: 'list-object',
itemLabel: 'Leistung',
itemFields: [
{ key: 'num', label: 'Nummer', type: 'text' },
{ key: 'title', label: 'Titel', type: 'text' },
{ key: 'text', label: 'Text', type: 'textarea' },
],
},
{ key: 'nichtMachenTitle', label: '"Was wir nicht machen" – Überschrift', type: 'text' },
{ key: 'nichtMachen', label: '"Was wir nicht machen" – Punkte', type: 'list-string' },
],
},
{
key: 'mappe',
label: 'Abschnitt 04 – Mappe',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{
key: 'items',
label: 'Arbeiten',
type: 'list-object',
itemLabel: 'Arbeit',
itemFields: [
{ key: 'image', label: 'Bildschirmfoto', type: 'image' },
{ key: 'kategorie', label: 'Kategorie', type: 'text' },
{ key: 'titel', label: 'Titel / Name', type: 'text' },
{ key: 'text', label: 'Text', type: 'textarea' },
{ key: 'nameHidden', label: 'Name noch verstecken (Platzhalter zeigen)', type: 'boolean' },
],
},
],
},
{
key: 'ablauf',
label: 'Abschnitt 05 – Ablauf',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{
key: 'steps',
label: 'Schritte',
type: 'list-object',
itemLabel: 'Schritt',
itemFields: [
{ key: 'num', label: 'Nummer', type: 'text' },
{ key: 'title', label: 'Titel', type: 'text' },
{ key: 'text', label: 'Text', type: 'textarea' },
{ key: 'dauer', label: 'Dauer (leer = Platzhalter)', type: 'text' },
],
},
],
},
{
key: 'about',
label: 'Abschnitt 06 – Über uns',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'quote', label: 'Zitat', type: 'text' },
{ key: 'paragraphs', label: 'Absätze', type: 'list-richtext' },
{ key: 'photo', label: 'Foto', type: 'image' },
{ key: 'laraBio', label: 'Lara – Kurzbio (leer = Platzhalter)', type: 'textarea' },
{ key: 'pascalBio', label: 'Pascal – Kurzbio (leer = Platzhalter)', type: 'textarea' },
],
},
{
key: 'faq',
label: 'Abschnitt 07 – FAQ',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{
key: 'items',
label: 'Fragen',
type: 'list-object',
itemLabel: 'Frage',
itemFields: [
{ key: 'question', label: 'Frage', type: 'text' },
{ key: 'answer', label: 'Antwort (leer = Platzhalter)', type: 'textarea' },
],
},
],
},
{
key: 'kontakt',
label: 'Abschnitt 08 – Kontakt',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Einleitungstext', type: 'richtext' },
{ key: 'phone', label: 'Telefonnummer (leer = Platzhalter)', type: 'text' },
{ key: 'hours', label: 'Erreichbarkeit (leer = Platzhalter)', type: 'text' },
{ key: 'email', label: 'E-Mail-Adresse', type: 'email' },
{ key: 'howWeWork', label: '"Wie wir arbeiten" (leer = Platzhalter)', type: 'textarea' },
],
},
{
key: 'footer',
label: 'Footer',
fields: [
{ key: 'laraNachname', label: 'Lara – Nachname (leer = Platzhalter)', type: 'text' },
{ key: 'pascalNachname', label: 'Pascal – Nachname (leer = Platzhalter)', type: 'text' },
],
},
{
key: 'impressum',
label: 'Impressum',
fields: [
{ key: 'anbieterAngaben', label: 'Name/Firma + Anschrift (leer = Platzhalter)', type: 'textarea' },
{ key: 'ustId', label: 'Umsatzsteuer-ID (leer = Platzhalter)', type: 'textarea' },
{ key: 'verantwortlich', label: 'Verantwortlich für den Inhalt (leer = Platzhalter)', type: 'textarea' },
{ key: 'note', label: 'Hinweistext am Seitenende', type: 'textarea' },
],
},
{
key: 'datenschutz',
label: 'Datenschutz',
fields: [
{ key: 'intro', label: 'Einleitung (leer = Platzhalter)', type: 'textarea' },
{
key: 'sections',
label: 'Abschnitte',
type: 'list-object',
itemLabel: 'Abschnitt',
itemFields: [
{ key: 'heading', label: 'Überschrift', type: 'text' },
{
key: 'text',
label: 'Text (leer = Platzhalter; {email} wird durch die Kontakt-E-Mail ersetzt)',
type: 'textarea',
},
],
},
{ key: 'note', label: 'Hinweistext am Seitenende', type: 'textarea' },
],
},
{
key: 'fonts',
file: 'theme',
label: 'Design – Schriftarten',
fields: [
{ key: 'heading', label: 'Überschriften-Schrift', type: 'select', options: FONT_NAMES },
{ key: 'body', label: 'Fließtext-Schrift', type: 'select', options: FONT_NAMES },
],
},
{
key: 'fontSizes',
file: 'theme',
label: 'Design – Schriftgrößen',
fields: [
{ key: 'sm', label: 'Klein (Kicker-Zeilen, Feinschrift)', type: 'size', min: 0.6, max: 1.5, step: 0.025 },
{ key: 'base', label: 'Standard (Fließtext)', type: 'size', min: 0.75, max: 1.5, step: 0.025 },
{ key: 'md', label: 'Mittel (Einleitungstexte)', type: 'size', min: 0.9, max: 1.75, step: 0.025 },
{ key: 'lg', label: 'Groß (H3)', type: 'size', min: 1, max: 2.5, step: 0.025 },
{ key: 'xl', label: 'Größer (H2 mobil)', type: 'size', min: 1.2, max: 3, step: 0.025 },
{ key: '2xl', label: 'Sehr groß (H2)', type: 'size', min: 1.5, max: 4, step: 0.025 },
{ key: '3xl', label: 'Riesig (H1)', type: 'size', min: 1.75, max: 5, step: 0.025 },
],
},
{
key: 'colors',
file: 'theme',
label: 'Design – Farben',
fields: [
{ key: 'bg', label: 'Hintergrund', type: 'color' },
{ key: 'bgAlt', label: 'Hintergrund (Variante)', type: 'color' },
{ key: 'surface', label: 'Flächen / Karten', type: 'color' },
{ key: 'text', label: 'Text', type: 'color' },
{ key: 'textMuted', label: 'Text (gedämpft)', type: 'color' },
{ key: 'border', label: 'Rahmen', type: 'color' },
{ key: 'primary', label: 'Primärfarbe (Buttons, Links)', type: 'color' },
{ key: 'primaryDark', label: 'Primärfarbe (Hover)', type: 'color' },
{ key: 'primaryContrast', label: 'Text auf Primärfarbe', type: 'color' },
{ key: 'accent', label: 'Akzentfarbe', type: 'color' },
{ key: 'accentDark', label: 'Akzentfarbe (dunkel)', type: 'color' },
{ key: 'success', label: 'Erfolg', type: 'color' },
{ key: 'danger', label: 'Fehler/Warnung', type: 'color' },
{ key: 'flag', label: 'Platzhalter-Markierung', type: 'color' },
{ key: 'flagBg', label: 'Platzhalter-Markierung (Hintergrund)', type: 'color' },
{ key: 'googleBlue', label: 'Linkfarbe in der Google-Vorschau', type: 'color' },
],
},
];

114
admin/lib/validate.js Normal file
View file

@ -0,0 +1,114 @@
const schema = require('./schema');
function isPlainObject(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function isFiniteNumber(v) {
return typeof v === 'number' && Number.isFinite(v);
}
function validateItemFields(item, itemFields, pathLabel, errors) {
if (!isPlainObject(item)) {
errors.push(`${pathLabel}: Eintrag muss ein Objekt sein.`);
return;
}
for (const field of itemFields) {
validateField(item[field.key], field, `${pathLabel}.${field.key}`, errors);
}
}
function validateField(value, field, pathLabel, errors) {
switch (field.type) {
case 'text':
case 'textarea':
case 'richtext':
case 'email':
case 'image':
if (typeof value !== 'string') errors.push(`${pathLabel}: erwartet Text.`);
break;
case 'select':
if (typeof value !== 'string') errors.push(`${pathLabel}: erwartet Text.`);
else if (field.options && !field.options.includes(value)) {
errors.push(`${pathLabel}: unbekannter Wert "${value}".`);
}
break;
case 'size':
if (typeof value !== 'string' || !/^-?\d+(\.\d+)?(rem|px|em)$/.test(value.trim())) {
errors.push(`${pathLabel}: erwartet eine CSS-Größe (z. B. "1.25rem").`);
}
break;
case 'color':
if (!isPlainObject(value)) {
errors.push(`${pathLabel}: erwartet eine Farbe.`);
break;
}
for (const channel of ['r', 'g', 'b']) {
if (!isFiniteNumber(value[channel]) || value[channel] < 0 || value[channel] > 255) {
errors.push(`${pathLabel}.${channel}: erwartet eine Zahl von 0 bis 255.`);
}
}
if (!isFiniteNumber(value.a) || value.a < 0 || value.a > 1) {
errors.push(`${pathLabel}.a: erwartet eine Zahl von 0 bis 1.`);
}
break;
case 'boolean':
if (typeof value !== 'boolean') errors.push(`${pathLabel}: erwartet Ja/Nein.`);
break;
case 'list-string':
case 'list-richtext':
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
errors.push(`${pathLabel}: erwartet eine Liste von Texten.`);
}
break;
case 'list-object':
if (!Array.isArray(value)) {
errors.push(`${pathLabel}: erwartet eine Liste.`);
break;
}
value.forEach((item, i) => validateItemFields(item, field.itemFields, `${pathLabel}[${i}]`, errors));
break;
case 'group':
validateItemFields(value, field.itemFields, pathLabel, errors);
break;
default:
break;
}
}
/** Validates a candidate content.json or theme.json payload against the
* admin schema's sections for the given file ('content' or 'theme').
* Returns an array of human-readable error strings (empty = valid). */
function validateAgainstSchema(payload, fileKind) {
const errors = [];
if (!isPlainObject(payload)) {
return ['Inhalt muss ein Objekt sein.'];
}
const sections = schema.filter((s) => (s.file || 'content') === fileKind);
for (const section of sections) {
const value = payload[section.key];
if (section.type === 'array-root') {
if (!Array.isArray(value)) {
errors.push(`${section.key}: erwartet eine Liste.`);
continue;
}
value.forEach((item, i) => validateItemFields(item, section.itemFields, `${section.key}[${i}]`, errors));
} else {
if (!isPlainObject(value)) {
errors.push(`${section.key}: erwartet ein Objekt.`);
continue;
}
for (const field of section.fields) {
validateField(value[field.key], field, `${section.key}.${field.key}`, errors);
}
}
}
return errors;
}
module.exports = {
validateContent: (payload) => validateAgainstSchema(payload, 'content'),
validateTheme: (payload) => validateAgainstSchema(payload, 'theme'),
};

897
admin/package-lock.json generated Normal file
View file

@ -0,0 +1,897 @@
{
"name": "larpaso-admin",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "larpaso-admin",
"version": "0.1.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie": "^0.7.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^2.0.1"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.16.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.13",
"proxy-addr": "~2.0.7",
"qs": "~6.16.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

17
admin/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "larpaso-admin",
"type": "commonjs",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node server.js",
"hash-password": "node hash-password.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie": "^0.7.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^2.0.1"
}
}

488
admin/public/app.js Normal file
View file

@ -0,0 +1,488 @@
(function () {
'use strict';
let schema = [];
let state = { content: null, theme: null };
const statusEl = document.getElementById('status');
const sectionsEl = document.getElementById('sections');
const navEl = document.getElementById('section-nav');
function setStatus(text, kind) {
statusEl.textContent = text;
statusEl.className = 'status' + (kind ? ' ' + kind : '');
}
function el(tag, attrs, children) {
const node = document.createElement(tag);
if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') node.className = value;
else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2), value);
else if (value !== undefined && value !== null) node.setAttribute(key, value);
}
}
(children || []).forEach((child) => {
if (child === null || child === undefined) return;
node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
});
return node;
}
async function api(path, options) {
const res = await fetch(path, {
...options,
headers: {
'X-Admin-Request': '1',
...(options && options.body && !(options.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
...(options ? options.headers : {}),
},
});
if (res.status === 401) {
window.location.href = '/admin/login';
throw new Error('Nicht angemeldet.');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || `Fehler (${res.status})`);
}
return data;
}
// --- Field renderers ---
function textField(field, getValue, setValue) {
const isArea = field.type === 'textarea' || field.type === 'richtext';
const input = isArea
? el('textarea', { rows: 4, oninput: (e) => setValue(e.target.value) })
: el('input', {
type: field.type === 'email' ? 'email' : 'text',
oninput: (e) => setValue(e.target.value),
});
input.value = getValue() || '';
return el('div', { class: 'field' }, [
el('label', {}, [field.label]),
input,
field.type === 'richtext' ? el('p', { class: 'field-hint' }, ['**fett**, *kursiv* möglich']) : null,
]);
}
function selectField(field, getValue, setValue) {
const select = el(
'select',
{ onchange: (e) => setValue(e.target.value) },
(field.options || []).map((opt) => {
const optEl = el('option', { value: opt }, [opt]);
if (opt === getValue()) optEl.selected = true;
return optEl;
})
);
return el('div', { class: 'field' }, [el('label', {}, [field.label]), select]);
}
function sizeField(field, getValue, setValue) {
const raw = getValue() || '1rem';
const numeric = parseFloat(raw) || 1;
const numberOut = el('span', { class: 'size-value' }, [raw]);
const slider = el('input', {
type: 'range',
min: String(field.min ?? 0.5),
max: String(field.max ?? 4),
step: String(field.step ?? 0.025),
oninput: (e) => {
const v = `${e.target.value}rem`;
setValue(v);
numberOut.textContent = v;
},
});
slider.value = String(numeric);
return el('div', { class: 'field' }, [el('label', {}, [field.label]), el('div', { class: 'size-field' }, [slider, numberOut])]);
}
function boolField(field, getValue, setValue) {
const input = el('input', { type: 'checkbox', onchange: (e) => setValue(e.target.checked) });
input.checked = Boolean(getValue());
const id = 'b_' + Math.random().toString(36).slice(2);
input.id = id;
return el('div', { class: 'field field-bool' }, [input, el('label', { for: id }, [field.label])]);
}
function clamp255(n) {
return Math.max(0, Math.min(255, Math.round(n)));
}
function toHex(color) {
const h = (n) => clamp255(n).toString(16).padStart(2, '0');
return `#${h(color.r)}${h(color.g)}${h(color.b)}`;
}
function fromHex(hex, alpha) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
if (!m) return null;
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16), a: alpha };
}
function colorField(field, getValue, setValue) {
const color = Object.assign({ r: 0, g: 0, b: 0, a: 1 }, getValue());
const swatch = el('div', { class: 'color-swatch' });
const hexInput = el('input', { type: 'text', class: 'color-hex' });
const nativePicker = el('input', { type: 'color' });
const channelInputs = {};
const channelNumbers = {};
['r', 'g', 'b'].forEach((ch) => {
channelInputs[ch] = el('input', { type: 'range', min: '0', max: '255', step: '1' });
channelNumbers[ch] = el('span', { class: 'channel-value' });
});
const alphaInput = el('input', { type: 'range', min: '0', max: '100', step: '1' });
const alphaNumber = el('span', { class: 'channel-value' });
function refresh() {
const hex = toHex(color);
swatch.style.background = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
hexInput.value = hex;
nativePicker.value = hex;
['r', 'g', 'b'].forEach((ch) => {
channelInputs[ch].value = String(color[ch]);
channelNumbers[ch].textContent = String(clamp255(color[ch]));
});
alphaInput.value = String(Math.round(color.a * 100));
alphaNumber.textContent = `${Math.round(color.a * 100)}%`;
}
function commit() {
setValue({ ...color });
refresh();
}
hexInput.addEventListener('change', () => {
const parsed = fromHex(hexInput.value, color.a);
if (parsed) {
color.r = parsed.r;
color.g = parsed.g;
color.b = parsed.b;
commit();
} else {
refresh(); // revert invalid input
}
});
nativePicker.addEventListener('input', () => {
const parsed = fromHex(nativePicker.value, color.a);
if (parsed) {
color.r = parsed.r;
color.g = parsed.g;
color.b = parsed.b;
commit();
}
});
['r', 'g', 'b'].forEach((ch) => {
channelInputs[ch].addEventListener('input', (e) => {
color[ch] = clamp255(Number(e.target.value));
commit();
});
});
alphaInput.addEventListener('input', (e) => {
color.a = Math.max(0, Math.min(100, Number(e.target.value))) / 100;
commit();
});
refresh();
return el('div', { class: 'field' }, [
el('label', {}, [field.label]),
el('div', { class: 'color-field' }, [
swatch,
el('div', { class: 'color-field-controls' }, [
el('div', { class: 'color-field-row' }, [nativePicker, hexInput]),
el('div', { class: 'color-channels' }, [
el('span', { class: 'channel-label' }, ['R']),
channelInputs.r,
channelNumbers.r,
el('span', { class: 'channel-label' }, ['G']),
channelInputs.g,
channelNumbers.g,
el('span', { class: 'channel-label' }, ['B']),
channelInputs.b,
channelNumbers.b,
el('span', { class: 'channel-label' }, ['A']),
alphaInput,
alphaNumber,
]),
]),
]),
]);
}
function imageField(field, getValue, setValue) {
const preview = el('img', { class: 'image-preview', alt: '' });
const urlInput = el('input', { type: 'text', placeholder: '/uploads/...' });
const fileInput = el('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp,image/svg+xml', hidden: true });
function refresh() {
const v = getValue() || '';
if (v) preview.setAttribute('src', v);
else preview.removeAttribute('src');
preview.style.visibility = v ? 'visible' : 'hidden';
urlInput.value = v;
}
urlInput.addEventListener('change', () => {
setValue(urlInput.value.trim());
refresh();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
setStatus('Bild wird hochgeladen …');
try {
const data = await api('/admin/api/upload', { method: 'POST', body: formData });
setValue(data.url);
refresh();
setStatus('Bild hochgeladen.', 'ok');
} catch (err) {
setStatus(err.message, 'error');
}
});
const uploadBtn = el('button', { type: 'button', onclick: () => fileInput.click() }, ['Hochladen']);
const clearBtn = el('button', {
type: 'button',
class: 'btn-ghost',
onclick: () => {
setValue('');
refresh();
},
}, ['Entfernen']);
refresh();
return el('div', { class: 'field' }, [
el('label', {}, [field.label]),
el('div', { class: 'image-field' }, [
preview,
el('div', { class: 'image-field-controls' }, [uploadBtn, clearBtn, urlInput, fileInput]),
]),
]);
}
function renderFieldGroup(fields, getObj) {
const container = el('div', {}, []);
fields.forEach((field) => {
const getValue = () => getObj()[field.key];
const setValue = (v) => {
getObj()[field.key] = v;
};
if (field.type === 'boolean') {
container.appendChild(boolField(field, getValue, setValue));
} else if (field.type === 'image') {
container.appendChild(imageField(field, getValue, setValue));
} else if (field.type === 'select') {
container.appendChild(selectField(field, getValue, setValue));
} else if (field.type === 'size') {
container.appendChild(sizeField(field, getValue, setValue));
} else if (field.type === 'color') {
container.appendChild(colorField(field, getValue, setValue));
} else if (field.type === 'group') {
container.appendChild(
el('fieldset', { class: 'group-field' }, [
el('legend', {}, [field.label]),
renderFieldGroup(field.itemFields, () => getObj()[field.key]),
])
);
} else if (field.type === 'list-string' || field.type === 'list-richtext') {
container.appendChild(renderStringList(field, getObj));
} else if (field.type === 'list-object') {
container.appendChild(renderObjectList(field, getObj));
} else {
container.appendChild(textField(field, getValue, setValue));
}
});
return container;
}
function renderStringList(field, getObj) {
const wrap = el('div', { class: 'field' }, [el('label', {}, [field.label])]);
const listEl = el('div', {}, []);
function draw() {
listEl.innerHTML = '';
const arr = getObj()[field.key];
arr.forEach((_, i) => {
const input = field.type === 'list-richtext'
? el('textarea', { rows: 2 })
: el('input', { type: 'text' });
input.value = arr[i];
input.addEventListener('input', (e) => {
arr[i] = e.target.value;
});
const removeBtn = el('button', {
type: 'button',
class: 'remove-btn',
onclick: () => {
arr.splice(i, 1);
draw();
},
}, ['✕']);
listEl.appendChild(el('div', { class: 'list-string-row' }, [input, removeBtn]));
});
}
draw();
const addBtn = el('button', {
type: 'button',
class: 'add-btn',
onclick: () => {
getObj()[field.key].push('');
draw();
},
}, ['+ hinzufügen']);
wrap.appendChild(listEl);
wrap.appendChild(addBtn);
return wrap;
}
function emptyItemFor(itemFields) {
const obj = {};
itemFields.forEach((f) => {
if (f.type === 'boolean') obj[f.key] = false;
else if (f.type === 'list-string' || f.type === 'list-richtext' || f.type === 'list-object') obj[f.key] = [];
else if (f.type === 'color') obj[f.key] = { r: 0, g: 0, b: 0, a: 1 };
else if (f.type === 'group') obj[f.key] = emptyItemFor(f.itemFields);
else obj[f.key] = '';
});
return obj;
}
function renderObjectList(field, getObj) {
const wrap = el('div', { class: 'field' }, [el('label', {}, [field.label])]);
const listEl = el('div', {}, []);
function draw() {
listEl.innerHTML = '';
const arr = getObj()[field.key];
arr.forEach((item, i) => {
const header = el('div', { class: 'list-item-card-header' }, [
el('span', {}, [`${field.itemLabel || 'Eintrag'} ${i + 1}`]),
el('button', {
type: 'button',
class: 'remove-btn',
onclick: () => {
arr.splice(i, 1);
draw();
},
}, ['Entfernen']),
]);
const body = renderFieldGroup(field.itemFields, () => arr[i]);
listEl.appendChild(el('div', { class: 'list-item-card' }, [header, body]));
});
}
draw();
const addBtn = el('button', {
type: 'button',
class: 'add-btn',
onclick: () => {
getObj()[field.key].push(emptyItemFor(field.itemFields));
draw();
},
}, [`+ ${field.itemLabel || 'Eintrag'} hinzufügen`]);
wrap.appendChild(listEl);
wrap.appendChild(addBtn);
return wrap;
}
function renderSection(section) {
const id = 'section-' + section.key;
const fileKind = section.file || 'content';
const block = el('section', { class: 'section-block', id }, [el('h2', {}, [section.label])]);
if (section.type === 'array-root') {
const listField = { key: '__root__', itemLabel: section.itemLabel, itemFields: section.itemFields, label: '' };
const fakeGetObj = () => ({ __root__: state[fileKind][section.key] });
const wrap = renderObjectList(listField, fakeGetObj);
wrap.querySelector('label').remove();
block.appendChild(wrap);
} else {
block.appendChild(renderFieldGroup(section.fields, () => state[fileKind][section.key]));
}
return block;
}
function renderAll() {
sectionsEl.innerHTML = '';
navEl.innerHTML = '';
schema.forEach((section) => {
sectionsEl.appendChild(renderSection(section));
navEl.appendChild(el('a', { href: '#section-' + section.key }, [section.label]));
});
}
async function load() {
setStatus('Lädt …');
const [fetchedSchema, content, theme] = await Promise.all([
api('/admin/api/schema'),
api('/admin/api/content'),
api('/admin/api/theme'),
]);
schema = fetchedSchema;
state = { content, theme };
renderAll();
setStatus('Bereit.');
}
async function save() {
setStatus('Speichert …');
document.getElementById('save-btn').disabled = true;
document.getElementById('publish-btn').disabled = true;
try {
await Promise.all([
api('/admin/api/content', { method: 'PUT', body: JSON.stringify(state.content) }),
api('/admin/api/theme', { method: 'PUT', body: JSON.stringify(state.theme) }),
]);
setStatus('Gespeichert.', 'ok');
return true;
} catch (err) {
setStatus(err.message, 'error');
return false;
} finally {
document.getElementById('save-btn').disabled = false;
document.getElementById('publish-btn').disabled = false;
}
}
async function publish() {
const saved = await save();
if (!saved) return;
setStatus('Veröffentlicht wird gebaut …');
const logEl = document.getElementById('log');
const logOutput = document.getElementById('log-output');
try {
const result = await api('/admin/api/publish', { method: 'POST' });
logOutput.textContent = result.output || '';
logEl.hidden = false;
setStatus('Veröffentlicht.', 'ok');
} catch (err) {
setStatus(err.message, 'error');
logOutput.textContent = err.message;
logEl.hidden = false;
}
}
document.getElementById('save-btn').addEventListener('click', save);
document.getElementById('publish-btn').addEventListener('click', publish);
document.getElementById('log-close').addEventListener('click', () => {
document.getElementById('log').hidden = true;
});
load().catch((err) => setStatus(err.message, 'error'));
})();

35
admin/public/index.html Normal file
View file

@ -0,0 +1,35 @@
<!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, nofollow" />
<title>larpaso · Editor</title>
<link rel="stylesheet" href="/admin/style.css" />
</head>
<body>
<header class="topbar">
<h1>larpaso · Editor</h1>
<div class="topbar-actions">
<span id="status" class="status" aria-live="polite"></span>
<button id="save-btn" type="button">Speichern</button>
<button id="publish-btn" type="button" class="btn-accent">Speichern &amp; veröffentlichen</button>
<form method="POST" action="/admin/logout"><button type="submit" class="btn-ghost">Abmelden</button></form>
</div>
</header>
<nav id="section-nav" class="section-nav" aria-label="Abschnitte"></nav>
<main id="sections"></main>
<div id="log" class="build-log" hidden>
<div class="build-log-header">
<span>Build-Ausgabe</span>
<button id="log-close" type="button" class="btn-ghost">Schließen</button>
</div>
<pre id="log-output"></pre>
</div>
<script src="/admin/app.js"></script>
</body>
</html>

215
admin/public/style.css Normal file
View file

@ -0,0 +1,215 @@
:root {
color-scheme: dark;
--bg: #14110f;
--surface: #1f1a16;
--surface-2: #241e19;
--border: #3a332c;
--text: #f2ede4;
--text-muted: #a89a86;
--accent: #c9a876;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.topbar h1 { font-size: 1.05rem; margin: 0; }
.topbar-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.status { font-size: 0.8rem; color: var(--text-muted); min-width: 6rem; }
.status.error { color: #f2b8b8; }
.status.ok { color: #b8d9a8; }
button {
font: inherit;
cursor: pointer;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
padding: 0.5rem 0.9rem;
}
button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-accent { background: var(--accent); color: #14110f; border-color: var(--accent); font-weight: 600; }
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
.section-nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding: 1rem 1.5rem 0;
}
.section-nav a {
font-size: 0.8rem;
color: var(--text-muted);
text-decoration: none;
padding: 0.3rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--border);
}
.section-nav a:hover { color: var(--accent); border-color: var(--accent); }
main { max-width: 860px; margin: 0 auto; padding: 1.5rem; }
.section-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.section-block h2 { font-size: 1rem; margin: 0 0 1rem; color: var(--accent); }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.3rem; }
.field input[type="text"],
.field input[type="email"],
.field textarea {
width: 100%;
padding: 0.55rem 0.7rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-family: inherit;
font-size: 0.9rem;
}
.field textarea { resize: vertical; min-height: 4.5rem; }
.field-hint { font-size: 0.72rem; color: var(--text-muted); margin-top: 0.25rem; }
.field-bool { display: flex; align-items: center; gap: 0.5rem; }
.field-bool input { width: auto; }
.field-bool label { margin: 0; }
.field select {
width: 100%;
padding: 0.55rem 0.7rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-family: inherit;
font-size: 0.9rem;
}
.size-field { display: flex; align-items: center; gap: 0.75rem; }
.size-field input[type="range"] { flex: 1; }
.size-value { font-size: 0.8rem; color: var(--text-muted); min-width: 3.5rem; text-align: right; }
.group-field {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.9rem 1rem 0.2rem;
margin-bottom: 1rem;
}
.group-field legend { font-size: 0.8rem; color: var(--accent); padding: 0 0.4rem; }
.color-field { display: flex; align-items: flex-start; gap: 0.9rem; flex-wrap: wrap; }
.color-swatch {
width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;
border: 1px solid var(--border);
background-image: linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%);
background-size: 12px 12px;
background-position: 0 0, 0 6px, 6px -6px, -6px 0;
}
.color-field-controls { display: flex; flex-direction: column; gap: 0.5rem; min-width: 220px; flex: 1; }
.color-field-row { display: flex; gap: 0.5rem; align-items: center; }
.color-field-row input[type="color"] { width: 2.5rem; height: 2.2rem; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: none; }
.color-hex { width: 8rem; font-family: monospace; }
.color-channels { display: grid; grid-template-columns: 1rem 1fr 2.4rem; gap: 0.35rem 0.5rem; align-items: center; }
.channel-label { font-size: 0.72rem; color: var(--text-muted); }
.channel-value { font-size: 0.75rem; color: var(--text-muted); text-align: right; }
.image-field { display: flex; align-items: center; gap: 0.9rem; flex-wrap: wrap; }
.image-preview {
width: 84px; height: 84px; border-radius: 8px; object-fit: cover;
background: var(--surface-2); border: 1px dashed var(--border);
}
.image-field-controls { display: flex; flex-direction: column; gap: 0.4rem; }
.image-field-controls input[type="text"] { width: 220px; }
.list-item-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.9rem 1rem;
margin-bottom: 0.75rem;
background: var(--surface-2);
}
.list-item-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
}
.list-item-card-header span { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.list-string-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
.list-string-row input { flex: 1; }
.remove-btn { background: transparent; border-color: transparent; color: #d99; padding: 0.3rem 0.5rem; }
.add-btn { font-size: 0.8rem; }
.build-log {
position: fixed;
inset: auto 1.5rem 1.5rem auto;
width: min(560px, 90vw);
max-height: 60vh;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.build-log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
color: var(--text-muted);
}
.build-log pre {
margin: 0;
padding: 1rem;
overflow: auto;
font-size: 0.75rem;
white-space: pre-wrap;
}

208
admin/server.js Normal file
View file

@ -0,0 +1,208 @@
require('dotenv').config();
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const express = require('express');
const multer = require('multer');
const auth = require('./lib/auth');
const { readContent, writeContent, readTheme, writeTheme, PROJECT_DIR } = require('./lib/content-store');
const { validateContent, validateTheme } = require('./lib/validate');
const schema = require('./lib/schema');
const { runBuild } = require('./lib/build');
const PORT = process.env.PORT || 4321;
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(PROJECT_DIR, 'public', 'uploads');
const ALLOWED_IMAGE_TYPES = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/svg+xml': '.svg',
};
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
if (!process.env.ADMIN_USERNAME || !process.env.ADMIN_PASSWORD_HASH || !process.env.SESSION_SECRET) {
console.error(
'ADMIN_USERNAME, ADMIN_PASSWORD_HASH und SESSION_SECRET müssen gesetzt sein (siehe admin/.env.example).'
);
process.exit(1);
}
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const app = express();
app.set('trust proxy', 1);
app.disable('x-powered-by');
app.use((req, res, next) => {
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
next();
});
app.use(express.json({ limit: '2mb' }));
// --- Login ---
function renderLoginPage({ csrfToken, error, locked }) {
return `<!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, nofollow" />
<title>Anmelden · larpaso Admin</title>
<style>
body { font-family: system-ui, sans-serif; background: #14110f; color: #f2ede4; display: flex; min-height: 100vh; align-items: center; justify-content: center; margin: 0; }
form { background: #1f1a16; padding: 2.5rem; border-radius: 12px; width: min(360px, 90vw); box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
h1 { font-size: 1.25rem; margin: 0 0 1.5rem; }
label { display: block; font-size: 0.85rem; margin-bottom: 0.3rem; color: #c9a876; }
input { width: 100%; box-sizing: border-box; padding: 0.6rem 0.75rem; margin-bottom: 1rem; border-radius: 6px; border: 1px solid #3a332c; background: #14110f; color: #f2ede4; }
button { width: 100%; padding: 0.7rem; border-radius: 6px; border: none; background: #c9a876; color: #14110f; font-weight: 600; cursor: pointer; }
.error { background: #4a1f1f; color: #f2b8b8; padding: 0.6rem 0.75rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.85rem; }
</style>
</head>
<body>
<form method="POST" action="/admin/login">
<h1>larpaso · Admin-Bereich</h1>
${error ? `<div class="error">${error}</div>` : ''}
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" autocomplete="username" required ${locked ? 'disabled' : ''} />
<label for="password">Passwort</label>
<input type="password" id="password" name="password" autocomplete="current-password" required ${locked ? 'disabled' : ''} />
<input type="hidden" name="csrf" value="${csrfToken}" />
<button type="submit" ${locked ? 'disabled' : ''}>Anmelden</button>
</form>
</body>
</html>`;
}
app.get('/admin/login', (req, res) => {
const cookies = auth.getCookies(req);
if (auth.isValidSession(cookies[auth.SESSION_COOKIE])) {
return res.redirect('/admin');
}
const csrfToken = auth.issueCsrfToken(res);
const locked = auth.isLockedOut(req);
res.send(
renderLoginPage({
csrfToken,
locked,
error: locked ? 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' : null,
})
);
});
app.post('/admin/login', express.urlencoded({ extended: false }), (req, res) => {
if (auth.isLockedOut(req)) {
const csrfToken = auth.issueCsrfToken(res);
return res
.status(429)
.send(renderLoginPage({ csrfToken, locked: true, error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' }));
}
if (!auth.verifyCsrfToken(req)) {
const csrfToken = auth.issueCsrfToken(res);
return res.status(403).send(renderLoginPage({ csrfToken, error: 'Sitzung abgelaufen, bitte erneut versuchen.' }));
}
const { username, password } = req.body;
if (!auth.verifyPassword(username, password)) {
auth.recordFailedLogin(req);
const csrfToken = auth.issueCsrfToken(res);
return res.status(401).send(renderLoginPage({ csrfToken, error: 'Benutzername oder Passwort falsch.' }));
}
auth.recordSuccessfulLogin(req);
const token = auth.createSession();
res.cookie(auth.SESSION_COOKIE, token, { ...auth.baseCookieOptions(), maxAge: 12 * 60 * 60 * 1000 });
res.redirect('/admin');
});
app.post('/admin/logout', (req, res) => {
const cookies = auth.getCookies(req);
auth.destroySession(cookies[auth.SESSION_COOKIE]);
res.clearCookie(auth.SESSION_COOKIE, auth.baseCookieOptions());
res.redirect('/admin/login');
});
// --- Protected admin UI + API ---
app.get('/admin', auth.requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/admin/api/schema', auth.requireAuth, (req, res) => {
res.json(schema);
});
app.get('/admin/api/content', auth.requireAuth, (req, res) => {
res.json(readContent());
});
app.put('/admin/api/content', auth.requireAuth, auth.requireAdminHeader, (req, res) => {
const errors = validateContent(req.body);
if (errors.length > 0) {
return res.status(400).json({ error: 'Ungültige Daten.', details: errors });
}
try {
writeContent(req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Speichern fehlgeschlagen: ${err.message}` });
}
});
app.get('/admin/api/theme', auth.requireAuth, (req, res) => {
res.json(readTheme());
});
app.put('/admin/api/theme', auth.requireAuth, auth.requireAdminHeader, (req, res) => {
const errors = validateTheme(req.body);
if (errors.length > 0) {
return res.status(400).json({ error: 'Ungültige Daten.', details: errors });
}
try {
writeTheme(req.body);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Speichern fehlgeschlagen: ${err.message}` });
}
});
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_UPLOAD_BYTES },
});
app.post('/admin/api/upload', auth.requireAuth, auth.requireAdminHeader, upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Keine Datei erhalten.' });
}
const ext = ALLOWED_IMAGE_TYPES[req.file.mimetype];
if (!ext) {
return res.status(400).json({ error: 'Nur JPG, PNG, WebP oder SVG erlaubt.' });
}
const filename = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}${ext}`;
fs.writeFileSync(path.join(UPLOAD_DIR, filename), req.file.buffer);
res.json({ url: `/uploads/${filename}` });
});
app.post('/admin/api/publish', auth.requireAuth, auth.requireAdminHeader, async (req, res) => {
const result = await runBuild();
const output = result.output.slice(-8000);
if (!result.ok) {
return res.status(500).json({ ok: false, error: result.error, output });
}
res.json({ ok: true, output });
});
// Static admin UI assets (index.html, app.js, style.css) — mounted after the
// API routes above so it never intercepts /admin/api/* requests.
app.use('/admin', auth.requireAuth, express.static(path.join(__dirname, 'public')));
app.listen(PORT, () => {
console.log(`larpaso admin läuft auf Port ${PORT}`);
});

323
content/site.json Normal file
View file

@ -0,0 +1,323 @@
{
"meta": {
"description": "Larpaso entwickelt individuelle Websites für Kunden – mit KI-gestützter Entwicklung als Schwerpunkt."
},
"nav": {
"logo": "/logo.jpg",
"logoAlt": "larpaso Webdesign",
"links": [
{
"label": "Warum",
"anchor": "warum"
},
{
"label": "Leistungen",
"anchor": "leistungen"
},
{
"label": "Arbeiten",
"anchor": "arbeiten"
},
{
"label": "Ablauf",
"anchor": "ablauf"
},
{
"label": "Preis",
"anchor": "preis"
}
],
"ctaLabel": "Projekt anfragen"
},
"hero": {
"eyebrow": "Digitalagentur für individuelle Websites",
"titleBefore": "Websites, die zu Ihrem ",
"titleEmphasis": "Unternehmen",
"titleAfter": " passen.",
"lead": "**Gebaut mit KI-gestützter Entwicklung.** larpaso entwickelt individuelle Websites für Kunden aus unterschiedlichen Branchen. Durch den gezielten Einsatz von KI in der Entwicklung liefern wir hochwertige Ergebnisse in kürzerer Zeit, *ohne Kompromisse bei Qualität und Individualität.*",
"ctaLabel": "Projekt anfragen",
"visual": {
"customImage": "",
"label": "Beispiel: Ihr Betrieb bei Google",
"todayTag": "Heute",
"todayUrl": "ihr-betrieb.de › home",
"todayTitle": "Ihr Betrieb – Startseite",
"todayDesc": "Herzlich willkommen auf unserer Homepage. Wir freuen uns über Ihren Besuch …",
"dividerText": "Dieselbe Firma, andere Zeile",
"possibleTag": "Möglich",
"possibleUrl": "ihr-betrieb.de",
"possibleTitle": "Ihr Betrieb in [Ort] – Termine, Preise, Anfahrt",
"possibleDesc": "Geöffnet Mo–Fr 9–18 Uhr. **Termin in zwei Klicks**, Preise stehen auf der Seite, Anfahrt und Parkplätze erklärt.",
"possibleMeta": "★★★★★ [Zahl] Bewertungen · Jetzt geöffnet"
}
},
"warum": {
"eyebrow": "02 · Was uns unterscheidet",
"title": "Individuell, KI-gestützt, technisch solide.",
"lead": "larpaso entwickelt individuelle Websites für Kunden, die sich von der Stange nicht zufriedengeben wollen.",
"readability": {
"customImage": "",
"title": "Lesbar schlägt vollständig.",
"lead": "Dieselben Angaben, zweimal. Einmal als Datei, die man erst herunterladen muss. Einmal als Tabelle, die schon da ist. Angerufen wird nach dem zweiten Weg.",
"beforeLabel": "So sieht es heute oft aus",
"pdfName": "Oeffnungszeiten_2026_final_v3.pdf",
"pdfMeta": "2,4 MB · zum Herunterladen",
"afterLabel": "So sieht es nachher aus",
"hoursTitle": "Öffnungszeiten",
"hoursRows": [
{
"day": "Montag – Donnerstag",
"time": "9:00–18:00"
},
{
"day": "Freitag",
"time": "9:00–14:00"
}
]
}
},
"preis": {
"eyebrow": "01 · Drei Plätze, dann ist Schluss",
"title": "Wir brauchen drei Seiten, die wir herzeigen können.",
"amount": "500 €",
"amountNote": "komplett, einmalig",
"lead": "Eine fertige Website statt eines Angebots über „Konzeptphasen“. Wir schreiben die Texte, bauen die Seite und stellen sie online. Sie sagen uns, was Sie machen und für wen.",
"slotsNote": "Noch [3] von 3 Plätzen frei — Stand [Datum].",
"features": [
{
"text": "Eine Seite mit allem, was Kunden suchen: Angebot, Preise, Zeiten, Anfahrt, Kontakt",
"excluded": false
},
{
"text": "Auf dem Handy zuerst gebaut, nicht nachträglich zusammengeschoben",
"excluded": false
},
{
"text": "Impressum und Datenschutzerklärung eingebaut, Schriften auf Ihrem Server",
"excluded": false
},
{
"text": "Alle Texte von uns geschrieben, nach einem Gespräch mit Ihnen",
"excluded": false
},
{
"text": "Google-Grundlagen: Titel mit Ort, Beschreibung, Firmendaten für die Kartenanzeige",
"excluded": false
},
{
"text": "Einweisung, wie Sie Zeiten und Preise selbst ändern",
"excluded": false
},
{
"text": "Nicht enthalten: Fotoshooting, Logo-Neuentwurf, Onlineshop",
"excluded": true
},
{
"text": "Nicht enthalten: Domain und Hosting. Das läuft auf Ihren Namen und kostet [ca. Betrag] € im Jahr",
"excluded": true
}
],
"asideTitle": "Warum so günstig?",
"asideParagraph1": "Weil wir am Anfang stehen und Arbeiten zeigen müssen, die echten Betrieben gehören. Dafür bitten wir um zwei Dinge: Wir dürfen die fertige Seite in unserer Mappe zeigen, und wenn Sie zufrieden sind, schreiben Sie uns drei Sätze, die wir zitieren dürfen.",
"asideParagraph2": "Kein Abo und keine Vertragslaufzeit. Sie zahlen die Hälfte zum Start, die Hälfte, wenn die Seite online ist.",
"ctaLabel": "Projekt anfragen"
},
"highlights": [
{
"num": "I",
"title": "Individuell entwickelt",
"text": "Keine Templates von der Stange — jede Website wird passend zu Zielen, Marke und Zielgruppe konzipiert und umgesetzt."
},
{
"num": "II",
"title": "KI-gestützte Entwicklung",
"text": "Wir setzen moderne KI-Werkzeuge im Entwicklungsprozess ein, um schneller, gründlicher und zu planbaren Kosten zu liefern."
},
{
"num": "III",
"title": "Technisch solide",
"text": "Saubere, wartbare Umsetzung mit Blick auf Performance, Zugänglichkeit und langfristige Pflegbarkeit."
}
],
"leistungen": {
"eyebrow": "03 · Was Sie bekommen",
"title": "Was wir für Sie tun können",
"lead": "Von der ersten Idee bis zur laufenden Betreuung: larpaso begleitet Ihr Website-Projekt mit individueller Entwicklung und dem gezielten Einsatz von KI-Werkzeugen.",
"items": [
{
"num": "01",
"title": "Individuelle Websites",
"text": "Konzeption und Umsetzung von Websites, die auf Ihr Unternehmen, Ihre Zielgruppe und Ihre Ziele zugeschnitten sind — von der einfachen Unternehmenspräsenz bis zur komplexeren Webanwendung."
},
{
"num": "02",
"title": "KI-gestützte Entwicklung",
"text": "Wir nutzen moderne KI-Werkzeuge gezielt im Entwicklungsprozess, um Umsetzungszeiten zu verkürzen und gleichzeitig Code-Qualität und Sorgfalt hochzuhalten."
},
{
"num": "03",
"title": "Konzeption & Beratung",
"text": "Vor der Umsetzung klären wir Struktur, Inhalte und technische Anforderungen — damit die Website von Anfang an zu Ihren Zielen passt."
},
{
"num": "04",
"title": "Redesign & Weiterentwicklung",
"text": "Bestehende Websites überarbeiten, modernisieren oder um neue Funktionen erweitern — technisch fundiert und mit klarem Fokus auf Nutzbarkeit."
},
{
"num": "05",
"title": "Wartung & Support",
"text": "Nach dem Launch lassen wir Sie nicht allein: Updates, kleinere Anpassungen und technischer Support nach Bedarf."
},
{
"num": "06",
"title": "Performance & Zugänglichkeit",
"text": "Websites, die schnell laden und für möglichst viele Nutzerinnen und Nutzer gut zugänglich sind — als Grundlage, nicht als nachträgliches Extra."
}
],
"nichtMachenTitle": "Was wir nicht machen",
"nichtMachen": [
"Keine Onlineshops. Dafür gibt es Leute, die nichts anderes tun.",
"Keine Fotos, die es nicht gibt: weder gekaufte Modelle in fremden Räumen noch am Rechner erfundene. Ihre Kunden sollen wiedererkennen, wo sie hinkommen.",
"Keine Versprechen über Google-Plätze. Wer die gibt, weiß es entweder nicht besser oder hofft, dass Sie nicht nachrechnen.",
"Keine zehn Seiten, wenn eine reicht. Die meisten Betriebe brauchen weniger, als ihnen verkauft wird."
]
},
"mappe": {
"eyebrow": "04 · Mappe",
"title": "Drei Entwürfe, an denen Sie unsere Handschrift sehen.",
"items": [
{
"kategorie": "Konzeptarbeit · Tanzstudio",
"titel": "Maison Verte",
"text": "Sand und Salbei. Der Stundenplan steht als Tabelle auf der Seite, nicht als Download, und die Preise auch. Für ein Studio, das ruhig wirken soll, ohne dabei alt auszusehen.",
"nameHidden": false,
"image": ""
},
{
"kategorie": "Konzeptarbeit · Tanzschule",
"titel": "Tanzhaus Kranich",
"text": "Dieselbe Seite zweimal, einmal kühl und einmal warm. Wir wollten wissen, was Farbe wirklich anrichtet. Der Unterschied war größer, als wir dachten. Seitdem schlagen wir Ihnen am Ende eine Fassung vor und nicht drei.",
"nameHidden": false,
"image": ""
},
{
"kategorie": "Konzeptarbeit · Kosmetikstudio",
"titel": "",
"text": "Weich, warm, und trotzdem kein Pastellrosa. Jede Behandlung mit Dauer und Preis, Termin direkt aus der Liste heraus. Damit niemand erst anrufen muss, um zu erfahren, was etwas kostet.",
"nameHidden": true,
"image": ""
}
]
},
"ablauf": {
"eyebrow": "05 · So arbeiten wir",
"title": "Vom ersten Gespräch bis zum Launch",
"steps": [
{
"num": "01",
"title": "Kennenlernen",
"text": "Wir klären Ziele, Zielgruppe und Anforderungen in einem unverbindlichen Erstgespräch.",
"dauer": ""
},
{
"num": "02",
"title": "Konzept & Design",
"text": "Struktur, Inhalte und visuelle Richtung werden gemeinsam abgestimmt.",
"dauer": ""
},
{
"num": "03",
"title": "Entwicklung",
"text": "Umsetzung mit modernen Werkzeugen — inklusive KI-gestützter Entwicklung für mehr Tempo.",
"dauer": "2–3 Tage"
},
{
"num": "04",
"title": "Launch & Betreuung",
"text": "Übergabe, Feinschliff und bei Bedarf laufende Weiterentwicklung.",
"dauer": ""
}
]
},
"about": {
"eyebrow": "06 · Über uns",
"title": "Wir sind larpaso.",
"quote": "KI als Werkzeug, nicht als Ausrede.",
"paragraphs": [
"Technik ändert sich schnell. Und mit ihr, was Kundinnen und Kunden von einem Betrieb erwarten: eine Seite, die auf dem Handy funktioniert, klar sagt, worum es geht, und nicht nach gestern aussieht. Was heute auffällt, ist in zwei Jahren Standard. Wir bauen Websites für Menschen, die da mithalten wollen, ohne sich selbst in Technik einarbeiten zu müssen.",
"Dabei helfen uns KI-Werkzeuge. Sie nehmen uns Routine ab, damit wir Zeit haben für das, worauf es ankommt: zuhören, mitdenken, sorgfältig arbeiten. So bleiben individuelle Lösungen in überschaubarer Zeit und zu planbaren Kosten möglich, weder Massenware noch Luxusgut.",
"Der Name ist aus uns gebaut: **Lara** und **Pas**cal. Und die zweite Hälfte ist ein Wort: *paso*, spanisch für Schritt. Für uns steht es für Fortschritt. Wir lernen weiter, entwickeln unsere Arbeit weiter und Ihre Seite gleich mit, Schritt für Schritt."
],
"photo": "",
"laraBio": "",
"pascalBio": ""
},
"faq": {
"eyebrow": "07 · Bevor Sie fragen",
"title": "Die Punkte, an denen die meisten zögern.",
"items": [
{
"question": "Kann ich die Seite danach selbst ändern?",
"answer": ""
},
{
"question": "Was, wenn mir das Ergebnis nicht gefällt?",
"answer": ""
},
{
"question": "Ich habe keine guten Fotos. Ist das ein Problem?",
"answer": ""
},
{
"question": "Müssen wir uns dafür treffen?",
"answer": ""
},
{
"question": "Wie lange dauert es insgesamt?",
"answer": ""
},
{
"question": "Was kostet es danach im Jahr?",
"answer": ""
},
{
"question": "Ich habe schon eine Website. Lohnt sich das trotzdem?",
"answer": ""
}
]
},
"kontakt": {
"eyebrow": "08 · Kontakt",
"title": "Lassen Sie uns sprechen.",
"lead": "Erzählen Sie uns kurz von Ihrem Projekt — wir melden uns zeitnah bei Ihnen zurück. Ein Erstgespräch ist unverbindlich und kostet Sie nichts.",
"phone": "",
"hours": "",
"email": "kontakt@larpaso.de",
"howWeWork": ""
},
"footer": {
"laraNachname": "",
"pascalNachname": ""
},
"impressum": {
"anbieterAngaben": "",
"ustId": "",
"verantwortlich": "",
"note": "Dieses Impressum ist noch nicht vollständig und muss vor dem Livegang mit den echten Angaben ergänzt werden."
},
"datenschutz": {
"intro": "",
"sections": [
{
"heading": "Verantwortliche Stelle",
"text": ""
},
{
"heading": "Kontaktformular",
"text": "Das Kontaktformular auf dieser Website öffnet beim Absenden Ihr E-Mail-Programm mit einem vorausgefüllten Entwurf. Ihre Angaben werden dabei direkt an {email} adressiert und nicht an einen Server dieser Website oder Dritte übertragen."
}
],
"note": "Diese Datenschutzerklärung ist noch nicht vollständig und muss vor dem Livegang ergänzt bzw. rechtlich geprüft werden."
}
}

113
content/theme.json Normal file
View file

@ -0,0 +1,113 @@
{
"fonts": {
"heading": "Playfair Display",
"body": "Inter"
},
"fontSizes": {
"sm": "0.875rem",
"base": "1rem",
"md": "1.125rem",
"lg": "1.375rem",
"xl": "1.75rem",
"2xl": "2.25rem",
"3xl": "3rem"
},
"colors": {
"bg": {
"r": 23,
"g": 19,
"b": 15,
"a": 1
},
"bgAlt": {
"r": 28,
"g": 23,
"b": 18,
"a": 1
},
"surface": {
"r": 33,
"g": 27,
"b": 21,
"a": 1
},
"text": {
"r": 242,
"g": 234,
"b": 217,
"a": 1
},
"textMuted": {
"r": 168,
"g": 154,
"b": 133,
"a": 1
},
"border": {
"r": 201,
"g": 168,
"b": 118,
"a": 0.2
},
"primary": {
"r": 201,
"g": 168,
"b": 118,
"a": 1
},
"primaryDark": {
"r": 176,
"g": 143,
"b": 92,
"a": 1
},
"primaryContrast": {
"r": 26,
"g": 21,
"b": 17,
"a": 1
},
"accent": {
"r": 201,
"g": 168,
"b": 118,
"a": 1
},
"accentDark": {
"r": 176,
"g": 143,
"b": 92,
"a": 1
},
"success": {
"r": 122,
"g": 154,
"b": 107,
"a": 1
},
"danger": {
"r": 201,
"g": 123,
"b": 100,
"a": 1
},
"flag": {
"r": 201,
"g": 123,
"b": 100,
"a": 1
},
"flagBg": {
"r": 201,
"g": 123,
"b": 100,
"a": 0.1
},
"googleBlue": {
"r": 143,
"g": 178,
"b": 232,
"a": 1
}
}
}

View file

@ -0,0 +1,18 @@
#!/bin/sh
set -e
# content/ lives on a persistent volume; seed each file from the image's
# untouched default on first run so a fresh volume isn't missing them.
mkdir -p /app/content
if [ ! -f /app/content/site.json ]; then
echo "Seeding content/site.json from image default..."
cp /app/content.default/site.json /app/content/site.json
fi
if [ ! -f /app/content/theme.json ]; then
echo "Seeding content/theme.json from image default..."
cp /app/content.default/theme.json /app/content/theme.json
fi
exec "$@"

10
docker-entrypoint-seed.sh Normal file
View file

@ -0,0 +1,10 @@
#!/bin/sh
set -e
# /usr/share/nginx/html is a shared volume with the admin container so
# published rebuilds show up without redeploying this container. On a fresh
# volume it's empty, so seed it once from the image's baked-in build.
if [ -z "$(ls -A /usr/share/nginx/html 2>/dev/null)" ]; then
echo "Seeding /usr/share/nginx/html from baked-in default build..."
cp -r /usr/share/nginx/html-default/. /usr/share/nginx/html/
fi

0
public/uploads/.gitkeep Normal file
View file

View file

@ -1,12 +1,16 @@
---
import { site } from '../lib/content';
const year = new Date().getFullYear();
const footer = site.footer;
---
<footer class="footer">
<div class="container footer-bottom">
<p>
&copy; {year} larpaso · Individuelle Websites, KI-gestützt entwickelt · Lara
{' '}<span class="placeholder">[Nachname]</span> und Pascal <span class="placeholder">[Nachname]</span>
{' '}{footer.laraNachname || <span class="placeholder">[Nachname]</span>} und Pascal{' '}
{footer.pascalNachname || <span class="placeholder">[Nachname]</span>}
</p>
<nav class="footer-nav" aria-label="Footer-Navigation">

View file

@ -1,17 +1,13 @@
---
const navItems = [
{ href: '#warum', label: 'Warum' },
{ href: '#leistungen', label: 'Leistungen' },
{ href: '#arbeiten', label: 'Arbeiten' },
{ href: '#ablauf', label: 'Ablauf' },
{ href: '#preis', label: 'Preis' },
];
import { site } from '../lib/content';
const nav = site.nav;
---
<header class="nav">
<div class="container nav-inner">
<a href="/" class="nav-logo">
<img src="/logo.jpg" alt="larpaso Webdesign" width="320" height="60" class="nav-logo-img" />
<img src={nav.logo} alt={nav.logoAlt} width="320" height="60" class="nav-logo-img" />
</a>
<input type="checkbox" id="nav-toggle" class="nav-toggle-checkbox" />
@ -23,13 +19,13 @@ const navItems = [
<nav class="nav-links" aria-label="Hauptnavigation">
<ul>
{navItems.map((item) => (
{nav.links.map((item) => (
<li>
<a href={item.href}>{item.label}</a>
<a href={`#${item.anchor}`}>{item.label}</a>
</li>
))}
</ul>
<a class="btn btn-primary nav-cta" href="#kontakt">Projekt anfragen</a>
<a class="btn btn-primary nav-cta" href="#kontakt">{nav.ctaLabel}</a>
</nav>
</div>
</header>

View file

@ -2,16 +2,15 @@
import Nav from '../components/Nav.astro';
import Footer from '../components/Footer.astro';
import '../styles/global.css';
import { site, theme, themeCss, googleFontsHref } from '../lib/content';
interface Props {
title: string;
description?: string;
}
const {
title,
description = 'Larpaso entwickelt individuelle Websites für Kunden – mit KI-gestützter Entwicklung als Schwerpunkt.',
} = Astro.props;
const { title, description = site.meta.description } = Astro.props;
const fontsHref = googleFontsHref(theme);
---
<!doctype html>
@ -23,12 +22,14 @@ const {
<meta name="description" content={description} />
<title>{title} · Larpaso</title>
{fontsHref && (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,500;0,600;0,700;1,500;1,600&display=swap"
rel="stylesheet"
/>
<link href={fontsHref} rel="stylesheet" />
</>
)}
<style set:html={themeCss(theme)} />
</head>
<body>
<Nav />

94
src/lib/content.ts Normal file
View file

@ -0,0 +1,94 @@
import siteData from '../../content/site.json';
import themeData from '../../content/theme.json';
import { fontFamilyCss, googleFontsHref as buildGoogleFontsHref } from './fonts';
export const site = siteData;
export const theme = themeData;
const COLOR_VAR_NAMES: Record<string, string> = {
bg: '--color-bg',
bgAlt: '--color-bg-alt',
surface: '--color-surface',
text: '--color-text',
textMuted: '--color-text-muted',
border: '--color-border',
primary: '--color-primary',
primaryDark: '--color-primary-dark',
primaryContrast: '--color-primary-contrast',
accent: '--color-accent',
accentDark: '--color-accent-dark',
success: '--color-success',
danger: '--color-danger',
flag: '--color-flag',
flagBg: '--color-flag-bg',
googleBlue: '--color-google-blue',
};
const FONT_SIZE_VAR_NAMES: Record<string, string> = {
sm: '--font-size-sm',
base: '--font-size-base',
md: '--font-size-md',
lg: '--font-size-lg',
xl: '--font-size-xl',
'2xl': '--font-size-2xl',
'3xl': '--font-size-3xl',
};
function rgba({ r, g, b, a }: { r: number; g: number; b: number; a: number }): string {
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/** Builds a `:root` override block (with !important, so it always wins over
* global.css's defaults regardless of stylesheet load order) from theme.json. */
export function themeCss(t: typeof theme): string {
const lines: string[] = [':root {'];
for (const [key, varName] of Object.entries(COLOR_VAR_NAMES)) {
const color = (t.colors as Record<string, { r: number; g: number; b: number; a: number }>)[key];
if (color) lines.push(` ${varName}: ${rgba(color)} !important;`);
}
for (const [key, varName] of Object.entries(FONT_SIZE_VAR_NAMES)) {
const size = (t.fontSizes as Record<string, string>)[key];
if (size) lines.push(` ${varName}: ${size} !important;`);
}
lines.push(` --font-serif: ${fontFamilyCss(t.fonts.heading)} !important;`);
lines.push(` --font-sans: ${fontFamilyCss(t.fonts.body)} !important;`);
lines.push('}');
return lines.join('\n');
}
export function googleFontsHref(t: typeof theme): string | null {
return buildGoogleFontsHref(t.fonts.heading, t.fonts.body);
}
/** Replaces a literal `{email}` token with a mailto link, for legal-page
* text where the email address needs to stay in sync with content.kontakt.email. */
export function mdWithEmail(input: string | undefined | null, email: string): string {
const html = md(input);
const link = `<a href="mailto:${escapeAttr(email)}">${escapeHtml(email)}</a>`;
return html.split('{email}').join(link);
}
function escapeAttr(input: string): string {
return escapeHtml(input).replace(/"/g, '&quot;');
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Escapes HTML, then supports a tiny **bold** / *italic* subset so editors
* can add emphasis without being able to inject arbitrary markup.
*/
export function md(input: string | undefined | null): string {
if (!input) return '';
let html = escapeHtml(input);
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
return html;
}

51
src/lib/fonts.ts Normal file
View file

@ -0,0 +1,51 @@
export interface FontOption {
name: string;
category: 'serif' | 'sans';
google: boolean; // false = system font, no Google Fonts request
fallback: string;
}
/**
* Curated font list for the admin "Design" editor. Kept in sync by hand with
* admin/lib/fonts.js (duplicated rather than shared because the Astro site
* is ESM/TS and the admin backend is CommonJS).
*/
export const FONT_OPTIONS: FontOption[] = [
{ name: 'Playfair Display', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Lora', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Merriweather', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Libre Baskerville', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'DM Serif Display', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Georgia (Systemschrift)', category: 'serif', google: false, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Inter', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Roboto', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Open Sans', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Poppins', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Montserrat', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Nunito', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Work Sans', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Source Sans 3', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'System UI (Systemschrift)', category: 'sans', google: false, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
];
export function findFont(name: string): FontOption {
return FONT_OPTIONS.find((f) => f.name === name) || FONT_OPTIONS[0];
}
export function fontFamilyCss(name: string): string {
const font = findFont(name);
return font.google ? `'${font.name}', ${font.fallback}` : font.fallback;
}
/** Builds the Google Fonts CSS2 stylesheet URL for the currently selected
* heading/body fonts (system fonts are skipped — nothing to fetch). */
export function googleFontsHref(headingFont: string, bodyFont: string): string | null {
const families = [headingFont, bodyFont]
.map(findFont)
.filter((f) => f.google)
.filter((f, i, arr) => arr.findIndex((g) => g.name === f.name) === i)
.map((f) => `family=${f.name.replace(/ /g, '+')}:wght@400;500;600;700`);
if (families.length === 0) return null;
return `https://fonts.googleapis.com/css2?${families.join('&')}&display=swap`;
}

View file

@ -1,5 +1,10 @@
---
import Layout from '../layouts/Layout.astro';
import { site, mdWithEmail } from '../lib/content';
const { datenschutz, kontakt } = site;
const introFallback =
'[Diese Seite ist noch ein Platzhalter. Vor dem Livegang durch eine vollständige, rechtlich geprüfte Datenschutzerklärung ersetzen — u. a. mit Angaben zu: verantwortlicher Stelle, Kontaktformular/Mailto-Versand, ggf. eingesetzten Diensten (Hosting, Schriftarten, Analyse-Tools), Speicherdauer und Betroffenenrechten.]';
---
<Layout title="Datenschutz" description="Datenschutzerklärung von larpaso.">
@ -8,30 +13,18 @@ import Layout from '../layouts/Layout.astro';
<p class="eyebrow">Datenschutz</p>
<h1>Datenschutzerklärung</h1>
<p class="placeholder">
[Diese Seite ist noch ein Platzhalter. Vor dem Livegang durch eine vollständige, rechtlich
geprüfte Datenschutzerklärung ersetzen — u. a. mit Angaben zu: verantwortlicher Stelle,
Kontaktformular/Mailto-Versand, ggf. eingesetzten Diensten (Hosting, Schriftarten,
Analyse-Tools), Speicherdauer und Betroffenenrechten.]
<p class={datenschutz.intro ? undefined : 'placeholder'}>
{datenschutz.intro || introFallback}
</p>
<h2>Verantwortliche Stelle</h2>
<p class="placeholder">
[Name / Firmenname, Anschrift, E-Mail — noch zu ergänzen]
</p>
{datenschutz.sections.map((section) => (
<>
<h2>{section.heading}</h2>
<p class={section.text ? undefined : 'placeholder'} set:html={mdWithEmail(section.text, kontakt.email) || '[Text noch zu ergänzen]'} />
</>
))}
<h2>Kontaktformular</h2>
<p>
Das Kontaktformular auf dieser Website öffnet beim Absenden Ihr E-Mail-Programm mit einem
vorausgefüllten Entwurf. Ihre Angaben werden dabei direkt an
<a href="mailto:kontakt@larpaso.de">kontakt@larpaso.de</a> adressiert und nicht an einen
Server dieser Website oder Dritte übertragen.
</p>
<p class="legal-note">
Diese Datenschutzerklärung ist noch nicht vollständig und muss vor dem Livegang ergänzt
bzw. rechtlich geprüft werden.
</p>
<p class="legal-note">{datenschutz.note}</p>
</div>
</section>
</Layout>

View file

@ -1,5 +1,8 @@
---
import Layout from '../layouts/Layout.astro';
import { site } from '../lib/content';
const { impressum, kontakt } = site;
---
<Layout title="Impressum" description="Impressum von larpaso.">
@ -8,33 +11,30 @@ import Layout from '../layouts/Layout.astro';
<p class="eyebrow">Impressum</p>
<h1>Angaben gemäß § 5 TMG</h1>
<p class="placeholder">
[Vollständiger Name / Firmenname, Rechtsform falls vorhanden — noch zu ergänzen]<br />
[Straße und Hausnummer — noch zu ergänzen]<br />
[PLZ und Ort — noch zu ergänzen]
<p class={impressum.anbieterAngaben ? undefined : 'placeholder'} style="white-space: pre-line">
{impressum.anbieterAngaben ||
'[Vollständiger Name / Firmenname, Rechtsform falls vorhanden — noch zu ergänzen]\n[Straße und Hausnummer — noch zu ergänzen]\n[PLZ und Ort — noch zu ergänzen]'}
</p>
<h2>Kontakt</h2>
<p>
E-Mail: <a href="mailto:kontakt@larpaso.de">kontakt@larpaso.de</a><br />
Telefon: <span class="placeholder">[Telefonnummer eintragen]</span>
E-Mail: <a href={`mailto:${kontakt.email}`}>{kontakt.email}</a><br />
Telefon: {kontakt.phone
? kontakt.phone
: <span class="placeholder">[Telefonnummer eintragen]</span>}
</p>
<h2>Umsatzsteuer-ID</h2>
<p class="placeholder">
[Falls vorhanden: Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG — sonst Absatz
entfernen]
<p class={impressum.ustId ? undefined : 'placeholder'}>
{impressum.ustId || '[Falls vorhanden: Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG — sonst Absatz entfernen]'}
</p>
<h2>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<p class="placeholder">
[Name, Anschrift wie oben — noch zu ergänzen]
<p class={impressum.verantwortlich ? undefined : 'placeholder'}>
{impressum.verantwortlich || '[Name, Anschrift wie oben — noch zu ergänzen]'}
</p>
<p class="legal-note">
Dieses Impressum ist noch nicht vollständig und muss vor dem Livegang mit den echten
Angaben ergänzt werden.
</p>
<p class="legal-note">{impressum.note}</p>
</div>
</section>
</Layout>

View file

@ -1,101 +1,8 @@
---
import Layout from '../layouts/Layout.astro';
import { site, md } from '../lib/content';
const highlights = [
{
num: 'I',
title: 'Individuell entwickelt',
text: 'Keine Templates von der Stange — jede Website wird passend zu Zielen, Marke und Zielgruppe konzipiert und umgesetzt.',
},
{
num: 'II',
title: 'KI-gestützte Entwicklung',
text: 'Wir setzen moderne KI-Werkzeuge im Entwicklungsprozess ein, um schneller, gründlicher und zu planbaren Kosten zu liefern.',
},
{
num: 'III',
title: 'Technisch solide',
text: 'Saubere, wartbare Umsetzung mit Blick auf Performance, Zugänglichkeit und langfristige Pflegbarkeit.',
},
];
const leistungen = [
{
num: '01',
title: 'Individuelle Websites',
text: 'Konzeption und Umsetzung von Websites, die auf Ihr Unternehmen, Ihre Zielgruppe und Ihre Ziele zugeschnitten sind — von der einfachen Unternehmenspräsenz bis zur komplexeren Webanwendung.',
},
{
num: '02',
title: 'KI-gestützte Entwicklung',
text: 'Wir nutzen moderne KI-Werkzeuge gezielt im Entwicklungsprozess, um Umsetzungszeiten zu verkürzen und gleichzeitig Code-Qualität und Sorgfalt hochzuhalten.',
},
{
num: '03',
title: 'Konzeption & Beratung',
text: 'Vor der Umsetzung klären wir Struktur, Inhalte und technische Anforderungen — damit die Website von Anfang an zu Ihren Zielen passt.',
},
{
num: '04',
title: 'Redesign & Weiterentwicklung',
text: 'Bestehende Websites überarbeiten, modernisieren oder um neue Funktionen erweitern — technisch fundiert und mit klarem Fokus auf Nutzbarkeit.',
},
{
num: '05',
title: 'Wartung & Support',
text: 'Nach dem Launch lassen wir Sie nicht allein: Updates, kleinere Anpassungen und technischer Support nach Bedarf.',
},
{
num: '06',
title: 'Performance & Zugänglichkeit',
text: 'Websites, die schnell laden und für möglichst viele Nutzerinnen und Nutzer gut zugänglich sind — als Grundlage, nicht als nachträgliches Extra.',
},
];
const nichtMachen = [
'Keine Onlineshops. Dafür gibt es Leute, die nichts anderes tun.',
'Keine Fotos, die es nicht gibt: weder gekaufte Modelle in fremden Räumen noch am Rechner erfundene. Ihre Kunden sollen wiedererkennen, wo sie hinkommen.',
'Keine Versprechen über Google-Plätze. Wer die gibt, weiß es entweder nicht besser oder hofft, dass Sie nicht nachrechnen.',
'Keine zehn Seiten, wenn eine reicht. Die meisten Betriebe brauchen weniger, als ihnen verkauft wird.',
];
const mappe = [
{
kategorie: 'Konzeptarbeit · Tanzstudio',
titel: 'Maison Verte',
text: 'Sand und Salbei. Der Stundenplan steht als Tabelle auf der Seite, nicht als Download, und die Preise auch. Für ein Studio, das ruhig wirken soll, ohne dabei alt auszusehen.',
nameHidden: false,
},
{
kategorie: 'Konzeptarbeit · Tanzschule',
titel: 'Tanzhaus Kranich',
text: 'Dieselbe Seite zweimal, einmal kühl und einmal warm. Wir wollten wissen, was Farbe wirklich anrichtet. Der Unterschied war größer, als wir dachten. Seitdem schlagen wir Ihnen am Ende eine Fassung vor und nicht drei.',
nameHidden: false,
},
{
kategorie: 'Konzeptarbeit · Kosmetikstudio',
titel: '',
text: 'Weich, warm, und trotzdem kein Pastellrosa. Jede Behandlung mit Dauer und Preis, Termin direkt aus der Liste heraus. Damit niemand erst anrufen muss, um zu erfahren, was etwas kostet.',
nameHidden: true,
},
];
const ablaufSteps = [
{ num: '01', title: 'Kennenlernen', text: 'Wir klären Ziele, Zielgruppe und Anforderungen in einem unverbindlichen Erstgespräch.', dauer: '' },
{ num: '02', title: 'Konzept & Design', text: 'Struktur, Inhalte und visuelle Richtung werden gemeinsam abgestimmt.', dauer: '' },
{ num: '03', title: 'Entwicklung', text: 'Umsetzung mit modernen Werkzeugen — inklusive KI-gestützter Entwicklung für mehr Tempo.', dauer: '2–3 Tage' },
{ num: '04', title: 'Launch & Betreuung', text: 'Übergabe, Feinschliff und bei Bedarf laufende Weiterentwicklung.', dauer: '' },
];
const faq = [
'Kann ich die Seite danach selbst ändern?',
'Was, wenn mir das Ergebnis nicht gefällt?',
'Ich habe keine guten Fotos. Ist das ein Problem?',
'Müssen wir uns dafür treffen?',
'Wie lange dauert es insgesamt?',
'Was kostet es danach im Jahr?',
'Ich habe schon eine Website. Lohnt sich das trotzdem?',
];
const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, kontakt } = site;
---
<Layout title="Startseite">
@ -103,38 +10,36 @@ const faq = [
<section class="section hero">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">Digitalagentur für individuelle Websites</p>
<h1>Websites, die zu Ihrem <em>Unternehmen</em> passen.</h1>
<p class="text-lead">
<strong>Gebaut mit KI-gestützter Entwicklung.</strong> larpaso entwickelt individuelle
Websites für Kunden aus unterschiedlichen Branchen. Durch den gezielten Einsatz von KI
in der Entwicklung liefern wir hochwertige Ergebnisse in kürzerer Zeit,
<em>ohne Kompromisse bei Qualität und Individualität.</em>
</p>
<p class="eyebrow">{hero.eyebrow}</p>
<h1>{hero.titleBefore}<em>{hero.titleEmphasis}</em>{hero.titleAfter}</h1>
<p class="text-lead" set:html={md(hero.lead)} />
<div class="hero-actions">
<a class="btn btn-primary" href="#kontakt">Projekt anfragen</a>
<a class="btn btn-primary" href="#kontakt">{hero.ctaLabel}</a>
</div>
</div>
<div class="hero-visual" aria-hidden="true">
<p class="hero-visual-label">Beispiel: Ihr Betrieb bei Google</p>
{hero.visual.customImage ? (
<img class="hero-visual-image" src={hero.visual.customImage} alt="" loading="lazy" />
) : (
<>
<p class="hero-visual-label">{hero.visual.label}</p>
<div class="search-card">
<span class="search-tag">Heute</span>
<p class="search-url">ihr-betrieb.de › home</p>
<p class="search-title">Ihr Betrieb – Startseite</p>
<p class="search-desc">Herzlich willkommen auf unserer Homepage. Wir freuen uns über Ihren Besuch …</p>
<span class="search-tag">{hero.visual.todayTag}</span>
<p class="search-url">{hero.visual.todayUrl}</p>
<p class="search-title">{hero.visual.todayTitle}</p>
<p class="search-desc">{hero.visual.todayDesc}</p>
</div>
<p class="hero-visual-divider">Dieselbe Firma, andere Zeile</p>
<p class="hero-visual-divider">{hero.visual.dividerText}</p>
<div class="search-card search-card-accent">
<span class="search-tag search-tag-accent">Möglich</span>
<p class="search-url">ihr-betrieb.de</p>
<p class="search-title">Ihr Betrieb in [Ort] – Termine, Preise, Anfahrt</p>
<p class="search-desc">
Geöffnet Mo–Fr 9–18 Uhr. <strong>Termin in zwei Klicks</strong>, Preise stehen auf der
Seite, Anfahrt und Parkplätze erklärt.
</p>
<p class="search-meta">★★★★★ <span class="placeholder">[Zahl]</span> Bewertungen · Jetzt geöffnet</p>
<span class="search-tag search-tag-accent">{hero.visual.possibleTag}</span>
<p class="search-url">{hero.visual.possibleUrl}</p>
<p class="search-title">{hero.visual.possibleTitle}</p>
<p class="search-desc" set:html={md(hero.visual.possibleDesc)} />
<p class="search-meta">{hero.visual.possibleMeta}</p>
</div>
</>
)}
</div>
</div>
</section>
@ -143,48 +48,27 @@ const faq = [
<section id="preis" class="section-tight">
<div class="container price-grid">
<div class="price-main">
<p class="eyebrow">01 · Drei Plätze, dann ist Schluss</p>
<h2>Wir brauchen drei Seiten, die wir herzeigen können.</h2>
<p class="price-tag"><span class="price-amount">500&nbsp;€</span> komplett, einmalig</p>
<p class="text-lead">
Eine fertige Website statt eines Angebots über „Konzeptphasen“. Wir schreiben die
Texte, bauen die Seite und stellen sie online. Sie sagen uns, was Sie machen und für wen.
</p>
<p class="eyebrow">{preis.eyebrow}</p>
<h2>{preis.title}</h2>
<p class="price-tag"><span class="price-amount">{preis.amount}</span> {preis.amountNote}</p>
<p class="text-lead">{preis.lead}</p>
<div class="price-progress" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<p class="price-note">
Noch <span class="placeholder">[3]</span> von 3 Plätzen frei — Stand
<span class="placeholder">[Datum]</span>.
</p>
<p class="price-note">{preis.slotsNote}</p>
<ul class="price-list">
<li>Eine Seite mit allem, was Kunden suchen: Angebot, Preise, Zeiten, Anfahrt, Kontakt</li>
<li>Auf dem Handy zuerst gebaut, nicht nachträglich zusammengeschoben</li>
<li>Impressum und Datenschutzerklärung eingebaut, Schriften auf Ihrem Server</li>
<li>Alle Texte von uns geschrieben, nach einem Gespräch mit Ihnen</li>
<li>Google-Grundlagen: Titel mit Ort, Beschreibung, Firmendaten für die Kartenanzeige</li>
<li>Einweisung, wie Sie Zeiten und Preise selbst ändern</li>
<li class="price-list-excluded">Nicht enthalten: Fotoshooting, Logo-Neuentwurf, Onlineshop</li>
<li class="price-list-excluded">
Nicht enthalten: Domain und Hosting. Das läuft auf Ihren Namen und kostet
<span class="placeholder">[ca. Betrag]</span> € im Jahr
</li>
{preis.features.map((feature) => (
<li class={feature.excluded ? 'price-list-excluded' : undefined}>{feature.text}</li>
))}
</ul>
</div>
<aside class="price-aside card">
<h3>Warum so günstig?</h3>
<p>
Weil wir am Anfang stehen und Arbeiten zeigen müssen, die echten Betrieben gehören.
Dafür bitten wir um zwei Dinge: Wir dürfen die fertige Seite in unserer Mappe zeigen,
und wenn Sie zufrieden sind, schreiben Sie uns drei Sätze, die wir zitieren dürfen.
</p>
<p>
Kein Abo und keine Vertragslaufzeit. Sie zahlen die Hälfte zum Start, die Hälfte, wenn
die Seite online ist.
</p>
<a class="btn btn-primary" href="#kontakt">Projekt anfragen</a>
<h3>{preis.asideTitle}</h3>
<p>{preis.asideParagraph1}</p>
<p>{preis.asideParagraph2}</p>
<a class="btn btn-primary" href="#kontakt">{preis.ctaLabel}</a>
</aside>
</div>
</section>
@ -192,12 +76,9 @@ const faq = [
<!-- Warum uns unterscheidet -->
<section id="warum" class="section">
<div class="container">
<p class="eyebrow">02 · Was uns unterscheidet</p>
<h2>Individuell, KI-gestützt, technisch solide.</h2>
<p class="text-lead">
larpaso entwickelt individuelle Websites für Kunden, die sich von der Stange nicht
zufriedengeben wollen.
</p>
<p class="eyebrow">{warum.eyebrow}</p>
<h2>{warum.title}</h2>
<p class="text-lead">{warum.lead}</p>
<div class="grid grid-3 differentiators">
{highlights.map((item) => (
@ -210,39 +91,39 @@ const faq = [
</div>
<div class="readability">
<h3>Lesbar schlägt vollständig.</h3>
<p class="text-lead">
Dieselben Angaben, zweimal. Einmal als Datei, die man erst herunterladen muss. Einmal
als Tabelle, die schon da ist. Angerufen wird nach dem zweiten Weg.
</p>
<h3>{warum.readability.title}</h3>
<p class="text-lead">{warum.readability.lead}</p>
{warum.readability.customImage ? (
<img class="readability-image" src={warum.readability.customImage} alt="" loading="lazy" />
) : (
<div class="readability-grid">
<div>
<p class="readability-label">So sieht es heute oft aus</p>
<p class="readability-label">{warum.readability.beforeLabel}</p>
<div class="pdf-card">
<span class="pdf-icon">PDF</span>
<div>
<p class="pdf-name">Oeffnungszeiten_2026_final_v3.pdf</p>
<p class="pdf-meta">2,4 MB · zum Herunterladen</p>
<p class="pdf-name">{warum.readability.pdfName}</p>
<p class="pdf-meta">{warum.readability.pdfMeta}</p>
</div>
</div>
</div>
<div>
<p class="readability-label">So sieht es nachher aus</p>
<p class="readability-label">{warum.readability.afterLabel}</p>
<div class="hours-card">
<h4>Öffnungszeiten</h4>
<h4>{warum.readability.hoursTitle}</h4>
<div class="hours-row hours-head">
<span>Tag</span><span></span>
</div>
{warum.readability.hoursRows.map((row) => (
<div class="hours-row">
<span>Montag – Donnerstag</span><span>9:00–18:00</span>
</div>
<div class="hours-row">
<span>Freitag</span><span>9:00–14:00</span>
<span>{row.day}</span><span>{row.time}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
</section>
@ -250,15 +131,12 @@ const faq = [
<!-- Leistungen -->
<section id="leistungen" class="section">
<div class="container">
<p class="eyebrow">03 · Was Sie bekommen</p>
<h2>Was wir für Sie tun können</h2>
<p class="text-lead">
Von der ersten Idee bis zur laufenden Betreuung: larpaso begleitet Ihr Website-Projekt
mit individueller Entwicklung und dem gezielten Einsatz von KI-Werkzeugen.
</p>
<p class="eyebrow">{leistungen.eyebrow}</p>
<h2>{leistungen.title}</h2>
<p class="text-lead">{leistungen.lead}</p>
<ul class="leistungen-list">
{leistungen.map((item) => (
{leistungen.items.map((item) => (
<li>
<span class="leistungen-num">{item.num}</span>
<div>
@ -270,9 +148,9 @@ const faq = [
</ul>
<div class="nicht-machen">
<h3>Was wir nicht machen</h3>
<h3>{leistungen.nichtMachenTitle}</h3>
<div class="grid grid-2">
{nichtMachen.map((text) => (
{leistungen.nichtMachen.map((text) => (
<p class="nicht-machen-item">— {text}</p>
))}
</div>
@ -283,15 +161,21 @@ const faq = [
<!-- Mappe / Arbeiten -->
<section id="arbeiten" class="section">
<div class="container">
<p class="eyebrow">04 · Mappe</p>
<h2>Drei Entwürfe, an denen Sie unsere Handschrift sehen.</h2>
<p class="eyebrow">{mappe.eyebrow}</p>
<h2>{mappe.title}</h2>
<div class="mappe-grid">
{mappe.map((item) => (
{mappe.items.map((item) => (
<article class="mappe-item">
{item.image ? (
<div class="mappe-shot mappe-shot-image">
<img src={item.image} alt={item.titel || item.kategorie} loading="lazy" />
</div>
) : (
<div class="mappe-shot">
<span>Bildschirmfoto <span class="placeholder">einsetzen</span></span>
</div>
)}
<p class="mappe-kategorie">{item.kategorie}</p>
<h3>{item.nameHidden ? <span class="placeholder">[Studio — erst nach Freigabe zeigen]</span> : item.titel}</h3>
<p>{item.text}</p>
@ -304,11 +188,11 @@ const faq = [
<!-- Ablauf -->
<section id="ablauf" class="section">
<div class="container">
<p class="eyebrow">05 · So arbeiten wir</p>
<h2>Vom ersten Gespräch bis zum Launch</h2>
<p class="eyebrow">{ablauf.eyebrow}</p>
<h2>{ablauf.title}</h2>
<div class="ablauf-list">
{ablaufSteps.map((step) => (
{ablauf.steps.map((step) => (
<div class="ablauf-step">
<span class="ablauf-num">{step.num}</span>
<div class="ablauf-body">
@ -327,46 +211,34 @@ const faq = [
<!-- Über uns -->
<section class="section about">
<div class="container about-grid">
{about.photo ? (
<div class="about-photo about-photo-image">
<img src={about.photo} alt="Lara und Pascal" loading="lazy" />
</div>
) : (
<div class="about-photo">
<span>
Foto von Lara und Pascal <span class="placeholder">einsetzen</span><br />
<small>zusammen, hochkant, echtes Licht</small>
</span>
</div>
)}
<div class="about-copy">
<p class="eyebrow">06 · Über uns</p>
<h2>Wir sind larpaso.</h2>
<p class="about-quote">KI als Werkzeug, nicht als Ausrede.</p>
<p class="eyebrow">{about.eyebrow}</p>
<h2>{about.title}</h2>
<p class="about-quote">{about.quote}</p>
<p>
Technik ändert sich schnell. Und mit ihr, was Kundinnen und Kunden von einem Betrieb
erwarten: eine Seite, die auf dem Handy funktioniert, klar sagt, worum es geht, und
nicht nach gestern aussieht. Was heute auffällt, ist in zwei Jahren Standard. Wir bauen
Websites für Menschen, die da mithalten wollen, ohne sich selbst in Technik einarbeiten
zu müssen.
</p>
<p>
Dabei helfen uns KI-Werkzeuge. Sie nehmen uns Routine ab, damit wir Zeit haben für das,
worauf es ankommt: zuhören, mitdenken, sorgfältig arbeiten. So bleiben individuelle
Lösungen in überschaubarer Zeit und zu planbaren Kosten möglich, weder Massenware noch
Luxusgut.
</p>
<p>
Der Name ist aus uns gebaut: <strong>Lara</strong> und <strong>Pas</strong>cal. Und die
zweite Hälfte ist ein Wort: <em>paso</em>, spanisch für Schritt. Für uns steht es für
Fortschritt. Wir lernen weiter, entwickeln unsere Arbeit weiter und Ihre Seite gleich
mit, Schritt für Schritt.
</p>
{about.paragraphs.map((paragraph) => (
<p set:html={md(paragraph)} />
))}
<div class="about-bios">
<p class="placeholder">
[Lara — drei bis vier Sätze in eigenen Worten: Was hat sie vorher gemacht, wie ist sie
dazu gekommen, was macht sie bei larpaso? Noch zu ergänzen.]
<p class={about.laraBio ? undefined : 'placeholder'}>
{about.laraBio || '[Lara — drei bis vier Sätze in eigenen Worten: Was hat sie vorher gemacht, wie ist sie dazu gekommen, was macht sie bei larpaso? Noch zu ergänzen.]'}
</p>
<p class="placeholder">
[Pascal — drei bis vier Sätze in eigenen Worten: Was hat er vorher gemacht, wie ist er
dazu gekommen, was macht er bei larpaso? Noch zu ergänzen.]
<p class={about.pascalBio ? undefined : 'placeholder'}>
{about.pascalBio || '[Pascal — drei bis vier Sätze in eigenen Worten: Was hat er vorher gemacht, wie ist er dazu gekommen, was macht er bei larpaso? Noch zu ergänzen.]'}
</p>
</div>
</div>
@ -376,17 +248,19 @@ const faq = [
<!-- FAQ -->
<section class="section faq">
<div class="container">
<p class="eyebrow">07 · Bevor Sie fragen</p>
<h2>Die Punkte, an denen die meisten zögern.</h2>
<p class="eyebrow">{faq.eyebrow}</p>
<h2>{faq.title}</h2>
<div class="faq-list">
{faq.map((question, i) => (
{faq.items.map((item) => (
<details class="faq-item" name="faq">
<summary>
{question}
{item.question}
<span class="faq-icon" aria-hidden="true">+</span>
</summary>
<p class="placeholder">[Antwort noch zu ergänzen]</p>
<p class={item.answer ? undefined : 'placeholder'}>
{item.answer || '[Antwort noch zu ergänzen]'}
</p>
</details>
))}
</div>
@ -397,28 +271,31 @@ const faq = [
<section id="kontakt" class="section-tight contact">
<div class="container contact-grid">
<div class="contact-intro">
<p class="eyebrow">08 · Kontakt</p>
<h2>Lassen Sie uns sprechen.</h2>
<p class="text-lead">
Erzählen Sie uns kurz von Ihrem Projekt — wir melden uns zeitnah bei Ihnen zurück. Ein
Erstgespräch ist unverbindlich und kostet Sie nichts.
</p>
<p class="eyebrow">{kontakt.eyebrow}</p>
<h2>{kontakt.title}</h2>
<p class="text-lead">{kontakt.lead}</p>
<div class="contact-detail">
<p class="contact-label">Lieber anrufen</p>
<p class="placeholder contact-phone">[Telefonnummer eintragen]</p>
<p class="placeholder contact-hours">[Erreichbarkeit eintragen, z. B. Mo–Fr 9–18 Uhr]</p>
<p class={kontakt.phone ? 'contact-phone' : 'placeholder contact-phone'}>
{kontakt.phone || '[Telefonnummer eintragen]'}
</p>
<p class={kontakt.hours ? 'contact-hours' : 'placeholder contact-hours'}>
{kontakt.hours || '[Erreichbarkeit eintragen, z. B. Mo–Fr 9–18 Uhr]'}
</p>
</div>
<div class="contact-detail">
<p class="contact-label">Oder direkt schreiben</p>
<p class="contact-email"><a href="mailto:kontakt@larpaso.de">kontakt@larpaso.de</a></p>
<p class="contact-email"><a href={`mailto:${kontakt.email}`}>{kontakt.email}</a></p>
<p class="contact-hint">Antwort in der Regel am selben Tag.</p>
</div>
<div class="contact-detail">
<p class="contact-label">Wie wir arbeiten</p>
<p class="placeholder">[Wie wir arbeiten — noch zu ergänzen]</p>
<p class={kontakt.howWeWork ? undefined : 'placeholder'}>
{kontakt.howWeWork || '[Wie wir arbeiten — noch zu ergänzen]'}
</p>
</div>
</div>
@ -463,8 +340,8 @@ const faq = [
</div>
</section>
<script>
const form = document.getElementById('contact-form') as HTMLFormElement | null;
<script define:vars={{ contactEmail: kontakt.email }}>
const form = document.getElementById('contact-form');
form?.addEventListener('submit', (event) => {
event.preventDefault();
const data = new FormData(form);
@ -485,7 +362,7 @@ const faq = [
const subject = encodeURIComponent(`Projektanfrage${betrieb ? ` – ${betrieb}` : ''}`);
const body = encodeURIComponent(bodyLines.join('\n'));
window.location.href = `mailto:kontakt@larpaso.de?subject=${subject}&body=${body}`;
window.location.href = `mailto:${contactEmail}?subject=${subject}&body=${body}`;
});
</script>
</Layout>
@ -522,6 +399,12 @@ const faq = [
margin-bottom: var(--space-3);
}
.hero-visual-image {
width: 100%;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.search-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
@ -531,7 +414,7 @@ const faq = [
}
.search-card-accent {
border-color: rgba(201, 168, 118, 0.4);
border-color: var(--color-accent);
}
.search-tag {
@ -558,7 +441,7 @@ const faq = [
}
.search-title {
color: #8fb2e8;
color: var(--color-google-blue);
font-size: var(--font-size-md);
margin: 0 0 var(--space-2) 0;
}
@ -690,6 +573,13 @@ const faq = [
margin-top: var(--space-6);
}
.readability-image {
width: 100%;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
margin-top: var(--space-6);
}
.readability-label {
font-size: 0.75rem;
letter-spacing: 0.08em;
@ -710,7 +600,7 @@ const faq = [
.pdf-icon {
background: var(--color-danger);
color: #fff;
color: var(--color-primary-contrast);
font-size: 0.7rem;
font-weight: var(--font-weight-bold);
padding: var(--space-2) var(--space-3);
@ -819,6 +709,18 @@ const faq = [
color: var(--color-text-muted);
font-size: var(--font-size-sm);
margin-bottom: var(--space-4);
overflow: hidden;
}
.mappe-shot-image {
border: none;
padding: 0;
}
.mappe-shot-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mappe-kategorie {
@ -883,6 +785,18 @@ const faq = [
padding: var(--space-5);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
overflow: hidden;
}
.about-photo-image {
border: none;
padding: 0;
}
.about-photo-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.about-quote {

View file

@ -24,6 +24,7 @@
--color-flag: #c97b64;
--color-flag-bg: rgba(201, 123, 100, 0.1);
--color-google-blue: #8fb2e8;
/* Typografie */
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;