feat(waas): add catch-all route, page manager and draft-publish pipeline

This commit is contained in:
Daniel S
2026-08-09 20:59:02 +02:00
parent 074d314897
commit d03750bc53
11 changed files with 558 additions and 2 deletions

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

@@ -0,0 +1,115 @@
---
// 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 fehlt oder unveröffentlicht ist
if (!page || (page.is_published === false && import.meta.env.PROD)) {
return Astro.redirect('/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>

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

@@ -0,0 +1,141 @@
---
// src/pages/admin/pages.astro
import Layout from '../../layouts/Layout.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">
<div class="max-w-6xl mx-auto px-4 py-8">
<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={`/admin/editor/?page_id=${page.id}`}
class="bg-slate-800 hover:bg-slate-700 text-white px-3 py-1.5 rounded text-xs font-medium inline-block transition-colors"
>
✏️ Im Editor öffnen
</a>
</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();
}
});
</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,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,28 @@
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 () => {
try {
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const livePath = path.join(DATA_DIR, 'site.config.json');
// 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 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Veröffentlichen' }), { status: 500 });
}
};

View File

@@ -0,0 +1,25 @@
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 draftConfig = await request.json();
if (!draftConfig || typeof draftConfig !== 'object') {
return new Response(JSON.stringify({ error: 'Ungültige Konfigurationsdaten' }), { status: 400 });
}
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(draftPath, JSON.stringify(draftConfig, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, message: 'Entwurf erfolgreich gespeichert' }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Speichern des Entwurfs' }), { 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' } });
};

View File

@@ -210,12 +210,20 @@ function readJsonFile<T>(filePath: string, fallback: T): T {
}
}
export function loadWaasConfigs(userAgent: string = '') {
export function loadWaasConfigs(userAgent: string = '', isDraft: boolean = false) {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
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');
const sitePath = fs.existsSync(deviceSitePath) ? deviceSitePath : genericSitePath;
let sitePath = genericSitePath;
if (isDraft && fs.existsSync(draftSitePath)) {
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');
@@ -239,3 +247,8 @@ export function loadWaasConfigs(userAgent: string = '') {
return { siteConfig, themeConfig, smtpConfig };
}
export function getSiteConfig(isDraft: boolean = false) {
return loadWaasConfigs('', isDraft).siteConfig;
}

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