Compare commits

..

14 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
21 changed files with 1342 additions and 510 deletions

View File

@@ -1,104 +1,32 @@
{
"siteName": "N&D IT Solutions",
"metaDescription": "Webdesign & Digital Solutions",
"hero": {
"badge": "WaaS",
"title": "Maßgeschneiderte Webseiten. Digital im Griff.",
"subtitle": "Wir machen Zeugs",
"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"
}
]
"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
},
"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": [
"contact",
"bento",
"hero"
],
"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

@@ -1,7 +1,9 @@
{
"activePreset": "amberGlow",
"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

@@ -8,6 +8,11 @@ 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..."

View File

@@ -13,20 +13,47 @@
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);
document.body.style.paddingTop = (document.body.style.paddingTop ? parseInt(document.body.style.paddingTop) + 30 : 30) + 'px';
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') {
// Injiziert CSS-Variablen live in den DOM (<head>)
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') {

View File

@@ -5,17 +5,22 @@ 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 ${config.animation ? `anim-${config.animation}` : ''}`}>
<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) => (
{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 && (

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

@@ -7,11 +7,13 @@ interface Props {
contextData?: Record<string, any>;
}
const { config, contextData = {} } = Astro.props;
const props = Astro.props;
const config = props.config || props.settings || props;
const contextData = props.contextData || {};
const heroTitle = resolveDynamicBinding(config.title, contextData);
const heroSubtitle = resolveDynamicBinding(config.subtitle, contextData);
const heroBadge = resolveDynamicBinding(config.badge, 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');
@@ -19,10 +21,11 @@ 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 ${config.animation ? `anim-${config.animation}` : ''}`}>
<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 -->

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();
}

File diff suppressed because it is too large Load Diff

View File

@@ -154,12 +154,13 @@ const presets = Object.values(THEME_PRESETS);
<!-- Sidebar Column -->
<div class="space-y-8">
<!-- Theme Preset Picker & Custom Colors -->
<!-- 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-[220px] 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">
@@ -167,13 +168,16 @@ 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>

View File

@@ -1,40 +1,24 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
// Vordefinierte Standard-Templates für neue Sektionen
// Standard-Templates
const SECTION_TEMPLATES: Record<string, any> = {
HeroSection: {
type: 'HeroSection',
settings: {
title: 'Neue Überschrift',
subtitle: 'Beschreibungstext hier eingeben.',
cta_text: 'Jetzt anfragen',
cta_url: '#contact'
}
title: 'Neue Überschrift',
subtitle: 'Beschreibungstext hier eingeben.',
ctaPrimaryText: 'Jetzt anfragen',
ctaPrimaryLink: '#contact'
},
BentoGrid: {
type: 'BentoGrid',
settings: {
title: 'Unsere Highlights',
columns: 3,
items: [
{ title: 'Feature 1', description: 'Beschreibung 1' },
{ title: 'Feature 2', description: 'Beschreibung 2' },
{ title: 'Feature 3', description: 'Beschreibung 3' }
]
}
sectionTitle: 'Unsere Highlights',
sectionSubtitle: 'Modernste Features im Überblick'
},
ContactSection: {
type: 'ContactSection',
settings: {
title: 'Kontaktieren Sie uns',
email_recipient: 'info@kunden-domain.de'
}
title: 'Kontaktieren Sie uns',
subtitle: 'Wir antworten innerhalb von 24 Stunden.'
}
};
@@ -42,41 +26,34 @@ export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, section_type } = await request.json();
if (!page_id || !section_type || !SECTION_TEMPLATES[section_type]) {
if (!page_id || !section_type) {
return new Response(JSON.stringify({ error: 'Gültige page_id und section_type erforderlich' }), { status: 400 });
}
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const livePath = path.join(DATA_DIR, 'site.config.json');
const db = getDb();
const secId = `sec_${section_type.toLowerCase()}_${Date.now()}`;
const defaultContent = JSON.stringify(SECTION_TEMPLATES[section_type] || {});
let configRaw: string;
try {
configRaw = await fs.readFile(draftPath, 'utf-8');
} catch {
configRaw = await fs.readFile(livePath, 'utf-8');
}
// 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;
const config = JSON.parse(configRaw);
const targetPage = config.pages?.find((p: any) => p.id === page_id);
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({})
);
if (!targetPage) {
return new Response(JSON.stringify({ error: 'Seite nicht gefunden' }), { status: 404 });
}
// Draft-Config via File-Generator neu kompilieren
await generateDraftConfig();
// Neue Sektion mit eindeutiger ID anfügen
const newSection = {
id: `sec_${section_type.toLowerCase()}_${Date.now()}`,
...SECTION_TEMPLATES[section_type]
};
targetPage.sections = targetPage.sections || [];
targetPage.sections.push(newSection);
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(draftPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, section: newSection }), { status: 201 });
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

@@ -1,11 +1,9 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
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, section_id } = await request.json();
@@ -14,31 +12,15 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify({ error: 'page_id und section_id erforderlich' }), { status: 400 });
}
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const livePath = path.join(DATA_DIR, 'site.config.json');
const db = getDb();
db.prepare('DELETE FROM page_sections WHERE id = ? AND page_id = ?').run(section_id, page_id);
let configRaw: string;
try {
configRaw = await fs.readFile(draftPath, 'utf-8');
} catch {
configRaw = await fs.readFile(livePath, 'utf-8');
}
const config = JSON.parse(configRaw);
const targetPage = config.pages?.find((p: any) => p.id === page_id);
if (!targetPage) {
return new Response(JSON.stringify({ error: 'Seite nicht gefunden' }), { status: 404 });
}
// Sektion filtern / entfernen
targetPage.sections = targetPage.sections.filter((sec: any) => sec.id !== section_id);
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(draftPath, JSON.stringify(config, null, 2), 'utf-8');
// 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

@@ -1,27 +1,13 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
import { publishLiveConfig } from '../../../services/file-generator';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const POST: APIRoute = async () => {
try {
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const livePath = path.join(DATA_DIR, 'site.config.json');
const liveConfig = await publishLiveConfig();
// Prüfe ob ein Entwurf existiert
try {
await fs.access(draftPath);
} catch {
return new Response(JSON.stringify({ error: 'Kein gespeicherter Entwurf vorhanden' }), { status: 404 });
}
// Atomares Kopieren des Entwurfs auf die Live-Konfiguration
await fs.copyFile(draftPath, livePath);
return new Response(JSON.stringify({ success: true, message: 'Website erfolgreich veröffentlicht!' }), { status: 200 });
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

@@ -1,36 +1,17 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
import { generateDraftConfig } from '../../../services/file-generator';
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 draftBody = await request.json();
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const livePath = path.join(DATA_DIR, 'site.config.json');
// Generiert aktualisierte site.config.draft.json
const draftConfig = await generateDraftConfig();
let baseConfig: any = {};
try {
const raw = await fs.readFile(draftPath, 'utf-8');
baseConfig = JSON.parse(raw);
} catch {
try {
const rawLive = await fs.readFile(livePath, 'utf-8');
baseConfig = JSON.parse(rawLive);
} catch {}
}
const updatedConfig = { ...baseConfig, ...draftBody, updated_at: new Date().toISOString() };
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(draftPath, JSON.stringify(updatedConfig, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, message: 'Entwurf erfolgreich gespeichert' }), { status: 200 });
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

@@ -1,11 +1,9 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
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, section_id, settings } = await request.json();
@@ -14,36 +12,45 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify({ error: 'page_id, section_id und settings erforderlich' }), { status: 400 });
}
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const livePath = path.join(DATA_DIR, 'site.config.json');
const db = getDb();
const existingRow = db.prepare('SELECT content_draft_json, styles_json FROM page_sections WHERE id = ?').get(section_id) as any;
let configRaw: string;
try {
configRaw = await fs.readFile(draftPath, 'utf-8');
} catch {
configRaw = await fs.readFile(livePath, 'utf-8');
}
const config = JSON.parse(configRaw);
const targetPage = config.pages?.find((p: any) => p.id === page_id);
if (!targetPage) {
return new Response(JSON.stringify({ error: 'Seite nicht gefunden' }), { status: 404 });
}
// Sektion finden und Einstellungen aktualisieren
const targetSection = targetPage.sections?.find((sec: any) => sec.id === section_id);
if (!targetSection) {
if (!existingRow) {
return new Response(JSON.stringify({ error: 'Sektion nicht gefunden' }), { status: 404 });
}
targetSection.settings = { ...targetSection.settings, ...settings };
const currentDraft = JSON.parse(existingRow.content_draft_json || '{}');
const currentStyles = JSON.parse(existingRow.styles_json || '{}');
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(draftPath, JSON.stringify(config, null, 2), 'utf-8');
// 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 } : {})
};
return new Response(JSON.stringify({ success: true, settings: targetSection.settings }), { status: 200 });
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

@@ -1,11 +1,9 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
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, sections } = await request.json();
@@ -14,36 +12,23 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify({ error: 'page_id und sections-Array erforderlich' }), { status: 400 });
}
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const livePath = path.join(DATA_DIR, 'site.config.json');
const db = getDb();
const updateStmt = db.prepare('UPDATE page_sections SET order_index = ?, content_draft_json = ? WHERE id = ?');
// 1. Lade bestehende Config (Entwurf bevorzugt)
let configRaw: string;
try {
configRaw = await fs.readFile(draftPath, 'utf-8');
} catch {
configRaw = await fs.readFile(livePath, 'utf-8');
}
sections.forEach((sec, index) => {
const draftJson = JSON.stringify(sec.settings || sec.content || {});
updateStmt.run(index, draftJson, sec.id);
});
const config = JSON.parse(configRaw);
// 2. Finde die Zielseite und aktualisiere das sections-Array
const targetPage = config.pages?.find((p: any) => p.id === page_id);
if (!targetPage) {
return new Response(JSON.stringify({ error: 'Zielseite nicht gefunden' }), { status: 404 });
}
targetPage.sections = sections;
// 3. Speichere ausschließlich in site.config.draft.json
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(draftPath, JSON.stringify(config, null, 2), 'utf-8');
// 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,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

@@ -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')
@@ -47,17 +48,20 @@ export interface SiteConfig {
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;
@@ -280,7 +284,29 @@ export function loadWaasConfigs(userAgent: string = '', isDraft: boolean = false
const genericSitePath = path.join(dataDir, 'site.config.json');
let sitePath = genericSitePath;
if (isDraft && fs.existsSync(draftSitePath)) {
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;

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);
});
});