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:
parent
478e032b0b
commit
93178a24e3
33 changed files with 3686 additions and 322 deletions
|
|
@ -1,3 +1,6 @@
|
|||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
.git
|
||||
admin/.env
|
||||
public/uploads/*
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
197
DEPLOYMENT_ADMIN.md
Normal 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.
|
||||
|
|
@ -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
32
Dockerfile.admin
Normal 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
19
admin/.env.example
Normal 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
9
admin/hash-password.js
Normal 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
148
admin/lib/auth.js
Normal 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
62
admin/lib/build.js
Normal 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 };
|
||||
26
admin/lib/content-store.js
Normal file
26
admin/lib/content-store.js
Normal 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
25
admin/lib/fonts.js
Normal 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
341
admin/lib/schema.js
Normal 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
114
admin/lib/validate.js
Normal 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
897
admin/package-lock.json
generated
Normal 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
17
admin/package.json
Normal 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
488
admin/public/app.js
Normal 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
35
admin/public/index.html
Normal 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 & 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
215
admin/public/style.css
Normal 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
208
admin/server.js
Normal 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
323
content/site.json
Normal 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
113
content/theme.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
18
docker-entrypoint-admin-seed.sh
Normal file
18
docker-entrypoint-admin-seed.sh
Normal 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
10
docker-entrypoint-seed.sh
Normal 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
0
public/uploads/.gitkeep
Normal 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>
|
||||
© {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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
94
src/lib/content.ts
Normal 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, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
51
src/lib/fonts.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 €</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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue