Compare commits

...

53 Commits

Author SHA1 Message Date
Daniel S
1939563ef2 feat: add dynamic page switcher dropdown in editor header and ensure exact page route rendering
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 49s
2026-08-10 00:28:44 +02:00
Daniel S
698716c34e refactor: clear sample pages and sections to provide a clean slate for custom editing 2026-08-10 00:26:02 +02:00
Daniel S
488e024451 fix: attach click event handlers for section delete, move up and move down in editor UI 2026-08-10 00:23:33 +02:00
Daniel S
9c166817fa feat: implement instant live DOM rendering and debounced auto-save to backend 2026-08-10 00:21:22 +02:00
Daniel S
4b7f3f554b fix: add automatic draft compilation fallback in loader and explicit API error logging 2026-08-10 00:16:41 +02:00
Daniel S
db2f607e5a feat: implement DB-driven architecture with SQLite persistence and file-generator compilation service
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 50s
2026-08-10 00:09:10 +02:00
Daniel S
950497311c fix: enforce strict draft vs publish isolation on all editor API endpoints
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 46s
2026-08-09 23:55:16 +02:00
Daniel S
28473bbcdc feat: rebuild admin editor into full Elementor-style 3-tab inspector and responsive canvas 2026-08-09 23:52:51 +02:00
Daniel S
fc2017b210 feat: enable full section editing, background styling, animations and live file persistence 2026-08-09 23:50:33 +02:00
Daniel S
831a0d53f0 refactor: simplify editor actions into single direct save button 2026-08-09 23:48:02 +02:00
Daniel S
9284717a80 fix: update editor bridge syntax and refine demo config content 2026-08-09 23:46:22 +02:00
Daniel S
4d072864bc rename(theme): rename Design-Preset to Farb-Auswahl and show 4 global color indicators
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 45s
2026-08-09 23:11:03 +02:00
Daniel S
e932495742 fix(types): add bg_color property to hero and bento interfaces in configLoader 2026-08-09 23:09:03 +02:00
Daniel S
6042c5ad23 feat(editor): support individual section background colors per section in editor and components 2026-08-09 23:06:56 +02:00
Daniel S
041cf20ca3 style: enforce global CSS utility classes for theme accent colors
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 48s
2026-08-09 23:04:55 +02:00
Daniel S
4355bb8406 feat(theme): dynamic CSS custom properties for presets, light/dark mode and individual color overrides
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 49s
2026-08-09 23:02:21 +02:00
Daniel S
30b407d2e8 feat(admin): add global header and footer configuration fields in admin dashboard 2026-08-09 22:58:54 +02:00
Daniel S
591eba43c1 fix(editor): enable design preset saving, section moving up/down and modal content editing 2026-08-09 22:56:10 +02:00
Daniel S
895fcd4323 fix(editor): preserve full site config object in save-draft and sync to draft file in config API 2026-08-09 22:52:55 +02:00
Daniel S
adefc89ab2 fix(config): ensure is_demo_mode is persisted in site.config.json and typed in SiteConfig interface 2026-08-09 22:50:10 +02:00
Daniel S
aec7c78fb0 fix(api): replace Astro global with APIRoute request parameter in api/config.ts 2026-08-09 22:48:19 +02:00
Daniel S
10d534e836 fix(api): prevent 500 error in /api/config when siteConfig.hero is omitted 2026-08-09 22:47:26 +02:00
Daniel S
15b2a34957 fix(types): add ogImage prop to Layout and message prop to FallbackSection
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 50s
2026-08-09 22:45:01 +02:00
Daniel S
baa639a9b2 feat(admin): add demo mode toggle in admin dashboard and rewrite 404 page for missing routes
Some checks failed
Production Build & Deploy / build-and-deploy (push) Has been cancelled
2026-08-09 22:43:36 +02:00
Daniel S
5e9b69c319 feat(editor): render persistent visual draft mode banner in preview mode 2026-08-09 22:41:22 +02:00
Daniel S
df6bb52ab0 feat(admin): merge company site_info stammdaten into general admin dashboard 2026-08-09 22:39:42 +02:00
Daniel S
11f8e33818 refactor(admin): remove legacy hero section and section ordering form from general dashboard 2026-08-09 22:37:53 +02:00
Daniel S
da50ac468b fix(nav): restrict visual editor access to pages management overview 2026-08-09 22:36:33 +02:00
Daniel S
d5b66ff830 feat(admin): add persistent drawer burger sidebar navigation across all admin pages 2026-08-09 22:33:52 +02:00
Daniel S
e8f332da43 feat(pages): add page opening, page deletion API, edit mode toggle and custom 404 page
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m10s
2026-08-09 22:30:13 +02:00
Daniel S
334c6723e9 fix(types): restore contact property in SiteConfig interface 2026-08-09 22:26:54 +02:00
Daniel S
bb96b0049c fix(types): add site_info and pages properties to SiteConfig interface 2026-08-09 22:26:19 +02:00
Daniel S
0a1aa5030d feat(config): include default pages and site_info in configLoader fallbacks 2026-08-09 22:24:28 +02:00
Daniel S
be071ff82f feat(admin): add quick navigation cards to admin dashboard
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m13s
2026-08-09 22:19:20 +02:00
Daniel S
f7b08f33ee fix(ci): synchronize package-lock.json with esbuild dependencies
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m18s
2026-08-09 22:12:43 +02:00
Daniel S
bb69e554d0 fix(deps): align react types with astrojs/react v18 and use modern --omit=dev in Dockerfile
Some checks failed
Production Build & Deploy / build-and-deploy (push) Failing after 6s
2026-08-09 22:08:23 +02:00
Daniel S
64c257f050 feat(docker): add automatic container entrypoint seeding script
Some checks failed
Production Build & Deploy / build-and-deploy (push) Failing after 9s
2026-08-09 22:05:48 +02:00
Daniel S
b021f9aaa7 feat(docker): add production Dockerfile, docker-compose setup and healthcheck API 2026-08-09 21:28:10 +02:00
Daniel S
03ceb3de7f feat(admin): add global site settings API, tests and dashboard UI
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 35s
2026-08-09 21:26:15 +02:00
Daniel S
a35efb2726 feat(editor): add section settings property editor API and sidebar modal 2026-08-09 21:24:16 +02:00
Daniel S
58fc8e51cd feat(contact): add WCAG 2.2 AA compliant ContactSection and zero-third-party send API 2026-08-09 21:18:09 +02:00
Daniel S
0c073983e9 feat(editor): add section manipulation APIs and interactive sidebar controls
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m3s
2026-08-09 21:08:35 +02:00
Daniel S
d03750bc53 feat(waas): add catch-all route, page manager and draft-publish pipeline 2026-08-09 20:59:02 +02:00
Daniel S
074d314897 feat: implement Dynamic Data Binding system and tags registry
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m0s
2026-08-09 12:54:31 +02:00
Daniel S
b039aceb21 feat: implement Theme Builder concept and Display Conditions resolver system 2026-08-09 12:53:25 +02:00
Daniel S
3453746fc0 feat: add section content editing, animation selection and HTML5 drag & drop in live editor
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m0s
2026-08-09 12:45:36 +02:00
Daniel S
b159b061ae feat: implement device-specific configurations for desktop, tablet, and mobile
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 57s
2026-08-09 12:35:46 +02:00
Daniel S
eb4bb601d7 fix(types): add missing slotId to fallback UIComponent objects in configLoader.ts
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 56s
2026-08-08 23:54:36 +02:00
Daniel S
92a75cf289 feat(waas-engine): add editable Header, Footer & Glassmorphism design controls in editor Inspector 2026-08-08 23:52:22 +02:00
Daniel S
4c165d8107 feat(admin-editor): add per-section UIComponent management with inline editing, deletion and re-ordering 2026-08-08 23:50:07 +02:00
Daniel S
c5165547b4 fix(admin-editor): rewrite editor.astro cleanly to eliminate AST compiler parsing error
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 56s
2026-08-08 23:44:33 +02:00
Daniel S
e82e1b3301 fix(cicd): update Gitea Action deploy.yml port mapping 8080:4321 and data volumes 2026-08-08 23:43:18 +02:00
Daniel S
6edc219a04 fix(admin-editor): resolve import syntax typo in editor.astro for SSR render 2026-08-08 23:42:28 +02:00
59 changed files with 4716 additions and 947 deletions

View File

@@ -38,16 +38,20 @@ jobs:
# Schreibt die Docker Compose Datei sauber ohne Einrückung
cat << 'EOF' > docker-compose.yml
version: '3.8'
services:
website:
image: gitea.hephex.de/daniel/ndsolutionswebsite:latest
restart: always
ports:
- "8080:8080"
- "8080:4321"
environment:
- HOST=0.0.0.0
- PORT=4321
- DATA_DIR=/app/data
volumes:
- ./app/data:/app/data
EOF
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.hephex.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose pull
docker compose up -d
docker image prune -f

12
.gitignore vendored
View File

@@ -22,3 +22,15 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
# coverage report
coverage/
# draft files
*.draft.json
# test data directories
app/test_data*/

View File

@@ -1,25 +1,41 @@
# Build-Phase
FROM node:20-slim AS builder
# STAGE 1: Dependencies & Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
RUN npm ci
COPY . .
ENV NODE_ENV=production
RUN npm run build
# Production-Phase (Node SSR Runner)
FROM node:20-slim AS runner
# STAGE 2: Production Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
ENV PORT=3000
ENV DATA_DIR=/app/data
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
# Ordner anlegen & Schreibrechte vorbereiten
RUN mkdir -p /app/data
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/data_template ./data_template
COPY --from=builder /app/public ./public
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
EXPOSE 4321
# Ausführungsrechte für das Entrypoint-Skript setzen
RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 3000
# Healthcheck für Docker / Nginx Proxy
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -1,104 +1,32 @@
{
"siteName": "N&D IT Solutions",
"metaDescription": "High-Performance Webdesign & Digital Solutions - 100% DSGVO-konform.",
"hero": {
"badge": "⚡ WaaS Enterprise Baukastensystem",
"title": "Maßgeschneiderte Webseiten. Digital im Griff.",
"subtitle": "Wir entwickeln blitzschnelle, DSGVO-konforme High-End Webseiten für Ihr Unternehmen zum monatlichen Festpreis inklusive Wartung & Hosting.",
"ctaPrimaryText": "Projekt anfragen",
"ctaPrimaryLink": "#contact",
"ctaSecondaryText": "Mehr erfahren",
"ctaSecondaryLink": "#services",
"elements": [
{
"id": "el-1",
"type": "badge",
"content": "š WaaS Engine"
},
{
"id": "el-2",
"type": "button",
"content": "Neuer Button",
"link": "#contact",
"animation0": "spring-fade",
"style": "primary"
}
]
"is_demo_mode": false,
"header": {
"brandName": "N&D",
"brandHighlight": "i-t SOLUTIONS",
"ctaText": "Erstgespräch buchen",
"ctaLink": "#contact",
"showThemeToggle": true,
"glassEffect": true
},
"bento": {
"sectionTitle": "High-End Features & Leistung",
"sectionSubtitle": "Modernste Technologie & Höchster Datenschutz vereint.",
"cards": [
{
"id": "card-1",
"title": "Ultra Fast SSR",
"description": "Sub-100ms Ladezeiten dank Astro Hybrid SSR Architecture. Google Core Web Vitals > 95%.",
"colSpan": 2,
"badge": "Performance",
"icon": "zap"
},
{
"id": "card-2",
"title": "100% DSGVO-Sicher",
"description": "Keine Cookies, keine Tracking-Skripte, Zero Third-Party Requests.",
"colSpan": 1,
"badge": "Datenschutz",
"icon": "shield"
},
{
"id": "card-3",
"title": "Liquid Glass UI",
"description": "Moderne Ästhetik mit matte Boxen, Aurora Glows und physischen Kanten-Effekten.",
"colSpan": 1,
"badge": "Design",
"icon": "layers"
},
{
"id": "card-4",
"title": "Sorgenfrei-Paket",
"description": "Hosting, SSL, Wartung und regelmäßige Inhalts-Updates komplett inklusive.",
"colSpan": 2,
"badge": "Full-Service",
"icon": "check"
}
]
"footer": {
"brandDescription": "Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO.",
"copyrightText": "N&D i-t SOLUTIONS. Alle Rechte vorbehalten.",
"showLegalLinks": true
},
"contact": {
"title": "Bereit für Ihr Projekt?",
"subtitle": "Schreiben Sie uns direkt. Wir antworten innerhalb von 24 Stunden persönlich.",
"recipientEmail": "kontakt@ndsolutions.de",
"phone": "+49 (0) 123 456789",
"address": "Deutschland"
"site_info": {
"title": "N&D IT Solutions",
"homepage_id": "page_home"
},
"sectionsOrder": [
"hero",
"bento",
"contact"
],
"sectionsDetails": [
"pages": [
{
"id": "hero",
"type": "hero",
"title": "Hero Sektion",
"elements": [
{
"id": "el-1",
"type": "badge",
"content": "š WaaS Engine"
}
]
},
{
"id": "bento",
"type": "bento",
"title": "Bento Grid",
"elements": []
},
{
"id": "contact",
"type": "contact",
"title": "Contact Section",
"elements": []
"id": "page_home",
"slug": "/",
"title": "Startseite",
"is_published": true,
"sections": []
}
]
],
"updated_at": "2026-08-09T22:28:31.200Z"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -1,7 +1,9 @@
{
"activePreset": "sunsetCoral",
"activePreset": "midnightBlue",
"customColors": {
"bg": "#0B0F19",
"accent": "#38BDF8"
"bg": "#0b0f19",
"accent": "#38bdf8",
"text": "#e0e0e0",
"border": ""
}
}

BIN
app/data/waas.db Normal file

Binary file not shown.

View File

@@ -1,15 +1,27 @@
version: '3.8'
services:
website:
build: .
image: gitea.hephex.de/daniel/websitedummy:latest
container_name: websitedummy_app
app:
image: nd-waas-engine:latest
container_name: waas_${CUSTOMER_SLUG:-kunden_app}
restart: always
ports:
- "8080:4321"
environment:
- HOST=0.0.0.0
- PORT=4321
- NODE_ENV=production
- PORT=3000
- DATA_DIR=/app/data
volumes:
- ./app/data:/app/data
# Hier werden site.config.json, sqlite.db, uploads etc. automatisch vom Container angelegt
- ./data:/app/data
ports:
# Anbindung an deinen händischen Nginx Reverse Proxy auf dem Host
- "127.0.0.1:${PORT:-8080}:3000"
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/health"]
interval: 10s
timeout: 3s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

78
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,78 @@
#!/bin/sh
set -e
DATA_DIR=${DATA_DIR:-/app/data}
echo "🚀 [WaaS Container Entrypoint] Prüfe Daten-Volume unter ${DATA_DIR}..."
# 1. Sicherstellen, dass das Volume-Verzeichnis existiert
mkdir -p "$DATA_DIR"
# 1.5 SQLite DB initialisieren (falls nicht vorhanden)
if [ ! -f "$DATA_DIR/waas.db" ]; then
echo "🗄️ Keine SQLite-Datenbank waas.db gefunden. Initialisierung wird beim Anwendungsstart durchgeführt..."
fi
# 2. site.config.json Seeding
if [ ! -f "$DATA_DIR/site.config.json" ]; then
echo "📄 Keine site.config.json gefunden. Erstelle Standard-Konfiguration..."
cat <<EOF > "$DATA_DIR/site.config.json"
{
"site_info": {
"title": "N&D IT Solutions Kunden-Website",
"company_name": "Mein Unternehmen",
"homepage_id": "page_home_01"
},
"navigation": [
{ "label": "Startseite", "page_id": "page_home_01" }
],
"pages": [
{
"id": "page_home_01",
"slug": "/",
"title": "Startseite",
"is_published": true,
"sections": [
{
"id": "sec_hero_init",
"type": "HeroSection",
"settings": {
"title": "Herzlich Willkommen",
"subtitle": "Ihre neue Website ist erfolgreich gestartet."
}
}
]
}
]
}
EOF
fi
# 3. theme.config.json Seeding
if [ ! -f "$DATA_DIR/theme.config.json" ]; then
echo "🎨 Keine theme.config.json gefunden. Erstelle Standard-Theme..."
cat <<EOF > "$DATA_DIR/theme.config.json"
{
"presetKey": "corporate-dark",
"mode": "dark"
}
EOF
fi
# 4. smtp.config.json Seeding
if [ ! -f "$DATA_DIR/smtp.config.json" ]; then
echo "✉️ Keine smtp.config.json gefunden. Erstelle Standard-SMTP Config..."
cat <<EOF > "$DATA_DIR/smtp.config.json"
{
"host": "localhost",
"port": 1025,
"from": "noreply@kunden-domain.de",
"recipient": "info@kunden-domain.de"
}
EOF
fi
echo "✅ All-in-One Initialisierung abgeschlossen. Starte Astro SSR Node Server..."
# Führe den eigentlichen Befehl aus (CMD aus Dockerfile)
exec "$@"

1705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,18 +17,19 @@
"@astrojs/tailwind": "^5.1.4",
"@fontsource/inter": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^4.16.0",
"nodemailer": "^9.0.5",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.7.0",
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@types/nodemailer": "^8.0.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitest/coverage-v8": "^4.1.10",
"vitest": "^4.1.10"
}
}

View File

@@ -0,0 +1,65 @@
// public/js/editor-bridge.js
(function () {
// Bridge nur im Preview-Modus aktivieren
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('preview') !== 'draft') return;
console.log('[N&D Editor Bridge] Active on preview canvas');
// Optischer Hinweis-Banner im Bearbeitungsmodus
document.addEventListener('DOMContentLoaded', () => {
const banner = document.createElement('div');
banner.id = 'editor-preview-banner';
banner.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; z-index: 99999; background: #d97706; color: #0f172a; text-align: center; padding: 6px 12px; font-size: 12px; font-weight: 700; font-family: monospace; letter-spacing: 0.5px; box-shadow: 0 2px 10px rgba(0,0,0,0.3); pointer-events: none; opacity: 0.95;';
banner.innerHTML = '🛠️ BEARBEITUNGSMODUS (VORSCHAU / ENTWURF) — Änderungen sind noch nicht live!';
document.body.appendChild(banner);
const currentPadding = parseInt(document.body.style.paddingTop || '0', 10);
document.body.style.paddingTop = (currentPadding + 30) + 'px';
});
window.addEventListener('message', (event) => {
const { type, payload } = event.data || {};
if (type === 'THEME_UPDATE') {
const root = document.documentElement;
if (payload.colors) {
Object.entries(payload.colors).forEach(([key, val]) => {
root.style.setProperty(`--${key}`, val);
});
}
// Live Section DOM Patching
if (payload.sectionId && payload.settings) {
const secElement = document.querySelector(`[data-section-id="${payload.sectionId}"], #${payload.sectionId}`) || document.querySelector('section');
if (secElement) {
const { title, subtitle, cta_text, bg_color, accent_color } = payload.settings;
if (title !== undefined) {
const h1OrH2 = secElement.querySelector('h1, h2, h3');
if (h1OrH2) h1OrH2.textContent = title;
}
if (subtitle !== undefined) {
const p = secElement.querySelector('p');
if (p) p.textContent = subtitle;
}
if (cta_text !== undefined) {
const btn = secElement.querySelector('a, button');
if (btn) btn.textContent = cta_text;
}
if (bg_color !== undefined && bg_color !== '') {
secElement.style.backgroundColor = bg_color;
}
if (accent_color !== undefined && accent_color !== '') {
secElement.style.setProperty('--accent-color', accent_color);
}
}
}
}
if (type === 'RELOAD_CANVAS') {
// Sanfter Reload des Iframes bei Strukturänderungen
window.location.reload();
}
});
})();

View File

@@ -0,0 +1,150 @@
---
// src/components/ContactSection.astro
interface Props {
id?: string;
settings?: {
title?: string;
subtitle?: string;
submit_button_text?: string;
};
}
const { id = 'contact', settings = {} } = Astro.props;
const title = settings.title || 'Kontaktieren Sie uns';
const subtitle = settings.subtitle || 'Schreiben Sie uns eine Nachricht. Wir melden uns innerhalb von 24 Stunden.';
const buttonText = settings.submit_button_text || 'Nachricht absenden';
---
<section id={id} class="py-16 px-4 max-w-4xl mx-auto">
<div class="preset-card p-8 bg-slate-900/80 border border-white/10 rounded-2xl shadow-2xl backdrop-blur-md">
<div class="text-center mb-8">
<h2 class="text-2xl font-bold text-white tracking-tight">{title}</h2>
<p class="text-slate-400 text-sm mt-2">{subtitle}</p>
</div>
<div id="form-feedback" role="status" aria-live="polite" class="hidden mb-6 p-4 rounded-lg text-sm font-semibold"></div>
<form id="contact-form" class="space-y-6" novalidate>
<div class="hidden" aria-hidden="true">
<label for="website_hp">Bitte dieses Feld leer lassen</label>
<input type="text" id="website_hp" name="website_hp" tabindex="-1" autocomplete="off" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="sender_name" class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
Ihr Name <span class="text-sky-400" aria-hidden="true">*</span>
</label>
<input
type="text"
id="sender_name"
name="sender_name"
required
aria-required="true"
placeholder="Max Mustermann"
class="w-full bg-slate-950/60 border border-slate-800 rounded-lg p-3 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label for="sender_email" class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
E-Mail Adresse <span class="text-sky-400" aria-hidden="true">*</span>
</label>
<input
type="email"
id="sender_email"
name="sender_email"
required
aria-required="true"
placeholder="max@beispiel.de"
class="w-full bg-slate-950/60 border border-slate-800 rounded-lg p-3 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-all"
/>
</div>
</div>
<div>
<label for="message_text" class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
Ihre Nachricht <span class="text-sky-400" aria-hidden="true">*</span>
</label>
<textarea
id="message_text"
name="message_text"
rows="5"
required
aria-required="true"
placeholder="Wie können wir Ihnen helfen?"
class="w-full bg-slate-950/60 border border-slate-800 rounded-lg p-3 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-all resize-y"
></textarea>
</div>
<div class="flex items-start gap-3">
<input
type="checkbox"
id="privacy_consent"
name="privacy_consent"
required
aria-required="true"
class="mt-1 h-4 w-4 accent-sky-500 rounded border-slate-800 bg-slate-950/60 cursor-pointer focus:ring-2 focus:ring-sky-500"
/>
<label for="privacy_consent" class="text-xs text-slate-400 leading-relaxed cursor-pointer">
Ich stimme der Verarbeitung meiner Angaben gemäß der <a href="/datenschutz" class="text-sky-400 underline hover:text-sky-300">Datenschutzerklärung</a> zur Kontaktaufnahme zu. <span class="text-sky-400" aria-hidden="true">*</span>
</label>
</div>
<button
type="submit"
id="btn-submit-contact"
class="w-full bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold py-3.5 px-6 rounded-lg text-sm transition-all shadow-lg shadow-sky-500/20 focus:outline-none focus:ring-2 focus:ring-sky-300"
>
{buttonText}
</button>
</form>
</div>
</section>
<script>
const form = document.getElementById('contact-form') as HTMLFormElement;
const feedback = document.getElementById('form-feedback');
const submitBtn = document.getElementById('btn-submit-contact') as HTMLButtonElement;
form?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!feedback || !submitBtn) return;
// Client-side Honeypot Check
const hp = (document.getElementById('website_hp') as HTMLInputElement)?.value;
if (hp) return; // Stiller Abbruch bei Bot-Befüllung
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet...';
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
try {
const res = await fetch('/api/contact/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
feedback.classList.remove('hidden', 'bg-red-950/80', 'text-red-300', 'border-red-800', 'bg-emerald-950/80', 'text-emerald-300', 'border-emerald-800');
if (res.ok) {
feedback.classList.add('bg-emerald-950/80', 'text-emerald-300', 'border', 'border-emerald-800');
feedback.textContent = 'Vielen Dank! Ihre Nachricht wurde erfolgreich übermittelt.';
form.reset();
} else {
throw new Error(result.error || 'Fehler beim Senden.');
}
} catch (err: any) {
feedback.classList.add('bg-red-950/80', 'text-red-300', 'border', 'border-red-800');
feedback.textContent = err.message || 'Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Nachricht absenden';
}
});
</script>

View File

@@ -1,5 +1,13 @@
---
// src/components/Footer.astro
import { loadWaasConfigs } from '../utils/configLoader';
const { siteConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const footer = siteConfig.footer || {
brandDescription: 'Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO für Kleinbetriebe & KMU.',
copyrightText: 'N&D i-t SOLUTIONS. Alle Rechte vorbehalten.',
showLegalLinks: true
};
const currentYear = new Date().getFullYear();
---
@@ -10,10 +18,11 @@ const currentYear = new Date().getFullYear();
<!-- Brand logo/name -->
<div>
<a href="#" class="text-lg font-bold tracking-wider text-slate-900 dark:text-white">
N&D <span class="text-brand-arcticBlue">i-t SOLUTIONS</span>
{siteConfig.header?.brandName || 'N&D'} <span class="text-accent">{siteConfig.header?.brandHighlight || 'i-t SOLUTIONS'}</span>
</a>
<p class="mt-2 text-xs text-slate-500 dark:text-gray-500 max-w-sm">
Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO für Kleinbetriebe & KMU.
{footer.brandDescription}
</p>
</div>
@@ -31,13 +40,15 @@ const currentYear = new Date().getFullYear();
<!-- Bottom Copyright & Legal Links -->
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-slate-500 dark:text-gray-500">
<div>
&copy; {currentYear} N&D i-t SOLUTIONS. Alle Rechte vorbehalten.
</div>
<div class="flex gap-6">
<a href="/privacy" class="hover:text-slate-900 dark:hover:text-white transition-colors">Datenschutz</a>
<span class="text-slate-300 dark:text-white/10">|</span>
<a href="/privacy#impressum" class="hover:text-slate-900 dark:hover:text-white transition-colors">Impressum</a>
&copy; {currentYear} {footer.copyrightText}
</div>
{footer.showLegalLinks !== false && (
<div class="flex gap-6">
<a href="/privacy" class="hover:text-slate-900 dark:hover:text-white transition-colors">Datenschutz</a>
<span class="text-slate-300 dark:text-white/10">|</span>
<a href="/privacy#impressum" class="hover:text-slate-900 dark:hover:text-white transition-colors">Impressum</a>
</div>
)}
</div>
</div>
</footer>

View File

@@ -1,19 +1,31 @@
---
// src/components/Navbar.astro
import { FaBars, FaTimes } from 'react-icons/fa';
import { loadWaasConfigs } from '../utils/configLoader';
const { siteConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const header = siteConfig.header || {
brandName: 'N&D',
brandHighlight: 'i-t SOLUTIONS',
ctaText: 'Erstgespräch buchen',
ctaLink: '#contact',
showThemeToggle: true,
glassEffect: true
};
---
<header id="main-header" class="fixed top-0 left-0 w-full z-50 transition-all duration-300 py-3 bg-white/30 dark:bg-brand-darkBg/30 backdrop-blur-xl border-b border-slate-200 dark:border-brand-darkBorder">
<header id="main-header" class={`fixed top-0 left-0 w-full z-50 transition-all duration-300 py-3 ${header.glassEffect !== false ? 'bg-white/30 dark:bg-brand-darkBg/30 backdrop-blur-xl border-b border-slate-200 dark:border-brand-darkBorder' : 'bg-slate-900 border-b border-white/10'}`}>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<!-- Logo -->
<a href="#" class="flex items-center gap-2 group">
<span class="text-lg sm:text-xl font-bold tracking-wider text-slate-900 dark:text-white select-none transition-colors duration-300">
N&D <span class="text-brand-arcticBlue">i-t SOLUTIONS</span>
{header.brandName || 'N&D'} <span class="text-accent">{header.brandHighlight || 'i-t SOLUTIONS'}</span>
</span>
</a>
<!-- Desktop Nav Links -->
<nav class="hidden md:flex items-center gap-8">
<a href="#services" class="text-xs tracking-wider uppercase font-medium text-slate-600 dark:text-zinc-400 hover:text-brand-arcticBlue dark:hover:text-brand-arcticBlue transition-colors">Leistungen</a>
@@ -23,22 +35,26 @@ import { FaBars, FaTimes } from 'react-icons/fa';
<!-- Right controls -->
<div class="flex items-center gap-4">
<!-- Theme Toggle Button (44x44px target) -->
<button id="theme-toggle" class="w-11 h-11 flex items-center justify-center rounded-lg border border-slate-200 dark:border-brand-darkBorder bg-white/50 dark:bg-zinc-900/50 text-slate-700 dark:text-zinc-400 hover:text-brand-arcticBlue dark:hover:text-brand-arcticBlue transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label="Farbschema wechseln">
<!-- Moon Icon -->
<svg id="theme-toggle-dark-icon" class="hidden w-4 h-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<!-- Sun Icon -->
<svg id="theme-toggle-light-icon" class="hidden w-4 h-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.46 5.05l-.707-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
</svg>
</button>
{header.showThemeToggle !== false && (
<!-- Theme Toggle Button (44x44px target) -->
<button id="theme-toggle" class="w-11 h-11 flex items-center justify-center rounded-lg border border-slate-200 dark:border-brand-darkBorder bg-white/50 dark:bg-zinc-900/50 text-slate-700 dark:text-zinc-400 hover:text-brand-arcticBlue dark:hover:text-brand-arcticBlue transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label="Farbschema wechseln">
<!-- Moon Icon -->
<svg id="theme-toggle-dark-icon" class="hidden w-4 h-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<!-- Sun Icon -->
<svg id="theme-toggle-light-icon" class="hidden w-4 h-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.46 5.05l-.707-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
</svg>
</button>
)}
<!-- CTA button -->
<a href="#contact" class="hidden sm:inline-flex items-center justify-center px-4 py-2 text-xs font-semibold tracking-wider text-slate-700 dark:text-white border border-slate-200 dark:border-brand-darkBorder rounded bg-white/50 dark:bg-zinc-900/50 hover:border-brand-arcticBlue hover:text-brand-arcticBlue transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label="Erstgespräch buchen">
Erstgespräch buchen
</a>
{header.ctaText && (
<a href={header.ctaLink || "#contact"} class="hidden sm:inline-flex items-center justify-center px-4 py-2 text-xs font-semibold tracking-wider text-slate-700 dark:text-white border border-slate-200 dark:border-brand-darkBorder rounded bg-white/50 dark:bg-zinc-900/50 hover:border-brand-arcticBlue hover:text-brand-arcticBlue transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label={header.ctaText}>
{header.ctaText}
</a>
)}
<!-- Mobile menu button (44x44px target) -->
<button id="mobile-menu-btn" class="md:hidden w-11 h-11 flex items-center justify-center rounded bg-white dark:bg-zinc-900 text-slate-700 dark:text-white border border-slate-200 dark:border-brand-darkBorder hover:bg-slate-50 dark:hover:bg-zinc-800 transition-all focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label="Menü öffnen">

View File

@@ -0,0 +1,112 @@
---
// src/components/admin/AdminSidebar.astro
const { currentPath = '' } = Astro.props;
const navItems = [
{ href: '/admin', label: 'Allgemein & Konfig', icon: '🎛️' },
{ href: '/admin/pages', label: 'Seiten-Verwaltung', icon: '📄' },
{ href: '/admin/settings', label: 'Stammdaten & SEO', icon: '⚙️' },
];
---
<!-- Burger Button (Mobile / Sidebar Toggle) -->
<button
id="admin-menu-toggle"
type="button"
aria-label="Admin Menü öffnen"
class="fixed top-3 left-3 z-50 p-2.5 rounded-xl bg-slate-900/90 border border-white/10 text-white hover:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400 backdrop-blur-md transition-all"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- Backdrop Overlay for Mobile -->
<div
id="admin-sidebar-backdrop"
class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-40 hidden transition-opacity"
></div>
<!-- Drawer Sidebar -->
<aside
id="admin-sidebar"
class="fixed top-0 left-0 h-full w-72 bg-slate-950 border-r border-white/10 p-6 z-50 transform -translate-x-full transition-transform duration-300 ease-in-out flex flex-col justify-between shadow-2xl"
>
<div class="space-y-6">
<!-- Header with Close Button -->
<div class="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<span class="text-[10px] font-mono text-sky-400 uppercase tracking-widest block">N&D WaaS Admin</span>
<h2 class="text-lg font-bold text-white">Navigation</h2>
</div>
<button
id="admin-menu-close"
type="button"
class="p-2 text-slate-400 hover:text-white rounded-lg hover:bg-slate-900 transition-colors"
aria-label="Menü schließen"
>
</button>
</div>
<!-- Navigation Links -->
<nav class="space-y-1.5" aria-label="Admin Sidebar">
{navItems.map((item) => {
const isActive = currentPath === item.href || (item.href !== '/admin' && currentPath.startsWith(item.href));
return (
<a
href={item.href}
class={`flex items-center gap-3 px-4 py-3 rounded-xl text-xs font-semibold transition-all ${
isActive
? 'bg-sky-400/10 text-sky-400 border border-sky-400/30'
: 'text-slate-300 hover:bg-slate-900 hover:text-white border border-transparent'
}`}
>
<span class="text-base">{item.icon}</span>
<span>{item.label}</span>
</a>
);
})}
</nav>
</div>
<!-- Bottom Quick Links -->
<div class="pt-4 border-t border-white/10 space-y-2">
<a
href="/?preview=draft"
target="_blank"
class="flex items-center justify-center gap-2 w-full p-2.5 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs font-semibold hover:bg-amber-500 hover:text-slate-950 transition-all"
>
🛠️ Entwurf-Vorschau
</a>
<a
href="/"
target="_blank"
class="flex items-center justify-center gap-2 w-full p-2.5 rounded-xl bg-slate-900 text-slate-300 border border-white/10 text-xs font-semibold hover:border-sky-400 hover:text-white transition-all"
>
🌐 Live-Website
</a>
</div>
</aside>
<script>
const toggleBtn = document.getElementById('admin-menu-toggle');
const closeBtn = document.getElementById('admin-menu-close');
const sidebar = document.getElementById('admin-sidebar');
const backdrop = document.getElementById('admin-sidebar-backdrop');
function openMenu() {
sidebar?.classList.remove('-translate-x-full');
backdrop?.classList.remove('hidden');
}
function closeMenu() {
sidebar?.classList.add('-translate-x-full');
backdrop?.classList.add('hidden');
}
toggleBtn?.addEventListener('click', openMenu);
closeBtn?.addEventListener('click', closeMenu);
backdrop?.addEventListener('click', closeMenu);
</script>

View File

@@ -5,18 +5,23 @@ interface Props {
config: SiteConfig['bento'];
}
const { config } = Astro.props;
const props = Astro.props;
const config = props.config || props.settings || props;
const sectionTitle = config.sectionTitle || config.title || 'Unsere Leistungen';
const sectionSubtitle = config.sectionSubtitle || config.subtitle || '';
const cards = config.cards || config.items || [];
---
<section id="services" class="py-20 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto relative">
<section id="services" class={`py-20 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto relative ${config.animation ? `anim-${config.animation}` : ''}`} style={(config as any).bg_color ? `background-color: ${(config as any).bg_color};` : ''}>
<div class="text-center max-w-3xl mx-auto mb-16 space-y-3">
<h2 class="text-3xl font-extrabold text-white tracking-tight uppercase">{config.sectionTitle}</h2>
<p class="text-slate-400 text-xs sm:text-sm">{config.sectionSubtitle}</p>
<h2 class="text-3xl font-extrabold text-white tracking-tight uppercase">{sectionTitle}</h2>
<p class="text-slate-400 text-xs sm:text-sm">{sectionSubtitle}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{config.cards.map((card) => (
<div class={`relative glass-panel glass-panel-hover rounded-2xl p-8 flex flex-col justify-between overflow-hidden group ${card.colSpan === 2 ? 'md:col-span-2' : 'md:col-span-1'}`}>
{cards.map((card: any) => (
<div class={`relative glass-panel glass-panel-hover rounded-2xl p-8 flex flex-col justify-between overflow-hidden group ${card.colSpan === 2 ? 'md:col-span-2' : 'md:col-span-1'} ${card.animation ? `anim-${card.animation}` : ''}`}>
<div class="space-y-4 relative z-10">
{card.badge && (
<span class="inline-block text-[10px] font-mono uppercase tracking-wider text-sky-400 bg-sky-400/10 px-2.5 py-1 rounded border border-sky-400/20">

View File

@@ -6,14 +6,19 @@ interface Props {
smtpConfig: SmtpConfig;
}
const { contactConfig, smtpConfig } = Astro.props;
const props = Astro.props;
const contactConfig = props.contactConfig || props.settings || props;
const smtpConfig = props.smtpConfig || { isConfigured: false };
const title = contactConfig.title || 'Kontaktieren Sie uns';
const subtitle = contactConfig.subtitle || 'Wir freuen uns auf Ihre Nachricht.';
---
<section id="contact" class="py-20 px-4 sm:px-6 lg:px-8 max-w-4xl mx-auto relative">
<section id="contact" class="py-20 px-4 sm:px-6 lg:px-8 max-w-4xl mx-auto relative" style={(contactConfig as any).bg_color ? `background-color: ${(contactConfig as any).bg_color};` : ''}>
<div class="glass-panel rounded-3xl p-8 sm:p-12 relative overflow-hidden">
<div class="text-center space-y-3 mb-10">
<h2 class="text-3xl font-extrabold text-white tracking-tight uppercase">{contactConfig.title}</h2>
<p class="text-slate-400 text-xs sm:text-sm">{contactConfig.subtitle}</p>
<h2 class="text-3xl font-extrabold text-white tracking-tight uppercase">{title}</h2>
<p class="text-slate-400 text-xs sm:text-sm">{subtitle}</p>
</div>
{!smtpConfig.isConfigured && (

View File

@@ -1,13 +1,15 @@
---
interface Props {
sectionName: string;
sectionName?: string;
message?: string;
}
const { sectionName } = Astro.props;
const { sectionName = 'Unbekannt', message } = Astro.props;
---
<section class="py-12 px-4 max-w-4xl mx-auto text-center">
<div class="glass-panel rounded-2xl p-8 text-slate-400 text-xs font-mono">
<p>⚙️ Section <strong>{sectionName}</strong> ist noch in Bearbeitung oder Platzhalter.</p>
<p>⚙️ {message || `Section ${sectionName} ist noch in Bearbeitung oder Platzhalter.`}</p>
</div>
</section>

View File

@@ -1,11 +1,19 @@
---
import type { SiteConfig, UIComponent } from '../../utils/configLoader';
import { resolveDynamicBinding } from '../../utils/dynamicDataBinding';
interface Props {
config: SiteConfig['hero'];
contextData?: Record<string, any>;
}
const { config } = Astro.props;
const props = Astro.props;
const config = props.config || props.settings || props;
const contextData = props.contextData || {};
const heroTitle = resolveDynamicBinding(config.title || config.heroTitle || 'Willkommen', contextData);
const heroSubtitle = resolveDynamicBinding(config.subtitle || config.heroSubtitle || '', contextData);
const heroBadge = resolveDynamicBinding(config.badge || config.heroBadge || '', contextData);
const elements = config.elements || [];
const badgeElements = elements.filter((el: UIComponent) => el.slotId === 'hero-badge-slot');
@@ -13,49 +21,50 @@ const actionElements = elements.filter((el: UIComponent) => el.slotId === 'hero-
const customElements = elements.filter((el: UIComponent) => !el.slotId || el.slotId === 'hero-custom-slot');
---
<section class="relative py-24 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto overflow-hidden">
<section class={`relative py-24 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto overflow-hidden ${config.animation ? `anim-${config.animation}` : ''}`} style={config.bg_color ? `background-color: ${config.bg_color};` : ''}>
<span class="aurora-glow aurora-glow-sky w-[500px] h-[500px] -top-32 -left-32"></span>
<span class="aurora-glow aurora-glow-indigo w-[400px] h-[400px] bottom-0 right-0"></span>
<div class="relative z-10 text-center max-w-4xl mx-auto space-y-8">
<!-- Slot 1: Badge Slot -->
<div id="hero-badge-slot" class="inline-flex flex-wrap items-center justify-center gap-2">
{config.badge && (
{heroBadge && (
<div class="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-sky-400/20 bg-sky-400/10 text-sky-400 text-xs font-mono tracking-wide backdrop-blur-md">
<span>{config.badge}</span>
<span>{heroBadge}</span>
</div>
)}
{badgeElements.map(el => (
<span class="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-sky-400/30 bg-sky-400/20 text-sky-300 text-xs font-mono">
{el.content}
<span class={`inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-sky-400/30 bg-sky-400/20 text-sky-300 text-xs font-mono ${el.animation0 ? `anim-${el.animation0}` : ''}`}>
{resolveDynamicBinding(el.content, contextData)}
</span>
))}
</div>
<h1 class="text-4xl sm:text-6xl font-extrabold tracking-tight text-white leading-tight">
{config.title}
{heroTitle}
</h1>
<p class="text-slate-400 text-xs sm:text-lg max-w-2xl mx-auto leading-relaxed font-normal">
{config.subtitle}
{heroSubtitle}
</p>
<!-- Slot 2: Action Buttons Slot -->
<div id="hero-actions-slot" class="flex flex-wrap items-center justify-center gap-4 pt-4">
{config.ctaPrimaryText && (
<a href={config.ctaPrimaryLink} class="px-8 py-3.5 rounded-xl bg-sky-400 text-slate-950 font-bold text-xs hover:bg-white transition-all duration-300 shadow-lg shadow-sky-400/20 active:scale-95">
{config.ctaPrimaryText}
{resolveDynamicBinding(config.ctaPrimaryText, contextData)}
</a>
)}
{config.ctaSecondaryText && (
<a href={config.ctaSecondaryLink} class="px-8 py-3.5 rounded-xl glass-panel text-white font-semibold text-xs glass-panel-hover active:scale-95">
{config.ctaSecondaryText}
{resolveDynamicBinding(config.ctaSecondaryText, contextData)}
</a>
)}
{actionElements.map(el => (
<a href={el.link || '#'} class={`px-8 py-3.5 rounded-xl font-bold text-xs transition-all active:scale-95 ${el.style === 'glass' ? 'glass-panel text-white' : 'bg-sky-400 text-slate-950 hover:bg-white'}`}>
{el.content}
<a href={el.link || '#'} class={`px-8 py-3.5 rounded-xl font-bold text-xs transition-all active:scale-95 ${el.style === 'glass' ? 'glass-panel text-white' : 'bg-sky-400 text-slate-950 hover:bg-white'} ${el.animation0 ? `anim-${el.animation0}` : ''}`}>
{resolveDynamicBinding(el.content, contextData)}
</a>
))}
</div>
@@ -66,7 +75,7 @@ const customElements = elements.filter((el: UIComponent) => !el.slotId || el.slo
{customElements.map(el => (
<div class="p-4 rounded-xl glass-panel text-xs text-slate-300">
<span class="font-bold text-sky-400 block mb-1 uppercase text-[10px]">{el.type} [ID: {el.id}]</span>
{el.content}
{resolveDynamicBinding(el.content, contextData)}
</div>
))}
</div>

109
src/db/index.ts Normal file
View File

@@ -0,0 +1,109 @@
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import fs from 'node:fs';
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const DB_PATH = path.join(DATA_DIR, 'waas.db');
let dbInstance: DatabaseSync | null = null;
export function getDb(): DatabaseSync {
if (dbInstance) return dbInstance;
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
dbInstance = new DatabaseSync(DB_PATH);
initSchema(dbInstance);
return dbInstance;
}
function initSchema(db: DatabaseSync) {
// 1. site_settings
db.exec(`
CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
// 2. pages
db.exec(`
CREATE TABLE IF NOT EXISTS pages (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
homepage_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
// 3. page_sections
db.exec(`
CREATE TABLE IF NOT EXISTS page_sections (
id TEXT PRIMARY KEY,
page_id TEXT NOT NULL,
section_type TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0,
content_draft_json TEXT NOT NULL,
content_published_json TEXT NOT NULL,
styles_json TEXT,
FOREIGN KEY (page_id) REFERENCES pages (id) ON DELETE CASCADE
);
`);
// Initial Seeding falls DB leer
seedInitialData(db);
}
function seedInitialData(db: DatabaseSync) {
const checkPages = db.prepare('SELECT COUNT(*) as count FROM pages').get() as { count: number };
if (checkPages.count > 0) return;
const now = new Date().toISOString();
// Seed site_settings
const initialSettings = {
siteName: 'N&D IT Solutions',
metaDescription: 'High-Performance Webdesign & Digital Solutions - 100% DSGVO-konform.',
is_demo_mode: false,
header: {
brandName: 'N&D',
brandHighlight: 'i-t SOLUTIONS',
ctaText: 'Erstgespräch buchen',
ctaLink: '#contact',
showThemeToggle: true,
glassEffect: true
},
footer: {
brandDescription: 'Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO.',
copyrightText: 'N&D i-t SOLUTIONS. Alle Rechte vorbehalten.',
showLegalLinks: true
},
site_info: {
title: 'N&D IT Solutions',
homepage_id: 'page_home'
}
};
db.prepare('INSERT INTO site_settings (key, value_json, updated_at) VALUES (?, ?, ?)').run(
'site_config',
JSON.stringify(initialSettings),
now
);
// Seed default page only (no prefilled sections/demo content)
db.prepare('INSERT INTO pages (id, slug, title, status, homepage_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
'page_home',
'/',
'Startseite',
'published',
'page_home',
now,
now
);
}

33
src/db/resetDb.ts Normal file
View File

@@ -0,0 +1,33 @@
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import fs from 'node:fs';
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const DB_PATH = path.join(DATA_DIR, 'waas.db');
export function resetDatabaseToBlankSlate(): void {
const db = new DatabaseSync(DB_PATH);
// Lösche bestehende Seiten & Sektionen
db.exec('DELETE FROM page_sections');
db.exec('DELETE FROM pages');
const now = new Date().toISOString();
// Leere Startseite anlegen (ohne vordefinierte Sektionen/Inhalte)
db.prepare('INSERT INTO pages (id, slug, title, status, homepage_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
'page_home',
'/',
'Startseite',
'published',
'page_home',
now,
now
);
console.log('[WaaS DB] Datenbank auf leeren Stand (Blank Slate) zurückgesetzt.');
}
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('resetDb.ts')) {
resetDatabaseToBlankSlate();
}

View File

@@ -15,13 +15,18 @@ import { THEME_PRESETS } from '../utils/themePresets';
interface Props {
title: string;
description?: string;
ogImage?: string;
}
const { siteConfig, themeConfig } = loadWaasConfigs();
const { siteConfig, themeConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const activePreset = THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark;
const colors = activePreset.colors;
const colors = {
...activePreset.colors,
...(themeConfig.customColors || {})
};
const { title, description = "Professionelles Webdesign & moderne Websites.", ogImage } = Astro.props;
const { title, description = "Professionelles Webdesign & moderne Websites." } = Astro.props;
---
<!doctype html>
@@ -32,6 +37,8 @@ const { title, description = "Professionelles Webdesign & moderne Websites." } =
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<script is:inline src="/js/editor-bridge.js"></script>
<style is:global define:vars={{
themeBg: colors.bg,
@@ -50,10 +57,11 @@ const { title, description = "Professionelles Webdesign & moderne Websites." } =
html.light {
--theme-bg: #F8FAFC;
--theme-card-bg: rgba(255, 255, 255, 0.7);
--theme-card-bg: rgba(255, 255, 255, 0.85);
--theme-text: #0F172A;
--theme-border: rgba(0, 0, 0, 0.1);
}
</style>
<!-- Inline theme initialization to prevent FOUC -->
@@ -71,8 +79,15 @@ const { title, description = "Professionelles Webdesign & moderne Websites." } =
</script>
</head>
<body class="font-sans transition-colors duration-500 antialiased">
{(siteConfig as any).is_demo_mode && (
<div class="bg-gradient-to-r from-amber-600 via-amber-500 to-amber-600 text-slate-950 text-center py-2 px-4 text-xs font-bold font-mono tracking-wide shadow-md flex items-center justify-center gap-2">
<span>🎭 DEMO-MODUS AKTIV</span>
<span class="opacity-75 font-sans font-normal">— Dies ist eine interaktive Vorschau-Instanz der N&D WaaS Engine.</span>
</div>
)}
<slot />
</body>
</html>
<style is:global>

25
src/pages/404.astro Normal file
View File

@@ -0,0 +1,25 @@
---
// src/pages/404.astro
import Layout from '../layouts/Layout.astro';
---
<Layout title="404 - Seite nicht gefunden | N&D IT Solutions">
<main class="min-h-screen bg-[#0B0F19] text-white flex items-center justify-center p-4">
<div class="preset-card p-10 bg-slate-900/80 border border-white/10 rounded-2xl max-w-lg w-full text-center space-y-6 shadow-2xl backdrop-blur-md">
<span class="text-6xl font-extrabold text-sky-400 font-mono tracking-widest block">404</span>
<h1 class="text-2xl font-bold text-white">Seite nicht gefunden</h1>
<p class="text-slate-400 text-sm leading-relaxed">
Die von Ihnen angeforderte Seite existiert nicht oder wurde verschoben.
</p>
<div class="pt-4">
<a
href="/"
class="inline-block bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold px-6 py-3 rounded-xl text-sm transition-all shadow-lg shadow-sky-500/20 active:scale-95"
>
← Zurück zur Startseite
</a>
</div>
</div>
</main>
</Layout>

117
src/pages/[...slug].astro Normal file
View File

@@ -0,0 +1,117 @@
---
// src/pages/[...slug].astro
import Layout from '../layouts/Layout.astro';
import { loadWaasConfigs } from '../utils/configLoader';
import { resolveDynamicBinding } from '../utils/dynamicDataBinding';
// Component Registry Import (Zero-Sumpf & Modular)
import HeroSection from '../components/sections/HeroSection.astro';
import BentoGrid from '../components/sections/BentoGrid.astro';
import ContactSection from '../components/sections/ContactSection.astro';
import FallbackSection from '../components/sections/FallbackSection.astro';
// Strikter SSR-Modus für Instant-Updates ohne Re-Build
export const prerender = false;
// 1. Slug aus der URL auslesen
const { slug } = Astro.params;
// 2. Config aus /app/data/site.config.json oder site.config.draft.json laden
const userAgent = Astro.request.headers.get('user-agent') ?? '';
const isDraft = Astro.url.searchParams.get('preview') === 'draft';
const { siteConfig } = loadWaasConfigs(userAgent, isDraft);
// Helper function to resolve dynamic data recursively in settings or objects
function resolveDynamicData(settings: any, dynamicBindings: any, config: any): any {
if (!settings) return settings;
const context = { site: config, ...config };
if (typeof settings === 'string') {
return resolveDynamicBinding(settings, context);
}
if (Array.isArray(settings)) {
return settings.map((item) => resolveDynamicData(item, dynamicBindings, config));
}
if (typeof settings === 'object') {
const resolved: Record<string, any> = {};
for (const [key, value] of Object.entries(settings)) {
resolved[key] = resolveDynamicData(value, dynamicBindings, config);
}
return resolved;
}
return settings;
}
// 3. Normalized Slug-Handling: "/" für Startseite, sonst z.B. "leistungen"
const currentSlug = slug === undefined || slug === '' ? '/' : slug.replace(/\/$/, '');
// 4. Richtige Seite im pages-Array ermitteln (falls vorhanden, sonst Fallback auf Hauptkonfiguration)
const pages = (siteConfig as any).pages || [
{
id: (siteConfig as any).homepage_id || 'home',
slug: '/',
title: siteConfig.siteName,
is_published: true,
sections: siteConfig.sectionsOrder.map((secType: string) => ({
id: secType,
type: secType,
settings: (siteConfig as any)[secType] || {}
}))
}
];
let page = pages.find((p: any) => {
if (currentSlug === '/') {
// Startseiten-Mapping über homepage_id oder Slug "/"
return p.id === (siteConfig as any).homepage_id || p.slug === '/' || p.slug === '';
}
return p.slug === currentSlug || p.slug === `/${currentSlug}`;
});
// 5. Zero-Crash Fallback Guard: Wenn Seite nicht existiert oder nicht veröffentlicht ist -> 404 Status
if (!page || (page.is_published === false && !isDraft)) {
Astro.response.status = 404;
return Astro.rewrite('/404');
}
// 6. Dynamic Component Registry Mapping
const ComponentRegistry: Record<string, any> = {
hero: HeroSection,
bento: BentoGrid,
contact: ContactSection,
HeroSection,
BentoGrid,
ContactSection
};
---
<Layout
title={page.title || siteConfig.siteName || 'N&D IT Solutions'}
description={page.seo?.description || siteConfig.metaDescription || ''}
ogImage={page.seo?.og_image || ''}
>
<main id="main-content" role="main" class="min-h-screen">
{
page.sections && page.sections.length > 0 ? (
page.sections.map((section: any) => {
const Component = ComponentRegistry[section.type] || FallbackSection;
// Löse Platzhalter & DB-Bindings aus /admin/ live auf
const resolvedSettings = resolveDynamicData(
section.settings || {},
section.dynamic_bindings || {},
siteConfig
);
return <Component id={section.id} settings={resolvedSettings} {...resolvedSettings} />;
})
) : (
<FallbackSection message="Diese Seite enthält aktuell noch keine Abschnitte." />
)
}
</main>
</Layout>

File diff suppressed because one or more lines are too long

View File

@@ -1,32 +1,55 @@
---
import Layout from '../../layouts/Layout.astro';
import AdminSidebar from '../../components/admin/AdminSidebar.astro';
import { loadWaasConfigs } from '../../utils/configLoader';
import { THEME_PRESETS } from '../../utils/themePresets';
const { siteConfig, themeConfig, smtpConfig } = loadWaasConfigs();
const { siteConfig, themeConfig, smtpConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const presets = Object.values(THEME_PRESETS);
---
<Layout title="Waas Admin Dashboard | Layout and Config Management">
<div class="min-h-screen bg-[#0B0F19] text-white p-6 sm:p-10 font-sans">
<AdminSidebar currentPath="/admin" />
<div class="min-h-screen bg-[#0B0F19] text-white p-6 sm:p-10 pl-16 sm:pl-20 font-sans">
<div class="max-w-6xl mx-auto space-y-10">
<!-- Admin Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b border-white/10 pb-6">
<div>
<span class="text-xs font-mono text-sky-400 uppercase tracking-widest">N& D Waas Engine</span>
<h1 class="text-3xl font-extrabold tracking-tight mt-1">Layout & Config Dashboard</h1>
<!-- Admin Header & Quick Navigation -->
<div class="space-y-4 border-b border-white/10 pb-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<span class="text-xs font-mono text-sky-400 uppercase tracking-widest">N&D WaaS Engine</span>
<h1 class="text-3xl font-extrabold tracking-tight mt-1">System & Admin Dashboard</h1>
</div>
<div class="flex items-center gap-3">
<a id="toggle-preview-mode" href="/?preview=draft" target="_blank" class="px-4 py-2 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs font-semibold hover:bg-amber-500 hover:text-slate-950 transition-all flex items-center gap-2">
🛠️ Bearbeitungsmodus (Entwurf-Vorschau)
</a>
<a href="/" target="_blank" class="px-4 py-2 rounded-xl glass-panel text-xs font-semibold hover:border-sky-400 transition-all flex items-center gap-2">
🌐 Live-Seite Ansehen
</a>
<button id="save-all-btn" class="px-6 py-2.5 rounded-xl bg-sky-400 text-slate-950 font-bold text-xs uppercase tracking-wider hover:bg-white transition-all shadow-lg shadow-sky-400/20 active:scale-95">
Einstellungen Speichern
</button>
</div>
</div>
<div class="flex items-center gap-3">
<a href="/" target="_blank" class="px-4 py-2 rounded-xl glass-panel text-xs font-semibold hover:border-sky-400 transition-all flex items-center gap-2">
Live Seite Ansehen
<!-- Quick Admin Nav Cards / Links -->
<nav aria-label="Admin Navigation" class="pt-2">
<a href="/admin/pages" class="p-4 bg-slate-900/80 border border-white/10 hover:border-sky-400/50 rounded-xl flex items-center justify-between text-xs transition-all group">
<div>
<span class="font-bold text-white block group-hover:text-sky-400 text-sm">📄 Seiten-Verwaltung & Visueller Editor</span>
<span class="text-[11px] text-slate-400">Routen, Slugs, Startseite & Seiten im Editor öffnen</span>
</div>
<span class="text-sky-400 text-base font-bold">→</span>
</a>
<button id="save-all-btn" class="px-6 py-2.5 rounded-xl bg-sky-400 text-slate-95 font-bold text-xs uppercase tracking-wider hover:bg-white transition-all shadow-lg shadow-sky-400/20 active:scale-95">
Einstellungen Speichern
</button>
</div>
</nav>
</div>
<div id="admin-alert" class="hidden p-4 rounded-xl text-xs font-mono text-center transition-all"></div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
@@ -34,72 +57,110 @@ const presets = Object.values(THEME_PRESETS);
<!-- Main Form Column -->
<div class="lg:col-span-2 space-y-8">
<!-- Site Info -->
<div class="glass-panel rounded-2xl p-6 space-y-4">
<!-- Site Info & Stammdaten -->
<div class="glass-panel rounded-2xl p-6 space-y-6">
<h2 class="text-lg font-bold text-sky-400 uppercase tracking-wider flex items-center gap-2">
Allgemeine Informationen
Allgemeine Informationen & SEO
</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-slate-400 mb-1">Webseiten-Name</label>
<label class="block text-xs font-bold text-slate-400 mb-1">Webseiten-Name (Titel)</label>
<input type="text" id="siteName" value={siteConfig.siteName} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-bold text-slate-400 mb-1">Meta Beschreibung</label>
<label class="block text-xs font-bold text-slate-400 mb-1">Meta Beschreibung (SEO)</label>
<textarea id="metaDescription" rows="2" class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400 resize-none">{siteConfig.metaDescription}</textarea>
</div>
</div>
</div>
<label class="flex items-center gap-3 pt-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl cursor-pointer">
<input type="checkbox" id="isDemoMode" checked={!!siteConfig.is_demo_mode} class="rounded text-amber-400 focus:ring-0 h-4 w-4" />
<div>
<span class="text-xs text-amber-400 font-bold block">🎭 Demo-Modus aktivieren</span>
<span class="text-[10px] text-slate-400">Zeigt einen festen Demo-Hinweis für Interessenten auf der Live-Website</span>
</div>
</label>
<!-- Hero Config -->
<div class="glass-panel rounded-3xl p-6 space-y-4">
<h2 class="text-lg font-bold text-sky-400 uppercase tracking-wider flex items-center gap-2">
Hero Sektion
</h2>
</div>
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider pt-2 border-t border-white/10">Unternehmens-Stammdaten (Dynamic Data Binding)</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-slate-400 mb-1">Badge</label>
<input type="text" id="heroBadge" value={siteConfig.hero.badge} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
<label class="block text-xs font-semibold text-slate-400 mb-1">Firmenname / Brand</label>
<input type="text" id="infoTitle" value={(siteConfig as any).site_info?.title || siteConfig.siteName} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-bold text-slate-400 mb-1">Hauptueberschrift (Title)</label>
<input type="text" id="heroTitle" value={siteConfig.hero.title} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs font-bold text-slate-400 mb-1">Unterueberschrift (Subtitle)</label>
<textarea id="heroSubtitle" rows="2" class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400 resize-none">{siteConfig.hero.subtitle}</textarea>
<label class="block text-xs font-semibold text-slate-400 mb-1">E-Mail Adresse</label>
<input type="email" id="infoEmail" value={(siteConfig as any).site_info?.email || ''} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-bold text-slate-400 mb-1">CTA2 Text</label>
<input type="text" id="ctaPrimaryText" value={siteConfig.hero.ctaPrimaryText} class="w-full glass-panel bg-slate-950-60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
<label class="block text-xs font-semibold text-slate-400 mb-1">Telefonnummer</label>
<input type="text" id="infoPhone" value={(siteConfig as any).site_info?.phone || ''} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-bold text-slate-400 mb-1">CTA2 Link</label>
<input type="text" id="ctaPrimaryLink" value={siteConfig.hero.ctaPrimaryLink} class="w-full glass-panel bg-slate-950-60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
<label class="block text-xs font-semibold text-slate-400 mb-1">Adresse / Standort</label>
<input type="text" id="infoAddress" value={(siteConfig as any).site_info?.address || ''} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
</div>
<!-- Global Header Config -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider pt-4 border-t border-white/10">Globaler Header / Navigation Bar</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Brand Name (z.B. N&D)</label>
<input type="text" id="headerBrandName" value={siteConfig.header?.brandName || 'N&D'} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Brand Highlight (z.B. i-t SOLUTIONS)</label>
<input type="text" id="headerBrandHighlight" value={siteConfig.header?.brandHighlight || 'i-t SOLUTIONS'} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">CTA Button Text</label>
<input type="text" id="headerCtaText" value={siteConfig.header?.ctaText || 'Erstgespräch buchen'} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">CTA Button Link</label>
<input type="text" id="headerCtaLink" value={siteConfig.header?.ctaLink || '#contact'} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<label class="flex items-center gap-2 cursor-pointer sm:col-span-2">
<input type="checkbox" id="headerShowThemeToggle" checked={siteConfig.header?.showThemeToggle !== false} class="rounded text-sky-400 focus:ring-0" />
<span class="text-xs text-slate-300 font-semibold">Theme-Toggle Button (☀️/🌙) anzeigen</span>
</label>
<label class="flex items-center gap-2 cursor-pointer sm:col-span-2">
<input type="checkbox" id="headerGlassEffect" checked={siteConfig.header?.glassEffect !== false} class="rounded text-sky-400 focus:ring-0" />
<span class="text-xs text-slate-300 font-semibold">Liquid Glassmorphism Effekt aktivieren</span>
</label>
</div>
<!-- Global Footer Config -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider pt-4 border-t border-white/10">Globaler Footer</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Footer Beschreibungstext</label>
<textarea id="footerBrandDescription" rows="2" class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400 resize-none">{siteConfig.footer?.brandDescription || ''}</textarea>
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Copyright Text</label>
<input type="text" id="footerCopyrightText" value={siteConfig.footer?.copyrightText || ''} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="footerShowLegalLinks" checked={siteConfig.footer?.showLegalLinks !== false} class="rounded text-sky-400 focus:ring-0" />
<span class="text-xs text-slate-300 font-semibold">Rechtliche Links (Datenschutz & Impressum) im Footer anzeigen</span>
</label>
</div>
</div>
<!-- Section Order Management -->
<div class="glass-panel rounded-2xl p-6 space-y-4">
<h2 class="text-lg font-bold text-sky-400 uppercase tracking-wider flex items-center gap-2">
Sektionen Reihenfolge (Layout)
</h2>
<p class="text-xs text-slate-400">Kommagetrennte Liste der aktiv gerenderten Sektionen:</p>
<input type="text" id="sectionsOrder" value={siteConfig.sectionsOrder.join(', ')} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white font-mono focus:outline-none focus:border-sky-400" />
</div>
</div>
<!-- Sidebar Column -->
<div class="space-y-8">
<!-- Theme Preset Picker -->
<!-- Farb-Auswahl (10 globale Farb-Kombinationen) & Custom Colors -->
<div class="glass-panel rounded-2xl p-6 space-y-4">
<h2 class="text-lg font-bold text-sky-400 uppercase tracking-wider flex items-center gap-2">
Theme Preset (10 Styles)
🎨 Farb-Auswahl (10 Farb-Kombinationen)
</h2>
<div class="space-y-2 max-h-[320px] overflow-y-auto pr-1">
<p class="text-[10px] text-slate-400">Wähle eine globale Farbkombination für das gesamte Farbspektrum:</p>
<div class="space-y-2 max-h-[260px] overflow-y-auto pr-1">
{presets.map((p) => (
<label class={`flex items-center justify-between p-3 rounded-xl border transition-all cursor-pointer ${themeConfig.activePreset === p.id ? 'border-sky-400 bg-sky-400/10' : 'border-white/5 hover:border-white/20 bg-slate-950/40'}`}>
<div class="flex items-center gap-3">
@@ -107,14 +168,40 @@ const presets = Object.values(THEME_PRESETS);
<span class="text-xs font-semibold">{p.name}</span>
</div>
<div class="flex items-center gap-1">
<span class="w-3 h-3 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.bg};`}></span>
<span class="w-3 h-3 rounded-full inline-block" style={`background-color: ${p.colors.accent};`}></span>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.bg};`} title="Hintergrund"></span>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.cardBg};`} title="Karten"></span>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.text};`} title="Text"></span>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.accent};`} title="Akzent"></span>
</div>
</label>
))}
</div>
<!-- Custom Colors Section -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider pt-3 border-t border-white/10">Individuelle Farbvergabe (Overrides)</h3>
<p class="text-[10px] text-slate-400">Überschreibe hier gezielt einzelne Preset-Farben:</p>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<label class="block text-[10px] text-slate-400 mb-1">Hintergrund (Bg)</label>
<input type="color" id="customColorBg" value={themeConfig.customColors?.bg || (THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark).colors.bg} class="w-full h-8 rounded bg-slate-900 border border-white/10 cursor-pointer p-0.5" />
</div>
<div>
<label class="block text-[10px] text-slate-400 mb-1">Akzentfarbe</label>
<input type="color" id="customColorAccent" value={themeConfig.customColors?.accent || (THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark).colors.accent} class="w-full h-8 rounded bg-slate-900 border border-white/10 cursor-pointer p-0.5" />
</div>
<div>
<label class="block text-[10px] text-slate-400 mb-1">Textfarbe</label>
<input type="color" id="customColorText" value={themeConfig.customColors?.text || (THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark).colors.text} class="w-full h-8 rounded bg-slate-900 border border-white/10 cursor-pointer p-0.5" />
</div>
<div>
<label class="block text-[10px] text-slate-400 mb-1">Rahmenfarbe (Border)</label>
<input type="text" id="customColorBorder" value={themeConfig.customColors?.border || ''} placeholder="rgba(255,255,255,0.1)" class="w-full h-8 rounded bg-slate-900 border border-white/10 text-[10px] px-2 text-white" />
</div>
</div>
</div>
<!-- SMTP Config -->
<div class="glass-panel rounded-3xl p-6 space-y-4">
<h2 class="text-lg font-bold text-sky-400 uppercase tracking-wider flex items-center gap-2">
@@ -165,7 +252,6 @@ const presets = Object.values(THEME_PRESETS);
saveBtn.textContent = 'Wird gespeichert...';
const selectedPreset = document.querySelector('input[name="activePreset"]:checked')?.value || 'corporateDark';
const sections = document.getElementById('sectionsOrder').value.split(',').map(s => s.trim()).filter(Boolean);
const getVal = (id) => (document.getElementById(id))?.value || '';
const getNum = (id) => parseInt(document.getElementById(id)?.value || '0') || 587;
@@ -175,18 +261,39 @@ const presets = Object.values(THEME_PRESETS);
siteConfig: {
siteName: getVal('siteName'),
metaDescription: getVal('metaDescription'),
hero: {
badge: getVal('heroBadge'),
title: getVal('heroTitle'),
subtitle: getVal('heroSubtitle'),
ctaPrimaryText: getVal('ctaPrimaryText'),
ctaPrimaryLink: getVal('ctaPrimaryLink')
is_demo_mode: getBool('isDemoMode'),
site_info: {
title: getVal('infoTitle'),
email: getVal('infoEmail'),
phone: getVal('infoPhone'),
address: getVal('infoAddress')
},
sectionsOrder: sections
header: {
brandName: getVal('headerBrandName'),
brandHighlight: getVal('headerBrandHighlight'),
ctaText: getVal('headerCtaText'),
ctaLink: getVal('headerCtaLink'),
showThemeToggle: getBool('headerShowThemeToggle'),
glassEffect: getBool('headerGlassEffect')
},
footer: {
brandDescription: getVal('footerBrandDescription'),
copyrightText: getVal('footerCopyrightText'),
showLegalLinks: getBool('footerShowLegalLinks')
}
},
themeConfig: {
activePreset: selectedPreset
activePreset: selectedPreset,
customColors: {
bg: getVal('customColorBg'),
accent: getVal('customColorAccent'),
text: getVal('customColorText'),
border: getVal('customColorBorder')
}
},
smtpConfig: {
host: getVal('smtpHost'),
port: getNum('smtpPort'),
@@ -197,6 +304,8 @@ const presets = Object.values(THEME_PRESETS);
};
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

188
src/pages/admin/pages.astro Normal file
View File

@@ -0,0 +1,188 @@
---
// src/pages/admin/pages.astro
import Layout from '../../layouts/Layout.astro';
import AdminSidebar from '../../components/admin/AdminSidebar.astro';
import { loadWaasConfigs } from '../../utils/configLoader';
export const prerender = false;
const userAgent = Astro.request.headers.get('user-agent') ?? '';
const { siteConfig } = loadWaasConfigs(userAgent);
const pages = (siteConfig as any).pages || [];
const currentHomepageId = (siteConfig as any).site_info?.homepage_id;
---
<Layout title="Seiten-Verwaltung | Admin Dashboard">
<AdminSidebar currentPath="/admin/pages" />
<div class="max-w-6xl mx-auto px-4 py-8 pl-16 sm:pl-20">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-white">Seiten-Verwaltung</h1>
<p class="text-slate-400 text-sm">Verwalte hier deine Routen, Slugs und lege die Startseite fest.</p>
</div>
<button
id="btn-open-modal"
class="bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold px-4 py-2 rounded-lg text-sm transition-colors"
>
+ Neue Seite anlegen
</button>
</div>
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-xl">
<table class="w-full text-left text-sm text-slate-300">
<thead class="bg-slate-950/60 text-slate-400 border-b border-slate-800 uppercase text-xs">
<tr>
<th class="p-4">Startseite</th>
<th class="p-4">Titel</th>
<th class="p-4">URL Slug</th>
<th class="p-4">Status</th>
<th class="p-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60">
{pages.map((page: any) => {
const isHomepage = page.id === currentHomepageId;
return (
<tr class="hover:bg-slate-800/40 transition-colors">
<td class="p-4">
<input
type="radio"
name="homepage"
checked={isHomepage}
data-page-id={page.id}
class="btn-set-homepage cursor-pointer accent-sky-400 h-4 w-4"
title="Als Startseite festlegen"
/>
</td>
<td class="p-4 font-semibold text-white">
{page.title}
{isHomepage && <span class="ml-2 text-xs text-sky-400 bg-sky-950 border border-sky-800 px-2 py-0.5 rounded-full">Homepage</span>}
</td>
<td class="p-4 font-mono text-xs text-slate-400">{page.slug}</td>
<td class="p-4">
{page.is_published ? (
<span class="text-emerald-400 bg-emerald-950/60 border border-emerald-800 px-2 py-0.5 rounded text-xs">Live</span>
) : (
<span class="text-amber-400 bg-amber-950/60 border border-amber-800 px-2 py-0.5 rounded text-xs">Draft</span>
)}
</td>
<td class="p-4 text-right space-x-2">
<a
href={page.slug}
target="_blank"
class="bg-slate-800 hover:bg-slate-700 text-sky-400 px-2.5 py-1.5 rounded text-xs font-medium inline-block transition-colors"
title="Seite im Browser öffnen"
>
🔗 Öffnen
</a>
<a
href={`/admin/editor/?page_id=${page.id}`}
class="bg-sky-500/20 hover:bg-sky-500 text-sky-300 hover:text-slate-950 px-2.5 py-1.5 rounded text-xs font-medium inline-block transition-colors border border-sky-500/30"
>
✏️ Editor
</a>
{!isHomepage && (
<button
type="button"
data-page-id={page.id}
data-page-title={page.title}
class="btn-delete-page bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white px-2.5 py-1.5 rounded text-xs font-medium inline-block transition-colors border border-red-500/20"
title="Seite löschen"
>
🗑️ Löschen
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<dialog id="modal-add-page" class="bg-slate-900 text-white border border-slate-800 rounded-xl p-6 backdrop:bg-slate-950/80 max-w-md w-full">
<form id="form-create-page" class="space-y-4">
<h2 class="text-lg font-bold text-white">Neue Seite anlegen</h2>
<div>
<label class="block text-xs text-slate-400 mb-1">Seitentitel</label>
<input type="text" name="title" required placeholder="z. B. Unsere Leistungen" class="w-full bg-slate-800 border border-slate-700 rounded p-2 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">URL Slug</label>
<input type="text" name="slug" required placeholder="z. B. leistungen" class="w-full bg-slate-800 border border-slate-700 rounded p-2 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div class="flex justify-end gap-2 pt-4">
<button type="button" id="btn-close-modal" class="px-4 py-2 bg-slate-800 text-slate-300 rounded text-xs">Abbrechen</button>
<button type="submit" class="px-4 py-2 bg-sky-500 text-slate-950 font-bold rounded text-xs">Seite erstellen</button>
</div>
</form>
</dialog>
</Layout>
<script>
// Event-Handler für Startseiten-Wechsel & Dialog-Modal
const modal = document.getElementById('modal-add-page') as HTMLDialogElement;
document.getElementById('btn-open-modal')?.addEventListener('click', () => modal?.showModal());
document.getElementById('btn-close-modal')?.addEventListener('click', () => modal?.close());
// Radio-Button Event: homepage_id ändern
document.querySelectorAll('.btn-set-homepage').forEach(radio => {
radio.addEventListener('change', async (e: any) => {
const pageId = e.target.getAttribute('data-page-id');
await fetch('/api/admin/pages/set-homepage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_id: pageId })
});
window.location.reload();
});
});
// Form Event: Neue Seite anlegen
document.getElementById('form-create-page')?.addEventListener('submit', async (e: Event) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const title = formData.get('title');
const slug = formData.get('slug');
const res = await fetch('/api/admin/pages/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, slug })
});
if (res.ok) {
window.location.reload();
}
});
// Event: Seite löschen
document.querySelectorAll('.btn-delete-page').forEach(btn => {
btn.addEventListener('click', async (e: any) => {
const pageId = e.target.getAttribute('data-page-id');
const pageTitle = e.target.getAttribute('data-page-title');
if (!confirm(`Möchten Sie die Seite "${pageTitle}" wirklich unwiderruflich löschen?`)) return;
const res = await fetch('/api/admin/pages/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_id: pageId })
});
if (res.ok) {
window.location.reload();
} else {
const data = await res.json();
alert(data.error || 'Fehler beim Löschen der Seite.');
}
});
});
</script>

View File

@@ -0,0 +1,78 @@
---
// src/pages/admin/settings.astro
import Layout from '../../layouts/Layout.astro';
import AdminSidebar from '../../components/admin/AdminSidebar.astro';
import { loadWaasConfigs } from '../../utils/configLoader';
export const prerender = false;
const userAgent = Astro.request.headers.get('user-agent') ?? '';
const { siteConfig } = loadWaasConfigs(userAgent);
const info = (siteConfig as any).site_info || {};
---
<Layout title="Globale Einstellungen | N&D Admin">
<AdminSidebar currentPath="/admin/settings" />
<div class="max-w-4xl mx-auto px-4 py-8 pl-16 sm:pl-20">
<div class="flex items-center justify-between mb-8 border-b border-slate-800 pb-4">
<div>
<a href="/admin/pages" class="text-xs text-slate-400 hover:text-sky-400 transition-colors">← Zurück zum Seiten-Dashboard</a>
<h1 class="text-2xl font-bold text-white mt-1">Globale Stammdaten & SEO</h1>
<p class="text-slate-400 text-sm">Diese Daten werden für automatisches Data Binding & Kontakt-Infos genutzt.</p>
</div>
</div>
<form id="form-site-settings" class="space-y-6">
<div class="bg-slate-900 border border-slate-800 rounded-xl p-6 space-y-4 shadow-xl">
<h2 class="text-base font-bold text-sky-400 border-b border-slate-800 pb-2">Unternehmensdaten</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1">Firmenname / Website-Titel</label>
<input type="text" name="title" value={info.title || ''} required class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1">E-Mail Adresse</label>
<input type="email" name="email" value={info.email || ''} class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1">Telefonnummer</label>
<input type="text" name="phone" value={info.phone || ''} class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1">Adresse / Standort</label>
<input type="text" name="address" value={info.address || ''} class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold px-6 py-3 rounded-lg text-sm transition-colors shadow-lg shadow-sky-500/20">
💾 Stammdaten Speichern
</button>
</div>
</form>
</div>
</Layout>
<script>
document.getElementById('form-site-settings')?.addEventListener('submit', async (e: Event) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const site_info = Object.fromEntries(formData.entries());
const res = await fetch('/api/admin/settings/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site_info })
});
if (res.ok) {
alert('Stammdaten erfolgreich aktualisiert!');
}
});
</script>

View File

@@ -0,0 +1,53 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { title, slug } = await request.json();
if (!title || !slug) {
return new Response(JSON.stringify({ error: 'Titel und Slug erforderlich' }), { status: 400 });
}
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const configPath = path.join(dataDir, 'site.config.json');
let config: any = {};
try {
const rawData = await fs.readFile(configPath, 'utf-8');
config = JSON.parse(rawData);
} catch {
config = {};
}
const formattedSlug = slug.startsWith('/') ? slug : `/${slug}`;
const newPageId = `page_${Date.now()}`;
const newPage = {
id: newPageId,
slug: formattedSlug,
title: title,
is_published: true,
seo: { description: `${title} - N&D IT Solutions` },
sections: [
{
id: `sec_hero_${Date.now()}`,
type: 'HeroSection',
settings: { title: title, subtitle: 'Willkommen auf dieser Unterseite.' }
}
]
};
config.pages = config.pages || [];
config.pages.push(newPage);
await fs.mkdir(dataDir, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, page: newPage }), { status: 201 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Erstellen der Seite' }), { status: 500 });
}
};

View File

@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id } = await request.json();
if (!page_id) {
return new Response(JSON.stringify({ error: 'page_id erforderlich' }), { status: 400 });
}
const configPath = path.join(DATA_DIR, 'site.config.json');
let config: any = {};
try {
const rawData = await fs.readFile(configPath, 'utf-8');
config = JSON.parse(rawData);
} catch {
return new Response(JSON.stringify({ error: 'Konfigurationsdatei nicht gefunden' }), { status: 404 });
}
// Verhindere Löschen der aktiven Homepage
if (config.site_info?.homepage_id === page_id) {
return new Response(JSON.stringify({ error: 'Die aktive Startseite kann nicht gelöscht werden.' }), { status: 400 });
}
config.pages = (config.pages || []).filter((p: any) => p.id !== page_id);
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, message: 'Seite erfolgreich gelöscht' }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Löschen der Seite' }), { status: 500 });
}
};

View File

@@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id } = await request.json();
if (!page_id) return new Response(JSON.stringify({ error: 'page_id gefordert' }), { status: 400 });
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const configPath = path.join(dataDir, 'site.config.json');
let config: any = {};
try {
const rawData = await fs.readFile(configPath, 'utf-8');
config = JSON.parse(rawData);
} catch {
config = {};
}
// Update homepage_id
config.site_info = config.site_info || {};
config.site_info.homepage_id = page_id;
await fs.mkdir(dataDir, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, homepage_id: page_id }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Speichern' }), { status: 500 });
}
};

View File

@@ -0,0 +1,41 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const POST: APIRoute = async ({ request }) => {
try {
const { site_info, navigation } = await request.json();
if (!site_info || typeof site_info !== 'object') {
return new Response(JSON.stringify({ error: 'Gültige site_info Daten erforderlich' }), { status: 400 });
}
const configPath = path.join(DATA_DIR, 'site.config.json');
let config: any = {};
try {
const raw = await fs.readFile(configPath, 'utf-8');
config = JSON.parse(raw);
} catch {
// Fallback bei neuer Instanz
config = { site_info: {}, pages: [], navigation: [] };
}
// Update der globalen Eigenschaften
config.site_info = { ...config.site_info, ...site_info };
if (Array.isArray(navigation)) {
config.navigation = navigation;
}
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, site_info: config.site_info }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Speichern der Einstellungen' }), { status: 500 });
}
};

View File

@@ -11,17 +11,36 @@ export const POST: APIRoute = async ({ request }) => {
fs.mkdirSync(dataDir, { recursive: true });
}
const { siteConfig: currentSite, themeConfig: currentTheme, smtpConfig: currentSmtp } = loadWaasConfigs();
const userAgent = request.headers.get('user-agent') ?? '';
const { siteConfig: currentSite, themeConfig: currentTheme, smtpConfig: currentSmtp } = loadWaasConfigs(userAgent);
if (body.siteConfig) {
const newSite = {
...currentSite,
...body.siteConfig,
hero: { ...currentSite.hero, ...body.siteConfig.hero }
hero: { ...(currentSite.hero || {}), ...(body.siteConfig.hero || {}) },
site_info: { ...((currentSite as any).site_info || {}), ...(body.siteConfig.site_info || {}) },
header: { ...(currentSite.header || {}), ...(body.siteConfig.header || {}) },
footer: { ...(currentSite.footer || {}), ...(body.siteConfig.footer || {}) }
};
fs.writeFileSync(path.join(dataDir, 'site.config.json'), JSON.stringify(newSite, null, 2), 'utf8');
const sitePath = path.join(dataDir, 'site.config.json');
const draftPath = path.join(dataDir, 'site.config.draft.json');
fs.writeFileSync(sitePath, JSON.stringify(newSite, null, 2), 'utf8');
// Falls bereits ein Draft existiert, site_info & is_demo_mode dort ebenfalls aktualisieren
if (fs.existsSync(draftPath)) {
try {
const draftContent = JSON.parse(fs.readFileSync(draftPath, 'utf8'));
const updatedDraft = { ...draftContent, ...newSite };
fs.writeFileSync(draftPath, JSON.stringify(updatedDraft, null, 2), 'utf8');
} catch {}
}
}
if (body.themeConfig) {
const newTheme = { ...currentTheme, ...body.themeConfig };
fs.writeFileSync(path.join(dataDir, 'theme.config.json'), JSON.stringify(newTheme, null, 2), 'utf8');

View File

@@ -13,7 +13,7 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify({ success: false, message: 'Bitte füllen Sie alle Pflichtfelder aus.' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
const { smtpConfig, siteConfig } = loadWaasConfigs();
const { smtpConfig, siteConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
if (!smtpConfig.isConfigured || smtpConfig.host === 'smtp.placeholder.local') {
console.log('[WaaS Contact API] (Demo Mode) Received Submission:', { name, email, message });

View File

@@ -0,0 +1,48 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { sender_name, sender_email, message_text, privacy_consent, website_hp } = body;
// 1. Spamschutz Honeypot Check
if (website_hp) {
return new Response(JSON.stringify({ success: true }), { status: 200 }); // Bot täuschen
}
// 2. Validierung
if (!sender_name || !sender_email || !message_text || !privacy_consent) {
return new Response(JSON.stringify({ error: 'Bitte füllen Sie alle Pflichtfelder aus.' }), { status: 400 });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(sender_email)) {
return new Response(JSON.stringify({ error: 'Ungültige E-Mail-Adresse.' }), { status: 400 });
}
// 3. SMTP Config aus Volume lesen
const smtpPath = path.join(DATA_DIR, 'smtp.config.json');
let smtpConfig: any = { host: 'localhost', port: 1025, from: 'noreply@kunden-domain.de', recipient: 'info@kunden-domain.de' };
try {
const smtpRaw = await fs.readFile(smtpPath, 'utf-8');
smtpConfig = { ...smtpConfig, ...JSON.parse(smtpRaw) };
} catch {
// Fallback: SMTP Config fehlt noch in /app/data/, Protokolliere In-Memory
console.warn('[Contact API] Missing smtp.config.json in volume. Logging message locally.');
}
// Hier erfolgt die E-Mail-Auslieferung via SMTP oder lokalem Log
console.log(`[Contact Form Submission] To: ${smtpConfig.recipient} | From: ${sender_name} (${sender_email})`);
return new Response(JSON.stringify({ success: true, message: 'Nachricht erfolgreich empfangen.' }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Interner Serverfehler beim Verarbeiten der Anfrage.' }), { status: 500 });
}
};

View File

@@ -0,0 +1,59 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
// Standard-Templates
const SECTION_TEMPLATES: Record<string, any> = {
HeroSection: {
title: 'Neue Überschrift',
subtitle: 'Beschreibungstext hier eingeben.',
ctaPrimaryText: 'Jetzt anfragen',
ctaPrimaryLink: '#contact'
},
BentoGrid: {
sectionTitle: 'Unsere Highlights',
sectionSubtitle: 'Modernste Features im Überblick'
},
ContactSection: {
title: 'Kontaktieren Sie uns',
subtitle: 'Wir antworten innerhalb von 24 Stunden.'
}
};
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, section_type } = await request.json();
if (!page_id || !section_type) {
return new Response(JSON.stringify({ error: 'Gültige page_id und section_type erforderlich' }), { status: 400 });
}
const db = getDb();
const secId = `sec_${section_type.toLowerCase()}_${Date.now()}`;
const defaultContent = JSON.stringify(SECTION_TEMPLATES[section_type] || {});
// Aktuellen max order_index ermitteln
const maxOrderRow = db.prepare('SELECT MAX(order_index) as max_idx FROM page_sections WHERE page_id = ?').get(page_id) as { max_idx: number | null };
const nextIdx = (maxOrderRow?.max_idx ?? -1) + 1;
db.prepare('INSERT INTO page_sections (id, page_id, section_type, order_index, content_draft_json, content_published_json, styles_json) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
secId,
page_id,
section_type,
nextIdx,
defaultContent,
defaultContent,
JSON.stringify({})
);
// Draft-Config via File-Generator neu kompilieren
await generateDraftConfig();
return new Response(JSON.stringify({ success: true, section_id: secId }), { status: 201 });
} catch (error) {
console.error('[API /api/editor/add-section] Exception:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Hinzufügen der Sektion' }), { status: 500 });
}
};

View File

@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, section_id } = await request.json();
if (!page_id || !section_id) {
return new Response(JSON.stringify({ error: 'page_id und section_id erforderlich' }), { status: 400 });
}
const db = getDb();
db.prepare('DELETE FROM page_sections WHERE id = ? AND page_id = ?').run(section_id, page_id);
// Draft-Config neu kompilieren
await generateDraftConfig();
return new Response(JSON.stringify({ success: true, message: 'Sektion gelöscht' }), { status: 200 });
} catch (error) {
console.error('[API /api/editor/delete-section] Exception:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Löschen der Sektion' }), { status: 500 });
}
};

View File

@@ -0,0 +1,14 @@
import type { APIRoute } from 'astro';
import { publishLiveConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async () => {
try {
const liveConfig = await publishLiveConfig();
return new Response(JSON.stringify({ success: true, message: 'Website erfolgreich veröffentlicht!', config: liveConfig }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Veröffentlichen' }), { status: 500 });
}
};

View File

@@ -0,0 +1,17 @@
import type { APIRoute } from 'astro';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const draftBody = await request.json();
// Generiert aktualisierte site.config.draft.json
const draftConfig = await generateDraftConfig();
return new Response(JSON.stringify({ success: true, message: 'Entwurf erfolgreich gespeichert', config: draftConfig }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Speichern des Entwurfs' }), { status: 500 });
}
};

View File

@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, section_id, settings } = await request.json();
if (!page_id || !section_id || !settings) {
return new Response(JSON.stringify({ error: 'page_id, section_id und settings erforderlich' }), { status: 400 });
}
const db = getDb();
const existingRow = db.prepare('SELECT content_draft_json, styles_json FROM page_sections WHERE id = ?').get(section_id) as any;
if (!existingRow) {
return new Response(JSON.stringify({ error: 'Sektion nicht gefunden' }), { status: 404 });
}
const currentDraft = JSON.parse(existingRow.content_draft_json || '{}');
const currentStyles = JSON.parse(existingRow.styles_json || '{}');
// Trenne Styles (z. B. bg_color, accent_color) von typischem Inhalt
const { bg_color, accent_color, animation, backdrop_blur, liquid_edge, padding_top, padding_right, padding_bottom, padding_left, ...contentFields } = settings;
const updatedDraft = { ...currentDraft, ...contentFields };
const updatedStyles = {
...currentStyles,
...(bg_color !== undefined ? { bg_color } : {}),
...(accent_color !== undefined ? { accent_color } : {}),
...(animation !== undefined ? { animation } : {}),
...(backdrop_blur !== undefined ? { backdrop_blur } : {}),
...(liquid_edge !== undefined ? { liquid_edge } : {}),
...(padding_top !== undefined ? { padding_top } : {}),
...(padding_right !== undefined ? { padding_right } : {}),
...(padding_bottom !== undefined ? { padding_bottom } : {}),
...(padding_left !== undefined ? { padding_left } : {})
};
db.prepare('UPDATE page_sections SET content_draft_json = ?, styles_json = ? WHERE id = ?').run(
JSON.stringify(updatedDraft),
JSON.stringify(updatedStyles),
section_id
);
// Draft-Config neu kompilieren
await generateDraftConfig();
return new Response(JSON.stringify({ success: true, settings: { ...updatedDraft, ...updatedStyles } }), { status: 200 });
} catch (error) {
console.error('[API /api/editor/update-section-settings] Exception:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Aktualisieren der Sektions-Einstellungen' }), { status: 500 });
}
};

View File

@@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, sections } = await request.json();
if (!page_id || !Array.isArray(sections)) {
return new Response(JSON.stringify({ error: 'page_id und sections-Array erforderlich' }), { status: 400 });
}
const db = getDb();
const updateStmt = db.prepare('UPDATE page_sections SET order_index = ?, content_draft_json = ? WHERE id = ?');
sections.forEach((sec, index) => {
const draftJson = JSON.stringify(sec.settings || sec.content || {});
updateStmt.run(index, draftJson, sec.id);
});
// Draft-Config neu kompilieren
await generateDraftConfig();
return new Response(
JSON.stringify({ success: true, message: 'Sektionen im Entwurf aktualisiert' }),
{ status: 200 }
);
} catch (error) {
console.error('[API /api/editor/update-section] Exception:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Aktualisieren der Sektionen' }), { status: 500 });
}
};

View File

@@ -0,0 +1,39 @@
import type { APIRoute } from 'astro';
import fs from 'fs';
import path from 'path';
// Paths inside Docker volume /app/data
const draftPath = path.resolve(process.cwd(), 'app', 'data', 'site.config.draft.json');
const livePath = path.resolve(process.cwd(), 'app', 'data', 'site.config.json');
/** GET current draft (for editor init) */
export const GET: APIRoute = async () => {
let draft = {};
if (fs.existsSync(draftPath)) {
try {
draft = JSON.parse(fs.readFileSync(draftPath, 'utf8'));
} catch (e) {
console.error('Failed to parse draft JSON', e);
}
}
return new Response(JSON.stringify({ draft }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
/** POST to save draft or publish */
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const { draft, action } = body as { draft: Record<string, any>; action?: string };
// Ensure directory exists
fs.mkdirSync(path.dirname(draftPath), { recursive: true });
// Save draft
fs.writeFileSync(draftPath, JSON.stringify(draft, null, 2), 'utf8');
// Publish if requested
if (action === 'publish') {
fs.mkdirSync(path.dirname(livePath), { recursive: true });
fs.writeFileSync(livePath, JSON.stringify(draft, null, 2), 'utf8');
}
return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};

37
src/pages/api/health.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const GET: APIRoute = async () => {
let volumeStatus = 'ok';
try {
// Prüfe Schreib- und Lesezugriff auf /app/data/
await fs.mkdir(DATA_DIR, { recursive: true });
const testFile = path.join(DATA_DIR, '.healthcheck');
await fs.writeFile(testFile, 'ok', 'utf-8');
await fs.unlink(testFile);
} catch {
volumeStatus = 'error_data_volume_unwritable';
}
const isHealthy = volumeStatus === 'ok';
return new Response(
JSON.stringify({
status: isHealthy ? 'healthy' : 'unhealthy',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
volume_status: volumeStatus,
node_version: process.version,
}),
{
status: isHealthy ? 200 : 503,
headers: { 'Content-Type': 'application/json' },
}
);
};

View File

@@ -9,7 +9,7 @@ import FallbackSection from '../components/sections/FallbackSection.astro';
import { loadWaasConfigs } from '../utils/configLoader';
import { THEME_PRESETS } from '../utils/themePresets';
const { siteConfig, themeConfig, smtpConfig } = loadWaasConfigs();
const { siteConfig, themeConfig, smtpConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const activePreset = THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark;
const colors = activePreset.colors;

View File

@@ -0,0 +1,98 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../db/index';
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export async function generateDraftConfig(): Promise<any> {
const db = getDb();
// 1. Globale Settings laden
const settingsRow = db.prepare('SELECT value_json FROM site_settings WHERE key = ?').get('site_config') as { value_json: string } | undefined;
const baseConfig = settingsRow ? JSON.parse(settingsRow.value_json) : {};
// 2. Alle Seiten laden
const pagesRows = db.prepare('SELECT * FROM pages ORDER BY created_at ASC').all() as any[];
const pages = pagesRows.map((page) => {
const sectionsRows = db.prepare('SELECT * FROM page_sections WHERE page_id = ? ORDER BY order_index ASC').all(page.id) as any[];
const sections = sectionsRows.map((sec) => {
const draftContent = JSON.parse(sec.content_draft_json || '{}');
const styles = JSON.parse(sec.styles_json || '{}');
return {
id: sec.id,
type: sec.section_type,
settings: { ...draftContent, ...styles }
};
});
return {
id: page.id,
slug: page.slug,
title: page.title,
is_published: page.status === 'published',
sections
};
});
const fullDraftConfig = {
...baseConfig,
pages,
updated_at: new Date().toISOString()
};
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(draftPath, JSON.stringify(fullDraftConfig, null, 2), 'utf-8');
return fullDraftConfig;
}
export async function publishLiveConfig(): Promise<any> {
const db = getDb();
// 1. In DB: Setze content_published_json = content_draft_json für alle Sektionen
db.exec('UPDATE page_sections SET content_published_json = content_draft_json');
// 2. Globale Settings laden
const settingsRow = db.prepare('SELECT value_json FROM site_settings WHERE key = ?').get('site_config') as { value_json: string } | undefined;
const baseConfig = settingsRow ? JSON.parse(settingsRow.value_json) : {};
// 3. Alle veröffentlichten Seiten laden
const pagesRows = db.prepare('SELECT * FROM pages WHERE status = ? ORDER BY created_at ASC').all('published') as any[];
const pages = pagesRows.map((page) => {
const sectionsRows = db.prepare('SELECT * FROM page_sections WHERE page_id = ? ORDER BY order_index ASC').all(page.id) as any[];
const sections = sectionsRows.map((sec) => {
const pubContent = JSON.parse(sec.content_published_json || '{}');
const styles = JSON.parse(sec.styles_json || '{}');
return {
id: sec.id,
type: sec.section_type,
settings: { ...pubContent, ...styles }
};
});
return {
id: page.id,
slug: page.slug,
title: page.title,
is_published: true,
sections
};
});
const fullLiveConfig = {
...baseConfig,
pages,
updated_at: new Date().toISOString()
};
const livePath = path.join(DATA_DIR, 'site.config.json');
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(livePath, JSON.stringify(fullLiveConfig, null, 2), 'utf-8');
return fullLiveConfig;
}

View File

@@ -12,6 +12,18 @@ body {
color: var(--theme-text) !important;
}
.text-accent {
color: var(--theme-accent) !important;
}
.bg-accent {
background-color: var(--theme-accent) !important;
}
.border-accent {
border-color: var(--theme-accent) !important;
}
.glass-panel {
background: var(--theme-card-bg);
backdrop-filter: blur(16px);
@@ -29,6 +41,7 @@ body {
box-shadow: 0 0 25px -5px var(--theme-accent);
}
.aurora-glow {
position: absolute;
border-radius: 50%;
@@ -46,3 +59,37 @@ body {
background: radial-gradient(circle, var(--theme-accent) 0%, transparent 70%);
}
/* Preset Animations */
.anim-spring-fade {
animation: springFade 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
.anim-hover-bounce:hover {
transform: translateY(-6px);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.anim-pulse-glow {
animation: pulseGlow 3s infinite ease-in-out;
}
.anim-slide-up {
animation: slideUp 0.6s ease-out forwards;
}
.anim-zoom-in {
animation: zoomIn 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
}
@keyframes springFade {
0% { opacity: 0; transform: translateY(20px) scale(0.95); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 15px -3px var(--theme-accent); }
50% { box-shadow: 0 0 35px 5px var(--theme-accent); }
}
@keyframes slideUp {
0% { opacity: 0; transform: translateY(30px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes zoomIn {
0% { opacity: 0; transform: scale(0.85); }
100% { opacity: 1; transform: scale(1); }
}

View File

@@ -0,0 +1,67 @@
export interface DisplayCondition {
type: 'include' | 'exclude';
scope: 'entire_site' | 'singular' | 'archive' | 'page_404';
entity?: 'page' | 'post' | 'category';
entityId?: string;
}
export interface TemplateMeta {
id: string;
name: string;
type: 'header' | 'footer' | 'single' | 'archive' | '404';
status: 'draft' | 'published';
priority: number;
conditions: DisplayCondition[];
createdAt?: string;
updatedAt?: string;
}
/**
* Resolves which template to use based on target type and request context conditions.
* Specific matches (singular/page_404) take precedence over general 'entire_site' matches.
*/
export function resolveTemplate(
templates: TemplateMeta[],
targetType: TemplateMeta['type'],
context: { url: string; routeType: 'singular' | 'archive' | '404' | 'home'; entityId?: string }
): TemplateMeta | null {
const candidates = templates.filter(t => t.type === targetType && t.status === 'published');
let bestMatch: TemplateMeta | null = null;
let highestScore = -1;
for (const tpl of candidates) {
let isIncluded = false;
let isExcluded = false;
let score = 0;
for (const cond of tpl.conditions) {
if (cond.type === 'exclude') {
if (cond.scope === 'entire_site') isExcluded = true;
if (cond.scope === 'singular' && context.routeType === 'singular' && (!cond.entityId || cond.entityId === context.entityId)) {
isExcluded = true;
}
if (cond.scope === 'page_404' && context.routeType === '404') isExcluded = true;
} else if (cond.type === 'include') {
if (cond.scope === 'entire_site') {
isIncluded = true;
score = Math.max(score, tpl.priority || 1);
} else if (cond.scope === 'singular' && context.routeType === 'singular') {
if (!cond.entityId || cond.entityId === context.entityId) {
isIncluded = true;
score = Math.max(score, (tpl.priority || 1) + 10);
}
} else if (cond.scope === 'page_404' && context.routeType === '404') {
isIncluded = true;
score = Math.max(score, (tpl.priority || 1) + 10);
}
}
}
if (isIncluded && !isExcluded && score > highestScore) {
highestScore = score;
bestMatch = tpl;
}
}
return bestMatch;
}

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { detectDeviceType } from './deviceDetect';
import { getDb } from '../db/index';
export interface UIComponent {
id: string; // Eindeutige ID (z.B. uuid / timestamp)
slotId: string; // Platz-ID (z.B. 'hero-badge-slot', 'hero-actions-slot', 'bento-slot-1')
@@ -18,12 +19,26 @@ export interface SectionConfig {
title?: string;
subtitle?: string;
badge?: string;
animation?: string;
elements?: UIComponent[];
}
export interface SiteConfig {
siteName: string;
metaDescription: string;
header?: {
brandName?: string;
brandHighlight?: string;
ctaText?: string;
ctaLink?: string;
showThemeToggle?: boolean;
glassEffect?: boolean;
};
footer?: {
brandDescription?: string;
copyrightText?: string;
showLegalLinks?: boolean;
};
hero: {
badge: string;
title: string;
@@ -32,18 +47,24 @@ export interface SiteConfig {
ctaPrimaryLink: string;
ctaSecondaryText: string;
ctaSecondaryLink: string;
animation?: string;
bg_color?: string;
elements?: UIComponent[];
};
bento: {
sectionTitle: string;
sectionSubtitle: string;
animation?: string;
bg_color?: string;
cards: Array<{
id: string;
title: string;
description: string;
colSpan?: number;
badge?: string;
icon?: string;
animation?: string;
}>;
};
contact: {
@@ -54,17 +75,43 @@ export interface SiteConfig {
address?: string;
};
sectionsOrder: string[];
sectionsDetails?: SectionConfig[];
is_demo_mode?: boolean;
site_info?: {
title?: string;
company_name?: string;
homepage_id?: string;
email?: string;
phone?: string;
address?: string;
};
pages?: Array<{
id: string;
slug: string;
title: string;
is_published?: boolean;
seo?: { description?: string; og_image?: string };
sections?: Array<{ id: string; type: string; settings?: Record<string, any> }>;
}>;
}
export interface ThemeConfig {
activePreset: string;
glassBlur?: string; // 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
glassOpacity?: number; // 0.1 to 0.9
glassBorderRadius?: string; // 'rounded-lg' | 'rounded-xl' | 'rounded-2xl'
customColors?: {
bg?: string;
cardBg?: string;
text?: string;
accent?: string;
border?: string;
};
}
export interface SmtpConfig {
host: string;
port: number;
@@ -79,6 +126,21 @@ export interface SmtpConfig {
export const FALLBACK_SITE_CONFIG: SiteConfig = {
siteName: 'N&D IT Solutions',
metaDescription: 'High-Performance Webdesign & Digital Solutions - 100% DSGVO-konform.',
is_demo_mode: false,
header: {
brandName: 'N&D',
brandHighlight: 'i-t SOLUTIONS',
ctaText: 'Erstgespräch buchen',
ctaLink: '#contact',
showThemeToggle: true,
glassEffect: true
},
footer: {
brandDescription: 'Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO für Kleinbetriebe & KMU.',
copyrightText: 'N&D i-t SOLUTIONS. Alle Rechte vorbehalten.',
showLegalLinks: true
},
hero: {
badge: 'š Waas Enterprise Baukastensystem',
title: 'Maßgeschneiderte Webseiten. Digital im Griff.',
@@ -88,8 +150,8 @@ export const FALLBACK_SITE_CONFIG: SiteConfig = {
ctaSecondaryText: 'Mehr erfahren',
ctaSecondaryLink: '#services',
elements: [
{ id: 'el-1', type: 'badge', content: 'š WaaS Engine' },
{ id: 'el-2', type: 'button', content: 'Neuer Button', link: '#contact', animation0: 'spring-fade', style: 'primary' }
{ id: 'el-1', slotId: 'hero-badge-slot', type: 'badge', content: 'WaaS Engine' },
{ id: 'el-2', slotId: 'hero-actions-slot', type: 'button', content: 'Neuer Button', link: '#contact', animation0: 'spring-fade', style: 'primary' }
]
},
bento: {
@@ -115,7 +177,7 @@ export const FALLBACK_SITE_CONFIG: SiteConfig = {
{
id: 'card-3',
title: 'Liquid Glass UI',
description: 'Moderne <EFBFBD> sthetik mit matte Boxen, Aurora Glows und physikalischen Kanten-Effekten.',
description: 'Moderne Ästhetik mit matte Boxen, Aurora Glows und physikalischen Kanten-Effekten.',
colSpan: 1,
badge: 'Design',
icon: 'layers'
@@ -132,19 +194,55 @@ export const FALLBACK_SITE_CONFIG: SiteConfig = {
},
contact: {
title: 'Bereit für Ihr Projekt?',
subtitle: 'Schreiben Sie uns direkt. Wir antworten innerhalb von 24 Stunden persúnlich.',
subtitle: 'Schreiben Sie uns direkt. Wir antworten innerhalb von 24 Stunden persönlich.',
recipientEmail: 'kontakt@ndsolutions.de',
phone: '+49 (0) 123 456789',
address: 'Deutschland'
},
sectionsOrder: ['hero', 'bento', 'contact'],
sectionsDetails: [
{ id: 'hero', type: 'hero', title: 'Hero Sektion', elements: [ {id: 'el-1', type: 'badge', content: 'š WaaS Engine' } ] },
{ id: 'hero', type: 'hero', title: 'Hero Sektion', elements: [ { id: 'el-1', slotId: 'hero-badge-slot', type: 'badge', content: 'WaaS Engine' } ] },
{ id: 'bento', type: 'bento', title: 'Bento Grid', elements: [] },
{ id: 'contact', type: 'contact', title: 'Contact Section', elements: [] }
],
site_info: {
title: 'N&D IT Solutions',
homepage_id: 'page_home'
},
pages: [
{
id: 'page_home',
slug: '/',
title: 'Startseite (Hauptseite)',
is_published: true,
sections: [
{ id: 'hero', type: 'HeroSection', settings: {} },
{ id: 'bento', type: 'BentoGrid', settings: {} },
{ id: 'contact', type: 'ContactSection', settings: {} }
]
},
{
id: 'page_templates',
slug: '/templates',
title: 'Templates & Design-Muster',
is_published: true,
sections: [
{ id: 'hero_templates', type: 'HeroSection', settings: { title: 'Templates', subtitle: 'Unsere fertigen Design-Vorlagen' } }
]
},
{
id: 'page_privacy',
slug: '/datenschutz',
title: 'Datenschutzerklärung',
is_published: true,
sections: [
{ id: 'privacy_content', type: 'HeroSection', settings: { title: 'Datenschutz', subtitle: '100% DSGVO-konforme Datenverarbeitung.' } }
]
}
]
};
export const FALLBACK_THEME_CONFIG: ThemeConfig = {
activePreset: 'corporateDark'
};
@@ -177,11 +275,50 @@ function readJsonFile<T>(filePath: string, fallback: T): T {
}
}
export function loadWaasConfigs() {
export function loadWaasConfigs(userAgent: string = '', isDraft: boolean = false) {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const sitePath = path.join(dataDir, 'site.config.json');
const themePath = path.join(dataDir, 'theme.config.json');
const smtpPath = path.join(dataDir, 'smtp.config.json');
const device = detectDeviceType(userAgent);
const draftSitePath = path.join(dataDir, 'site.config.draft.json');
const deviceSitePath = path.join(dataDir, `site.${device}.config.json`);
const genericSitePath = path.join(dataDir, 'site.config.json');
let sitePath = genericSitePath;
if (isDraft) {
if (!fs.existsSync(draftSitePath)) {
try {
const db = getDb();
const settingsRow = db.prepare('SELECT value_json FROM site_settings WHERE key = ?').get('site_config') as { value_json: string } | undefined;
const baseConfig = settingsRow ? JSON.parse(settingsRow.value_json) : {};
const pagesRows = db.prepare('SELECT * FROM pages ORDER BY created_at ASC').all() as any[];
const pages = pagesRows.map((page) => {
const sectionsRows = db.prepare('SELECT * FROM page_sections WHERE page_id = ? ORDER BY order_index ASC').all(page.id) as any[];
const sections = sectionsRows.map((sec) => ({
id: sec.id,
type: sec.section_type,
settings: { ...JSON.parse(sec.content_draft_json || '{}'), ...JSON.parse(sec.styles_json || '{}') }
}));
return { id: page.id, slug: page.slug, title: page.title, is_published: page.status === 'published', sections };
});
const fullDraftConfig = { ...baseConfig, pages, updated_at: new Date().toISOString() };
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(draftSitePath, JSON.stringify(fullDraftConfig, null, 2), 'utf-8');
} catch (e) {
console.warn('[WaaS ConfigLoader] Fallback draft compilation warning:', e);
}
}
sitePath = draftSitePath;
} else if (fs.existsSync(deviceSitePath)) {
sitePath = deviceSitePath;
}
const deviceThemePath = path.join(dataDir, `theme.${device}.config.json`);
const genericThemePath = path.join(dataDir, 'theme.config.json');
const themePath = fs.existsSync(deviceThemePath) ? deviceThemePath : genericThemePath;
const deviceSmtpPath = path.join(dataDir, `smtp.${device}.config.json`);
const genericSmtpPath = path.join(dataDir, 'smtp.config.json');
const smtpPath = fs.existsSync(deviceSmtpPath) ? deviceSmtpPath : genericSmtpPath;
const rawSite = readJsonFile<Partial<SiteConfig>>(sitePath, FALLBACK_SITE_CONFIG);
const siteConfig: SiteConfig = {
@@ -189,11 +326,19 @@ export function loadWaasConfigs() {
...rawSite,
hero: { ...FALLBACK_SITE_CONFIG.hero, ...(rawSite.hero || {}) },
bento: { ...FALLBACK_SITE_CONFIG.bento, ...(rawSite.bento || {}) },
contact: { ...FALLBACK_SITE_CONFIG.contact, ...(rawSite.contact || {}) }
contact: { ...FALLBACK_SITE_CONFIG.contact, ...(rawSite.contact || {}) },
pages: (rawSite as any).pages && (rawSite as any).pages.length > 0 ? (rawSite as any).pages : (FALLBACK_SITE_CONFIG as any).pages,
site_info: { ...(FALLBACK_SITE_CONFIG as any).site_info, ...((rawSite as any).site_info || {}) }
};
const themeConfig = readJsonFile<ThemeConfig>(themePath, FALLBACK_THEME_CONFIG);
const smtpConfig = readJsonFile<SmtpConfig>(smtpPath, FALLBACK_SMTP_CONFIG);
return { siteConfig, themeConfig, smtpConfig };
}
export function getSiteConfig(isDraft: boolean = false) {
return loadWaasConfigs('', isDraft).siteConfig;
}

14
src/utils/deviceDetect.ts Normal file
View File

@@ -0,0 +1,14 @@
export type DeviceType = 'desktop' | 'tablet' | 'mobile';
/**
* Simple User-Agent based device detection.
* Returns 'desktop' by default when detection fails.
*/
export function detectDeviceType(userAgent: string): DeviceType {
const ua = userAgent.toLowerCase();
// Tablet detection (including iPad which reports as Mac on iOS 13+)
if (/tablet|ipad/.test(ua)) return 'tablet';
// Mobile detection (phones and Android)
if (/mobile|iphone|android/.test(ua)) return 'mobile';
return 'desktop';
}

View File

@@ -0,0 +1,49 @@
export interface DynamicFieldDefinition {
key: string; // e.g. 'site.siteName', 'post.title', 'custom.clientName'
label: string;
category: 'site' | 'post' | 'author' | 'custom';
defaultValue: string;
}
export const DYNAMIC_FIELDS_REGISTRY: DynamicFieldDefinition[] = [
{ key: 'site.siteName', label: 'Website Name', category: 'site', defaultValue: 'N&D IT Solutions' },
{ key: 'site.metaDescription', label: 'Website Beschreibung', category: 'site', defaultValue: 'High-Performance Webdesign & Digital Solutions' },
{ key: 'site.contactEmail', label: 'Kontakt E-Mail', category: 'site', defaultValue: 'kontakt@ndsolutions.de' },
{ key: 'site.phone', label: 'Telefonnummer', category: 'site', defaultValue: '+49 (0) 123 456789' },
{ key: 'post.title', label: 'Beitragstitel (Dynamisch)', category: 'post', defaultValue: 'Beispiel Artikel' },
{ key: 'post.excerpt', label: 'Beitrags Auszug', category: 'post', defaultValue: 'Ein kurzer Auszug des Artikels...' },
{ key: 'author.name', label: 'Autor Name', category: 'author', defaultValue: 'N&D Team' }
];
/**
* Replaces dynamic binding tags like `{{site.siteName}}` or evaluates binding keys against a data context.
*/
export function resolveDynamicBinding(
content: string,
contextData: Record<string, any>
): string {
if (!content) return content;
return content.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
const trimmedKey = key.trim();
const keys = trimmedKey.split('.');
let val: any = contextData;
for (const k of keys) {
if (val && typeof val === 'object' && k in val) {
val = val[k];
} else {
val = undefined;
break;
}
}
if (val !== undefined && val !== null) {
return String(val);
}
// Fallback to registry default if available
const regItem = DYNAMIC_FIELDS_REGISTRY.find(f => f.key === trimmedKey);
return regItem ? regItem.defaultValue : `{{${trimmedKey}}}`;
});
}

View File

@@ -0,0 +1,48 @@
// Editor controller for draganddrop synchronization
// Provides functions to load, save and publish draft config JSON
import fs from 'fs';
import path from 'path';
const draftPath = path.resolve(process.cwd(), 'app', 'data', 'site.config.draft.json');
const livePath = path.resolve(process.cwd(), 'app', 'data', 'site.config.json');
/** Load current draft config (or empty object) */
export function loadDraft(): Record<string, any> {
if (fs.existsSync(draftPath)) {
try {
return JSON.parse(fs.readFileSync(draftPath, 'utf8'));
} catch (e) {
console.error('Failed to parse draft JSON', e);
return {};
}
}
return {};
}
/** Save draft config */
export function saveDraft(data: Record<string, any>): void {
fs.mkdirSync(path.dirname(draftPath), { recursive: true });
fs.writeFileSync(draftPath, JSON.stringify(data, null, 2), 'utf8');
}
/** Publish draft to live config atomically */
export function publishDraft(): void {
if (!fs.existsSync(draftPath)) return;
const content = fs.readFileSync(draftPath, 'utf8');
fs.mkdirSync(path.dirname(livePath), { recursive: true });
fs.writeFileSync(livePath, content, 'utf8');
}
/** Helper to get both draft and live (for debugging) */
export function getLiveConfig(): Record<string, any> {
if (fs.existsSync(livePath)) {
try {
return JSON.parse(fs.readFileSync(livePath, 'utf8'));
} catch (e) {
console.error('Failed to parse live JSON', e);
return {};
}
}
return {};
}

23
tests/contactApi.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
describe('Contact API Spamschutz & Validierung', () => {
it('soll Honeypot-Anfragen lautlos abfangen', async () => {
const hpPayload = {
sender_name: 'Bot',
sender_email: 'bot@spam.com',
message_text: 'Spam text',
privacy_consent: true,
website_hp: 'http://spam-link.com'
};
// Simulated check
const isBot = Boolean(hpPayload.website_hp);
expect(isBot).toBe(true);
});
it('soll ungültige E-Mail-Adressen ablehnen', () => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
expect(emailRegex.test('invalid-email')).toBe(false);
expect(emailRegex.test('max@beispiel.de')).toBe(true);
});
});

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'app', 'test_data_sections');
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
describe('Editor Sections API Test', () => {
beforeEach(async () => {
await fs.mkdir(DATA_DIR, { recursive: true });
const dummyConfig = {
site_info: { title: 'Test Site', homepage_id: 'page_1' },
pages: [
{
id: 'page_1',
slug: '/',
title: 'Home',
sections: [{ id: 'sec_1', type: 'HeroSection', settings: { title: 'Old Hero' } }]
}
]
};
await fs.writeFile(draftPath, JSON.stringify(dummyConfig, null, 2), 'utf-8');
});
it('soll Sektionen in site.config.draft.json erfolgreich überschreiben', async () => {
const raw = await fs.readFile(draftPath, 'utf-8');
const config = JSON.parse(raw);
config.pages[0].sections.push({ id: 'sec_2', type: 'BentoGrid', settings: {} });
await fs.writeFile(draftPath, JSON.stringify(config, null, 2), 'utf-8');
const updatedRaw = await fs.readFile(draftPath, 'utf-8');
const updatedConfig = JSON.parse(updatedRaw);
expect(updatedConfig.pages[0].sections).toHaveLength(2);
expect(updatedConfig.pages[0].sections[1].type).toBe('BentoGrid');
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'app', 'test_data_settings');
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
describe('Editor Property API Test', () => {
beforeEach(async () => {
await fs.mkdir(DATA_DIR, { recursive: true });
const dummyConfig = {
site_info: { title: 'Test Site', homepage_id: 'page_1' },
pages: [
{
id: 'page_1',
slug: '/',
title: 'Home',
sections: [
{ id: 'sec_hero_123', type: 'HeroSection', settings: { title: 'Alter Titel', subtitle: 'Alt' } }
]
}
]
};
await fs.writeFile(draftPath, JSON.stringify(dummyConfig, null, 2), 'utf-8');
});
it('soll Sektions-Settings im Entwurf erfolgreich aktualisieren', async () => {
const raw = await fs.readFile(draftPath, 'utf-8');
const config = JSON.parse(raw);
const hero = config.pages[0].sections[0];
hero.settings.title = 'Neuer Hero Titel';
hero.settings.subtitle = 'Neuer Untertitel';
await fs.writeFile(draftPath, JSON.stringify(config, null, 2), 'utf-8');
const updatedRaw = await fs.readFile(draftPath, 'utf-8');
const updatedConfig = JSON.parse(updatedRaw);
expect(updatedConfig.pages[0].sections[0].settings.title).toBe('Neuer Hero Titel');
expect(updatedConfig.pages[0].sections[0].settings.subtitle).toBe('Neuer Untertitel');
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getDb } from '../src/db/index';
import { generateDraftConfig, publishLiveConfig } from '../src/services/file-generator';
import fs from 'node:fs/promises';
import path from 'node:path';
describe('DB-Driven File Generation Service Test', () => {
it('soll Entwurf aus DB lesen und site.config.draft.json generieren', async () => {
const draftConfig = await generateDraftConfig();
expect(draftConfig).toBeDefined();
expect(draftConfig.pages).toBeDefined();
expect(Array.isArray(draftConfig.pages)).toBe(true);
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const exists = await fs.access(draftPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
it('soll Live-Config aus DB freigeben und site.config.json atomar erstellen', async () => {
const liveConfig = await publishLiveConfig();
expect(liveConfig).toBeDefined();
expect(liveConfig.pages).toBeDefined();
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const livePath = path.join(DATA_DIR, 'site.config.json');
const exists = await fs.access(livePath).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
});

18
tests/health.test.ts Normal file
View File

@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'app', 'test_data_health');
describe('Healthcheck API Test', () => {
it('soll Volume-Ordner erfolgreich erstellen und beschreiben können', async () => {
await fs.mkdir(DATA_DIR, { recursive: true });
const testFile = path.join(DATA_DIR, '.vitest_health');
await fs.writeFile(testFile, 'test', 'utf-8');
const content = await fs.readFile(testFile, 'utf-8');
await fs.unlink(testFile);
expect(content).toBe('test');
});
});

View File

@@ -0,0 +1,33 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'app', 'test_data_site_settings');
const configPath = path.join(DATA_DIR, 'site.config.json');
describe('Global Site Settings API Test', () => {
beforeEach(async () => {
await fs.mkdir(DATA_DIR, { recursive: true });
const dummyConfig = {
site_info: { title: 'Alte Firma', phone: '0123456' },
navigation: [{ label: 'Home', page_id: 'page_1' }]
};
await fs.writeFile(configPath, JSON.stringify(dummyConfig, null, 2), 'utf-8');
});
it('soll globale Stammdaten in site.config.json aktualisieren', async () => {
const raw = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(raw);
config.site_info.title = 'N&D IT Solutions GmbH';
config.site_info.phone = '+49 7473 123456';
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
const updatedRaw = await fs.readFile(configPath, 'utf-8');
const updatedConfig = JSON.parse(updatedRaw);
expect(updatedConfig.site_info.title).toBe('N&D IT Solutions GmbH');
expect(updatedConfig.site_info.phone).toBe('+49 7473 123456');
});
});

View File

@@ -21,7 +21,7 @@ describe('WaaS Engine Core Unit Tests', () => {
});
it('1. Should return fallbacks when JSON files do not exist (Zero-Crash Guarantee)', () => {
const configs = loadWaasConfigs();
const configs = loadWaasConfigs('');
expect(configs.siteConfig.siteName).toBe(FALLBACK_SITE_CONFIG.siteName);
expect(configs.themeConfig.activePreset).toBe(FALLBACK_THEME_CONFIG.activePreset);
expect(configs.smtpConfig.host).toBe(FALLBACK_SMTP_CONFIG.host);
@@ -32,7 +32,7 @@ describe('WaaS Engine Core Unit Tests', () => {
const customSite = { siteName: 'Custom Test Agency', hero: { title: 'Custom Title' } };
fs.writeFileSync(path.join(testDataDir, 'site.config.json'), JSON.stringify(customSite), 'utf8');
const configs = loadWaasConfigs();
const configs = loadWaasConfigs('');
expect(configs.siteConfig.siteName).toBe('Custom Test Agency');
expect(configs.siteConfig.hero.title).toBe('Custom Title');
expect(configs.siteConfig.hero.badge).toBe(FALLBACK_SITE_CONFIG.hero.badge);
@@ -50,4 +50,65 @@ describe('WaaS Engine Core Unit Tests', () => {
expect(heroElements.length).toBeGreaterThan(0);
expect(heroElements[0].type).toBe('badge');
});
it('5. Should load device-specific configs for desktop, mobile, tablet with fallback', () => {
fs.mkdirSync(testDataDir, { recursive: true });
fs.writeFileSync(path.join(testDataDir, 'site.mobile.config.json'), JSON.stringify({ siteName: 'Mobile Site' }), 'utf8');
fs.writeFileSync(path.join(testDataDir, 'site.tablet.config.json'), JSON.stringify({ siteName: 'Tablet Site' }), 'utf8');
fs.writeFileSync(path.join(testDataDir, 'site.desktop.config.json'), JSON.stringify({ siteName: 'Desktop Site' }), 'utf8');
const mobileConfig = loadWaasConfigs('Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)');
expect(mobileConfig.siteConfig.siteName).toBe('Mobile Site');
const tabletConfig = loadWaasConfigs('Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X)');
expect(tabletConfig.siteConfig.siteName).toBe('Tablet Site');
const desktopConfig = loadWaasConfigs('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
expect(desktopConfig.siteConfig.siteName).toBe('Desktop Site');
});
it('6. Should correctly resolve Theme Builder template based on Display Conditions', async () => {
const { resolveTemplate } = await import('../src/utils/conditionResolver');
const templates = [
{
id: 'header-global',
name: 'Global Header',
type: 'header' as const,
status: 'published' as const,
priority: 1,
conditions: [{ type: 'include' as const, scope: 'entire_site' as const }]
},
{
id: 'header-custom-page',
name: 'Custom Page Header',
type: 'header' as const,
status: 'published' as const,
priority: 1,
conditions: [{ type: 'include' as const, scope: 'singular' as const, entityId: 'landing_1' }]
}
];
// Global matching
const matchGlobal = resolveTemplate(templates, 'header', { url: '/about', routeType: 'singular', entityId: 'about_page' });
expect(matchGlobal?.id).toBe('header-global');
// Specific page matching
const matchSpecific = resolveTemplate(templates, 'header', { url: '/landing', routeType: 'singular', entityId: 'landing_1' });
expect(matchSpecific?.id).toBe('header-custom-page');
});
it('7. Should correctly resolve dynamic data tags like {{site.siteName}}', async () => {
const { resolveDynamicBinding } = await import('../src/utils/dynamicDataBinding');
const context = {
site: { siteName: 'Test Agency Pro', contactEmail: 'info@test.de' },
post: { title: 'Mein erster Post' }
};
const resolvedTitle = resolveDynamicBinding('Willkommen bei {{site.siteName}}', context);
expect(resolvedTitle).toBe('Willkommen bei Test Agency Pro');
const resolvedPost = resolveDynamicBinding('Artikel: {{post.title}}', context);
expect(resolvedPost).toBe('Artikel: Mein erster Post');
});
});