Compare commits
2 Commits
074d314897
...
0c073983e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c073983e9 | ||
|
|
d03750bc53 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -22,3 +22,11 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# coverage report
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# draft files
|
||||||
|
*.draft.json
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
27
public/js/editor-bridge.js
Normal file
27
public/js/editor-bridge.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// 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');
|
||||||
|
|
||||||
|
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 as string);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'RELOAD_CANVAS') {
|
||||||
|
// Sanfter Reload des Iframes bei Strukturänderungen
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -32,6 +32,8 @@ const { title, description = "Professionelles Webdesign & moderne Websites." } =
|
|||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
|
||||||
|
<script is:inline src="/js/editor-bridge.js"></script>
|
||||||
|
|
||||||
<style is:global define:vars={{
|
<style is:global define:vars={{
|
||||||
themeBg: colors.bg,
|
themeBg: colors.bg,
|
||||||
|
|||||||
115
src/pages/[...slug].astro
Normal file
115
src/pages/[...slug].astro
Normal 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>
|
||||||
@@ -1,842 +1,185 @@
|
|||||||
---
|
---
|
||||||
|
// src/pages/admin/editor.astro
|
||||||
import Layout from '../../layouts/Layout.astro';
|
import Layout from '../../layouts/Layout.astro';
|
||||||
import { loadWaasConfigs } from '../../utils/configLoader';
|
import { loadWaasConfigs } from '../../utils/configLoader';
|
||||||
import { THEME_PRESETS } from '../../utils/themePresets';
|
import { THEME_PRESETS } from '../../utils/themePresets';
|
||||||
|
|
||||||
const { siteConfig, themeConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
|
export const prerender = false;
|
||||||
|
|
||||||
|
const { searchParams } = Astro.url;
|
||||||
|
const pageId = searchParams.get('page_id');
|
||||||
|
|
||||||
|
// Lade den aktuellen Entwurf oder Fallback auf Live Config
|
||||||
|
const userAgent = Astro.request.headers.get('user-agent') ?? '';
|
||||||
|
const { siteConfig: draftConfig } = loadWaasConfigs(userAgent, true);
|
||||||
|
const pages = (draftConfig as any).pages || [];
|
||||||
|
const targetPage = pages.find((p: any) => p.id === pageId) || pages[0];
|
||||||
|
const previewUrl = `${targetPage?.slug || '/'}?preview=draft`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Live Visual Editor | WaaS Engine">
|
<Layout title={`Editor: ${targetPage?.title || 'Baukasten'} | N&D Admin`}>
|
||||||
<div class="h-screen w-screen flex flex-col bg-[#0B0F19] text-white font-sans overflow-hidden">
|
<div class="h-screen w-screen flex overflow-hidden bg-slate-950 font-sans">
|
||||||
|
|
||||||
<!-- Top Bar -->
|
<aside class="w-80 border-r border-slate-800 bg-slate-900 flex flex-col justify-between z-20">
|
||||||
<header class="h-14 bg-slate-900/80 border-b border-white/10 px-6 flex items-center justify-between backdrop-blur-md shrink-0 z-20">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-xs font-mono text-sky-400 font-bold uppercase tracking-widest">Waas Elementor Live Editor</span>
|
|
||||||
<span class="text-xs px-2.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-mono">SSR Live Active</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Switcher (PC | Tablet | Mobile) -->
|
|
||||||
<div class="flex items-center gap-1 bg-slate-950/70 p-1 rounded-xl border border-white/10">
|
|
||||||
<button type="button" id="device-desktop" class="device-btn px-3 py-1 rounded-lg text-xs font-semibold transition-all bg-sky-400/20 text-sky-400 border border-sky-400/30" data-device="desktop">
|
|
||||||
Desktop (100%)
|
|
||||||
</button>
|
|
||||||
<button type="button" id="device-tablet" class="device-btn px-3 py-1 rounded-lg text-xs font-semibold transition-all text-slate-400 hover:text-white" data-device="tablet">
|
|
||||||
Tablet (768px)
|
|
||||||
</button>
|
|
||||||
<button type="button" id="device-mobile" class="device-btn px-3 py-1 rounded-lg text-xs font-semibold transition-all text-slate-400 hover:text-white" data-device="mobile">
|
|
||||||
Mobile (375px)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button id="theme-conditions-btn" class="px-3.5 py-1.5 rounded-lg border border-sky-400/30 bg-sky-400/10 text-sky-400 font-semibold text-xs hover:bg-sky-400 hover:text-slate-950 transition-all">
|
|
||||||
Display Conditions
|
|
||||||
</button>
|
|
||||||
<a href="/admin" class="px-3.5 py-1.5 rounded-lg glass-panel text-xs hover:border-sky-400 transition-all">
|
|
||||||
Classic Admin
|
|
||||||
</a>
|
|
||||||
<button id="save-editor-btn" class="px-5 py-1.5 rounded-lg 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">
|
|
||||||
Live Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div id="editor-alert" class="hidden px-4 py-2 bg-emerald-500/10 border-b border-emerald-500/20 text-emerald-400 text-xs font-mono text-center"></div>
|
|
||||||
|
|
||||||
<!-- Split Screen Main -->
|
|
||||||
<div class="flex-1 flex overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Left Panel: Controls & Drag-and-Drop Sektionen -->
|
<div class="p-4 space-y-6 overflow-y-auto">
|
||||||
<aside class="w-90 sm:w-96 bg-slate-950/70 border-r border-white/10 p-5 overflow-y-auto space-y-6 shrink-0 z-10 custom-scroll">
|
<div class="flex items-center justify-between border-b border-slate-800 pb-4">
|
||||||
|
|
||||||
<!-- 3-Teiler Inspector Tabs (Inhalt, Stil, Erweitert) -->
|
|
||||||
<div class="flex bg-slate-900/80 p-1 rounded-xl border border-white/10">
|
|
||||||
<button type="button" id="tab-btn-content" class="inspector-tab-btn flex-1 py-1.5 rounded-lg text-xs font-bold transition-all bg-sky-400/20 text-sky-400 border border-sky-400/30" data-tab="content">
|
|
||||||
Inhalt
|
|
||||||
</button>
|
|
||||||
<button type="button" id="tab-btn-style" class="inspector-tab-btn flex-1 py-1.5 rounded-lg text-xs font-bold transition-all text-slate-400 hover:text-white" data-tab="style">
|
|
||||||
Stil
|
|
||||||
</button>
|
|
||||||
<button type="button" id="tab-btn-advanced" class="inspector-tab-btn flex-1 py-1.5 rounded-lg text-xs font-bold transition-all text-slate-400 hover:text-white" data-tab="advanced">
|
|
||||||
Erweitert
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TAB 1: INHALT (Sektionen, Layout & Widget Hinzufuegen) -->
|
|
||||||
<div id="tab-panel-content" class="space-y-6">
|
|
||||||
<!-- Neue Sektion Hinzufuegen -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-3">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider">
|
|
||||||
+ Neue Sektion Hinzufuegen
|
|
||||||
</h3>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<select id="new-section-select" class="flex-1 glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="hero">Hero Section</option>
|
|
||||||
<option value="bento">Bento Grid Section</option>
|
|
||||||
<option value="services">Leistungen / Services</option>
|
|
||||||
<option value="contact">Kontaktformular Section</option>
|
|
||||||
<option value="faq">FAQ Akkordeon Section</option>
|
|
||||||
<option value="testimonials">Kundenbewertungen Section</option>
|
|
||||||
<option value="cta-banner">CTA Banner Section</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" id="add-section-btn" class="px-3 py-2 rounded-lg bg-sky-400/20 text-sky-400 border border-sky-400/30 font-bold text-xs hover:bg-sky-400 hover:text-slate-950 transition-all">
|
|
||||||
+ Hinzufuegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sektionen Re-Order mit HTML5 Drag & Drop -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-3">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider flex items-center justify-between">
|
|
||||||
<span>Sektionen Reihenfolge (Layout)</span>
|
|
||||||
<span class="text-[10px] text-slate-400 font-mono">Drag & Drop</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div id="sections-list" class="space-y-2">
|
|
||||||
{siteConfig.sectionsOrder.map((sec, idx) => (
|
|
||||||
<div data-section={sec} draggable="true" class="section-item flex items-center justify-between p-3 rounded-lg bg-slate-900/60 border border-white/5 hover:border-sky-400/40 transition-all group cursor-grab active:cursor-grabbing">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-slate-500 font-mono text-xs">#{idx + 1}</span>
|
|
||||||
<span class="text-xs font-semibold capitalize text-white">{sec}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button type="button" onclick={`moveSection('${sec}', -1)`} class="p-1 hover:text-sky-400 text-xs font-bold">Up</button>
|
|
||||||
<button type="button" onclick={`moveSection('${sec}', 1)`} class="p-1 hover:text-sky-400 text-xs font-bold">Down</button>
|
|
||||||
<button type="button" onclick={`deleteSection('${sec}')`} class="p-1 text-red-400/70 hover:text-red-400 text-xs font-bold ml-1">Trash</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sektions-Inhalte & Animationen Bearbeiten -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider">
|
|
||||||
Hero Sektion: Inhalt & Animation
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Hero Title</label>
|
|
||||||
<input type="text" id="hero-title-input" value={siteConfig.hero.title} class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Hero Subtitle</label>
|
|
||||||
<textarea id="hero-subtitle-input" class="w-full h-16 glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">{siteConfig.hero.subtitle}</textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Hero Badge Text</label>
|
|
||||||
<input type="text" id="hero-badge-input" value={siteConfig.hero.badge} class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Hero Sektion Animation</label>
|
|
||||||
<select id="hero-anim-select" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="" selected={!siteConfig.hero.animation}>Keine Animation</option>
|
|
||||||
<option value="spring-fade" selected={siteConfig.hero.animation === 'spring-fade'}>Spring Fade (Federleicht)</option>
|
|
||||||
<option value="slide-up" selected={siteConfig.hero.animation === 'slide-up'}>Slide Up (Eingleiten)</option>
|
|
||||||
<option value="zoom-in" selected={siteConfig.hero.animation === 'zoom-in'}>Zoom In (Heranzoomen)</option>
|
|
||||||
<option value="pulse-glow" selected={siteConfig.hero.animation === 'pulse-glow'}>Pulse Glow (Leuchten)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bento Grid: Inhalt & Animation -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider">
|
|
||||||
Bento Grid: Inhalt & Animation
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Sektions-Titel</label>
|
|
||||||
<input type="text" id="bento-title-input" value={siteConfig.bento.sectionTitle} class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Sektions-Untertitel</label>
|
|
||||||
<input type="text" id="bento-subtitle-input" value={siteConfig.bento.sectionSubtitle} class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Bento Grid Animation</label>
|
|
||||||
<select id="bento-anim-select" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="" selected={!siteConfig.bento.animation}>Keine Animation</option>
|
|
||||||
<option value="slide-up" selected={siteConfig.bento.animation === 'slide-up'}>Slide Up</option>
|
|
||||||
<option value="spring-fade" selected={siteConfig.bento.animation === 'spring-fade'}>Spring Fade</option>
|
|
||||||
<option value="zoom-in" selected={siteConfig.bento.animation === 'zoom-in'}>Zoom In</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dynamische UIKomponente Hinzufuegen per Sektion -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider">
|
|
||||||
UI-Komponente Hinzufuegen & Verwaltungsverzeichnis
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Ziel-Sektion</label>
|
|
||||||
<select id="new-el-section" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400 capitalize">
|
|
||||||
{siteConfig.sectionsOrder.map(sec => (
|
|
||||||
<option value={sec}>{sec} Sektion</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Platz (Slot ID)</label>
|
|
||||||
<select id="new-el-slot" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="hero-badge-slot">Hero Badge Slot</option>
|
|
||||||
<option value="hero-actions-slot">Hero Buttons Slot</option>
|
|
||||||
<option value="hero-custom-slot">Hero Custom Elements Slot</option>
|
|
||||||
<option value="bento-header-slot">Bento Header Slot</option>
|
|
||||||
<option value="bento-grid-slot">Bento Grid Elements Slot</option>
|
|
||||||
<option value="contact-form-slot">Contact Form Slot</option>
|
|
||||||
<option value="section-custom-slot">Allgemeiner Sektions-Slot</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Typ</label>
|
|
||||||
<select id="new-el-type" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="button">Button (CTA)</option>
|
|
||||||
<option value="badge">Badge / Utility Tag</option>
|
|
||||||
<option value="text">Textblock / Paragraph</option>
|
|
||||||
<option value="card">Feature Bento Card</option>
|
|
||||||
<option value="accordion">FAQ Akkordeon Block</option>
|
|
||||||
<option value="stat-box">Statistiken / Zahlen-Box</option>
|
|
||||||
<option value="testimonial">Kundenbewertung / Testimonial</option>
|
|
||||||
<option value="cta-box">Banner / Call-to-Action Box</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Element Animation</label>
|
|
||||||
<select id="new-el-anim" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="">Keine Animation</option>
|
|
||||||
<option value="spring-fade">Spring Fade</option>
|
|
||||||
<option value="hover-bounce">Hover Bounce</option>
|
|
||||||
<option value="pulse-glow">Pulse Glow</option>
|
|
||||||
<option value="slide-up">Slide Up</option>
|
|
||||||
<option value="zoom-in">Zoom In</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Dynamische Daten-Verknüpfung (Dynamic Tag)</label>
|
|
||||||
<select id="new-el-dynamic-tag" onchange="if(this.value){ document.getElementById('new-el-content').value = '{{' + this.value + '}}'; }" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-sky-400 focus:outline-none focus:border-sky-400 font-mono mb-2">
|
|
||||||
<option value="">-- Statischer Text (Kein Tag) --</option>
|
|
||||||
<option value="site.siteName">site.siteName (Website Name)</option>
|
|
||||||
<option value="site.metaDescription">site.metaDescription (Beschreibung)</option>
|
|
||||||
<option value="site.contactEmail">site.contactEmail (Kontakt Email)</option>
|
|
||||||
<option value="site.phone">site.phone (Telefonnummer)</option>
|
|
||||||
<option value="post.title">post.title (Beitragstitel)</option>
|
|
||||||
<option value="post.excerpt">post.excerpt (Beitragsauszug)</option>
|
|
||||||
<option value="author.name">author.name (Autor Name)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Inhalt / Label / Tag</label>
|
|
||||||
<input type="text" id="new-el-content" placeholder="z.B. Jetzt Anfragen oder {{site.siteName}}" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400 font-mono" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" id="add-el-btn" class="w-full py-2.5 rounded-lg bg-sky-400/20 text-sky-400 border border-sky-400/30 font-bold text-xs uppercase tracking-wider hover:bg-sky-400 hover:text-slate-950 transition-all">
|
|
||||||
+ Komponente in Sektion platzieren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Component Manager per Section (Positionieren, Bearbeiten, Loeschen) -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-3">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider flex items-center justify-between">
|
|
||||||
<span>Sektions-Komponenten (<span id="elements-count">{(siteConfig.hero.elements || []).length}</span>)</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<!-- Dynamic Filter per Section -->
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<label class="text-[10px] text-slate-400 uppercase font-bold">Filter:</label>
|
|
||||||
<select id="filter-section-select" class="flex-1 glass-panel bg-slate-900 rounded p-1 text-xs text-white capitalize">
|
|
||||||
<option value="all">Alle Sektionen</option>
|
|
||||||
{siteConfig.sectionsOrder.map(sec => (
|
|
||||||
<option value={sec}>{sec}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="elements-container" class="space-y-2 max-h-80 overflow-y-auto pr-1 custom-scroll">
|
|
||||||
{((siteConfig.hero.elements || [])).map((el, idx) => (
|
|
||||||
<div data-id={el.id} draggable="true" class="element-item p-3 rounded-lg bg-slate-900/70 border border-white/10 space-y-2 text-xs group cursor-grab active:cursor-grabbing">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span class="font-bold text-sky-400 uppercase text-[10px] block">{el.type} (Slot: {el.slotId})</span>
|
|
||||||
<span id={`el-label-${el.id}`} class="text-slate-200 font-semibold">{el.content}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button type="button" onclick={`moveElement('${el.id}', -1)`} class="p-1 hover:text-sky-400 text-xs font-bold" title="Nach oben verschieben">Up</button>
|
|
||||||
<button type="button" onclick={`moveElement('${el.id}', 1)`} class="p-1 hover:text-sky-400 text-xs font-bold" title="Nach unten verschieben">Down</button>
|
|
||||||
<button type="button" onclick={`deleteElement('${el.id}')`} class="p-1 text-red-400/80 hover:text-red-400 text-xs font-bold ml-1" title="Komponente loeschen">Trash</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Inline Edit Form -->
|
|
||||||
<div class="pt-2 border-t border-white/5 flex gap-2">
|
|
||||||
<input type="text" id={`edit-input-${el.id}`} value={el.content} class="flex-1 glass-panel bg-slate-950 rounded px-2 py-1 text-xs text-white border border-white/10 focus:border-sky-400" />
|
|
||||||
<button type="button" onclick={`updateElementContent('${el.id}')`} class="px-2 py-1 rounded bg-sky-400/20 text-sky-400 border border-sky-400/30 text-[10px] font-bold hover:bg-sky-400 hover:text-slate-950 transition-all">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TAB 2: STIL (Layout, Header, Footer & Glassmorphism) -->
|
|
||||||
<div id="tab-panel-style" class="hidden space-y-6">
|
|
||||||
<!-- Glassmorphism & Theme Styles -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider">
|
|
||||||
Glassmorphism & Theme Presets
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Theme Preset</label>
|
|
||||||
<select id="style-preset-select" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
{Object.entries(THEME_PRESETS).map(([id, preset]) => (
|
|
||||||
<option value={id} selected={id === themeConfig.activePreset}>{preset.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Glass Backdrop Blur</label>
|
|
||||||
<select id="style-blur-select" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="blur-sm">Subtle (blur-sm 4px)</option>
|
|
||||||
<option value="blur-md">Medium (blur-md 8px)</option>
|
|
||||||
<option value="blur-xl" selected>Ultra Glass (blur-xl 20px)</option>
|
|
||||||
<option value="blur-3xl">Max Aurora (blur-3xl 60px)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Glass Border Radius</label>
|
|
||||||
<select id="style-radius-select" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="rounded-lg">Medium (12px)</option>
|
|
||||||
<option value="rounded-xl" selected>Large (16px)</option>
|
|
||||||
<option value="rounded-2xl">Extra Large (24px)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header Bearbeiten -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider">
|
|
||||||
Header & Navigation Bearbeiten
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Marken-Name / Logo</label>
|
|
||||||
<input type="text" id="header-brand-name" value={siteConfig.header?.brandName || 'N&D'} class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Marken Highlight</label>
|
|
||||||
<input type="text" id="header-brand-highlight" value={siteConfig.header?.brandHighlight || 'i-t SOLUTIONS'} class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">CTA Button Text</label>
|
|
||||||
<input type="text" id="header-cta-text" value={siteConfig.header?.ctaText || 'Erstgespräch buchen'} class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer Bearbeiten -->
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider">
|
|
||||||
Footer & Reconstitution Bearbeiten
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Footer Beschreibung</label>
|
|
||||||
<textarea id="footer-description" class="w-full h-16 glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">{siteConfig.footer?.brandDescription || 'Ihr Partner für professionelles Webdesign.'}</textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Copyright Text</label>
|
|
||||||
<input type="text" id="footer-copyright" value={siteConfig.footer?.copyrightText || 'N&D i-t SOLUTIONS. Alle Rechte vorbehalten.'} class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TAB 3: ERWEITERT (Animationen, Custom CSS & Z_index) -->
|
|
||||||
<div id="tab-panel-advanced" class="hidden space-y-6">
|
|
||||||
<div class="glass-panel rounded-xl p-4 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-sky-400 uppercase tracking-wider">
|
|
||||||
Erweiterte Effekte & CSS
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Globale Animation GPU Acceleration</label>
|
|
||||||
<select id="advanced-gpu-select" class="w-full glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-400">
|
|
||||||
<option value="enabled">Activated (Hardware XL)</option>
|
|
||||||
<option value="disabled">Disabled (Save Battery)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Custom CSS Overrides</label>
|
|
||||||
<textarea id="advanced-custom-css" placeholder=".custom-glass { backdrop-blur: 20px; }" class="w-full h-20 glass-panel bg-slate-900 rounded-lg p-2 text-xs text-white font-mono focus:outline-none focus:border-sky-400"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Right Panel: Live Website Preview iframe with Responsive Viewport Container -->
|
|
||||||
<main class="flex-1 bg-slate-900/90 relative flex items-center justify-center p-4 overflow-hidden">
|
|
||||||
<div id="iframe-container" class="w-full h-full transition-all duration-300 flex items-center justify-center">
|
|
||||||
<iframe id="preview-iframe" src="/" class="w-full h-full transition-all duration-300 border-none shadow-2xl rounded-xl"></iframe>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Theme Builder Conditions Modal Overlay -->
|
|
||||||
<div id="conditions-modal" class="hidden fixed inset-0 bg-slate-950/80 backdrop-blur-md z-50 flex items-center justify-center p-4">
|
|
||||||
<div class="bg-slate-900 border border-white/10 rounded-2xl p-6 w-full max-w-lg space-y-5 shadow-2xl text-xs">
|
|
||||||
<div class="flex items-center justify-between border-b border-white/10 pb-3">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-bold text-sky-400 uppercase tracking-wider">Theme Builder Display Conditions</h3>
|
<a href="/admin/pages" class="text-xs text-slate-400 hover:text-sky-400 transition-colors">← Zurück zur Übersicht</a>
|
||||||
<p class="text-slate-400 text-[11px] mt-0.5">Bestimme, wo dieses Template auf der Website erscheinen soll.</p>
|
<h1 class="text-base font-bold text-white mt-1">{targetPage?.title || 'Seite bearbeiten'}</h1>
|
||||||
</div>
|
</div>
|
||||||
<button id="close-conditions-modal" class="text-slate-400 hover:text-white font-bold text-sm">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="conditions-list" class="space-y-3 max-h-60 overflow-y-auto pr-1">
|
<div class="space-y-2">
|
||||||
<!-- Dynamic Condition Rows -->
|
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wider block">Design-Preset</label>
|
||||||
|
<select id="preset-selector" class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-xs text-white focus:outline-none focus:border-sky-500">
|
||||||
|
{Object.entries(THEME_PRESETS).map(([key, preset]) => (
|
||||||
|
<option value={key}>{preset.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="add-condition-btn" class="text-xs text-sky-400 font-bold hover:underline flex items-center gap-1">
|
<div class="space-y-2">
|
||||||
+ Bedingung hinzufügen
|
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wider block">Farbschema</label>
|
||||||
</button>
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button id="btn-mode-dark" class="px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-xs font-bold text-white hover:border-sky-500 transition-colors">🌙 Dark Mode</button>
|
||||||
|
<button id="btn-mode-light" class="px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-xs font-bold text-slate-300 hover:border-sky-500 transition-colors">☀️ Light Mode</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-3 border-t border-white/10">
|
<div class="space-y-2 pt-4 border-t border-slate-800">
|
||||||
<button id="cancel-conditions-btn" class="px-4 py-2 rounded-lg glass-panel text-slate-300 font-semibold hover:text-white">Abbrechen</button>
|
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wider block">Sektion hinzufügen</label>
|
||||||
<button id="save-conditions-btn" class="px-5 py-2 rounded-lg bg-sky-400 text-slate-950 font-bold uppercase hover:bg-white transition-all">Bedingungen Speichern</button>
|
<div class="flex gap-2">
|
||||||
|
<select id="select-new-section-type" class="flex-1 bg-slate-800 border border-slate-700 rounded-lg p-2 text-xs text-white focus:outline-none focus:border-sky-500">
|
||||||
|
<option value="HeroSection">Hero Section</option>
|
||||||
|
<option value="BentoGrid">Bento Grid</option>
|
||||||
|
<option value="ContactSection">Kontakt Formular</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-add-section" class="bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold px-3 py-2 rounded-lg text-xs transition-colors">
|
||||||
|
+ Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 pt-4 border-t border-slate-800">
|
||||||
|
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wider block">Sektionen verwalten</label>
|
||||||
|
<div id="section-list" class="space-y-2">
|
||||||
|
{targetPage?.sections?.map((sec: any, index: number) => (
|
||||||
|
<div class="p-3 bg-slate-800/80 border border-slate-700/60 rounded-lg text-xs space-y-2 text-slate-200" data-section-id={sec.id}>
|
||||||
|
<div class="flex items-center justify-between font-semibold">
|
||||||
|
<span class="text-sky-400">#{index + 1} {sec.type}</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button class="btn-move-up hover:text-sky-400 px-1" title="Nach oben">⬆️</button>
|
||||||
|
<button class="btn-move-down hover:text-sky-400 px-1" title="Nach unten">⬇️</button>
|
||||||
|
<button class="btn-delete-section hover:text-red-400 px-1" title="Sektion löschen">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="p-4 border-t border-slate-800 bg-slate-950/50 space-y-2">
|
||||||
|
<button id="btn-save-draft" class="w-full bg-slate-800 hover:bg-slate-700 text-slate-200 font-semibold py-2.5 rounded-lg text-xs transition-colors border border-slate-700">
|
||||||
|
💾 Entwurf speichern
|
||||||
|
</button>
|
||||||
|
<button id="btn-publish" class="w-full bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold py-2.5 rounded-lg text-xs transition-colors shadow-lg shadow-sky-500/20">
|
||||||
|
🚀 Live Veröffentlichen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="flex-1 bg-slate-950 flex flex-col relative">
|
||||||
|
<div class="h-10 bg-slate-900 border-b border-slate-800 flex items-center justify-between px-4 text-xs text-slate-400">
|
||||||
|
<span>Vorschau-Route: <code class="text-sky-400 font-mono">{previewUrl}</code></span>
|
||||||
|
<span id="editor-status-badge" class="text-amber-400 bg-amber-950/60 border border-amber-800/60 px-2 py-0.5 rounded text-[10px]">Entwurf aktiv</span>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
id="preview-iframe"
|
||||||
|
src={previewUrl}
|
||||||
|
class="w-full h-full border-none bg-slate-950"
|
||||||
|
title="Live Website Preview"
|
||||||
|
></iframe>
|
||||||
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<script is:inline define:vars={{ siteConfigJSON: JSON.stringify(siteConfig), themeConfigJSON: JSON.stringify(themeConfig) }}>
|
<script>
|
||||||
let siteConfigState = JSON.parse(siteConfigJSON);
|
const iframe = document.getElementById('preview-iframe') as HTMLIFrameElement;
|
||||||
let themeConfigState = JSON.parse(themeConfigJSON);
|
const statusBadge = document.getElementById('editor-status-badge');
|
||||||
|
|
||||||
if (!siteConfigState.hero.elements) siteConfigState.hero.elements = [];
|
// 1. PostMessage helper
|
||||||
|
function sendToIframe(type: string, payload: any) {
|
||||||
function reloadIframe() {
|
if (iframe?.contentWindow) {
|
||||||
const iframe = document.getElementById('preview-iframe');
|
iframe.contentWindow.postMessage({ type, payload }, '*');
|
||||||
if (iframe) iframe.src = iframe.src;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inspectorTabs = document.querySelectorAll('.inspector-tab-btn');
|
// 2. Draft Speichern
|
||||||
inspectorTabs.forEach(btn => {
|
document.getElementById('btn-save-draft')?.addEventListener('click', async () => {
|
||||||
btn.addEventListener('click', () => {
|
const res = await fetch('/api/editor/save-draft', {
|
||||||
inspectorTabs.forEach(b => {
|
method: 'POST',
|
||||||
b.classList.remove('bg-sky-400/20', 'text-sky-400', 'border', 'border-sky-400/30');
|
headers: { 'Content-Type': 'application/json' },
|
||||||
b.classList.add('text-slate-400');
|
body: JSON.stringify({ updated_at: new Date().toISOString() })
|
||||||
});
|
|
||||||
btn.classList.remove('text-slate-400');
|
|
||||||
btn.classList.add('bg-sky-400/20', 'text-sky-400', 'border', 'border-sky-400/30');
|
|
||||||
|
|
||||||
const tab = btn.getAttribute('data-tab');
|
|
||||||
document.getElementById('tab-panel-content').classList.add('hidden');
|
|
||||||
document.getElementById('tab-panel-style').classList.add('hidden');
|
|
||||||
document.getElementById('tab-panel-advanced').classList.add('hidden');
|
|
||||||
|
|
||||||
document.getElementById('tab-panel-' + tab).classList.remove('hidden');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.ok && statusBadge) {
|
||||||
|
statusBadge.textContent = 'Entwurf gespeichert';
|
||||||
|
statusBadge.className = 'text-sky-400 bg-sky-950/60 border border-sky-800/60 px-2 py-0.5 rounded text-[10px]';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const deviceBtns = document.querySelectorAll('.device-btn');
|
// 3. Veröffentlichen
|
||||||
const iframeContainer = document.getElementById('iframe-container');
|
document.getElementById('btn-publish')?.addEventListener('click', async () => {
|
||||||
|
const res = await fetch('/api/editor/publish', { method: 'POST' });
|
||||||
|
if (res.ok && statusBadge) {
|
||||||
|
statusBadge.textContent = 'Live Veröffentlicht!';
|
||||||
|
statusBadge.className = 'text-emerald-400 bg-emerald-950/60 border border-emerald-800/60 px-2 py-0.5 rounded text-[10px]';
|
||||||
|
sendToIframe('RELOAD_CANVAS', {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
deviceBtns.forEach(btn => {
|
// 4. Preset Wechsel
|
||||||
btn.addEventListener('click', () => {
|
document.getElementById('preset-selector')?.addEventListener('change', (e: any) => {
|
||||||
deviceBtns.forEach(b => {
|
sendToIframe('THEME_UPDATE', { preset: e.target.value });
|
||||||
b.classList.remove('bg-sky-400/20', 'text-sky-400', 'border', 'border-sky-400/30');
|
});
|
||||||
b.classList.add('text-slate-400');
|
|
||||||
|
// 5. Event: Sektion hinzufügen
|
||||||
|
document.getElementById('btn-add-section')?.addEventListener('click', async () => {
|
||||||
|
const sectionType = (document.getElementById('select-new-section-type') as HTMLSelectElement).value;
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const pageId = urlParams.get('page_id');
|
||||||
|
|
||||||
|
const res = await fetch('/api/editor/add-section', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ page_id: pageId, section_type: sectionType })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Event: Sektion löschen
|
||||||
|
document.querySelectorAll('.btn-delete-section').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e: any) => {
|
||||||
|
const card = e.target.closest('[data-section-id]');
|
||||||
|
const sectionId = card?.getAttribute('data-section-id');
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const pageId = urlParams.get('page_id');
|
||||||
|
|
||||||
|
const res = await fetch('/api/editor/delete-section', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ page_id: pageId, section_id: sectionId })
|
||||||
});
|
});
|
||||||
btn.classList.remove('text-slate-400');
|
|
||||||
btn.classList.add('bg-sky-400/20', 'text-sky-400', 'border', 'border-sky-400/30');
|
|
||||||
|
|
||||||
const device = btn.getAttribute('data-device');
|
if (res.ok) {
|
||||||
if (device === 'mobile') {
|
window.location.reload();
|
||||||
iframeContainer.className = 'w-[375px] h-[667px] mx-auto transition-all duration-300 shadow-2xl rounded-2xl border border-white/10 overflow-hidden shrink-0';
|
|
||||||
} else if (device === 'tablet') {
|
|
||||||
iframeContainer.className = 'w-[768px] h-[1024px] mx-auto transition-all duration-300 shadow-2xl rounded-2xl border border-white/10 overflow-hidden shrink-0';
|
|
||||||
} else {
|
|
||||||
iframeContainer.className = 'w-full h-full transition-all duration-300 flex items-center justify-center';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const addSectionBtn = document.getElementById('add-section-btn');
|
|
||||||
if (addSectionBtn) {
|
|
||||||
addSectionBtn.addEventListener('click', () => {
|
|
||||||
const newSec = document.getElementById('new-section-select').value;
|
|
||||||
if (!siteConfigState.sectionsOrder.includes(newSec)) {
|
|
||||||
siteConfigState.sectionsOrder.push(newSec);
|
|
||||||
renderSectionsList();
|
|
||||||
updateSectionSelectors();
|
|
||||||
} else {
|
|
||||||
alert('Sektion ist bereits im Layout enthalten!');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.deleteSection = function(secName) {
|
|
||||||
siteConfigState.sectionsOrder = siteConfigState.sectionsOrder.filter(s => s !== secName);
|
|
||||||
renderSectionsList();
|
|
||||||
updateSectionSelectors();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.moveSection = function(secName, dir) {
|
|
||||||
const arr = siteConfigState.sectionsOrder;
|
|
||||||
const idx = arr.indexOf(secName);
|
|
||||||
if (idx === -1) return;
|
|
||||||
const targetIdx = idx + dir;
|
|
||||||
if (targetIdx < 0 || targetIdx >= arr.length) return;
|
|
||||||
|
|
||||||
const temp = arr[idx];
|
|
||||||
arr[idx] = arr[targetIdx];
|
|
||||||
arr[targetIdx] = temp;
|
|
||||||
|
|
||||||
renderSectionsList();
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Drag & Drop for Section Items ---
|
|
||||||
let draggedSecIndex = -1;
|
|
||||||
|
|
||||||
function initSectionDragAndDrop() {
|
|
||||||
const listContainer = document.getElementById('sections-list');
|
|
||||||
if (!listContainer) return;
|
|
||||||
|
|
||||||
const items = listContainer.querySelectorAll('.section-item');
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
item.addEventListener('dragstart', (e) => {
|
|
||||||
draggedSecIndex = index;
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
item.classList.add('opacity-40');
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('dragend', () => {
|
|
||||||
item.classList.remove('opacity-40');
|
|
||||||
draggedSecIndex = -1;
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (draggedSecIndex !== -1 && draggedSecIndex !== index) {
|
|
||||||
const arr = siteConfigState.sectionsOrder;
|
|
||||||
const movedItem = arr.splice(draggedSecIndex, 1)[0];
|
|
||||||
arr.splice(index, 0, movedItem);
|
|
||||||
renderSectionsList();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSectionsList() {
|
|
||||||
const listContainer = document.getElementById('sections-list');
|
|
||||||
if (!listContainer) return;
|
|
||||||
listContainer.innerHTML = siteConfigState.sectionsOrder.map((sec, idx) => (
|
|
||||||
'<div data-section="' + sec + '" draggable="true" class="section-item flex items-center justify-between p-3 rounded-lg bg-slate-900/60 border border-white/5 hover:border-sky-400/40 transition-all group cursor-grab active:cursor-grabbing">' +
|
|
||||||
'<div class="flex items-center gap-2">' +
|
|
||||||
'<span class="text-slate-500 font-mono text-xs">#' + (idx + 1) + '</span>' +
|
|
||||||
'<span class="text-xs font-semibold capitalize text-white">' + sec + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="flex items-center gap-1">' +
|
|
||||||
'<button type="button" onclick="moveSection(\'' + sec + '\', -1)" class="p-1 hover:text-sky-400 text-xs font-bold">Up</button>' +
|
|
||||||
'<button type="button" onclick="moveSection(\'' + sec + '\', 1)" class="p-1 hover:text-sky-400 text-xs font-bold">Down</button>' +
|
|
||||||
'<button type="button" onclick="deleteSection(\'' + sec + '\')" class="p-1 text-red-400/70 hover:text-red-400 text-xs font-bold ml-1">Trash</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>'
|
|
||||||
)).join('');
|
|
||||||
|
|
||||||
initSectionDragAndDrop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial call for drag and drop setup
|
|
||||||
initSectionDragAndDrop();
|
|
||||||
|
|
||||||
function updateSectionSelectors() {
|
|
||||||
const newElSec = document.getElementById('new-el-section');
|
|
||||||
const filterSec = document.getElementById('filter-section-select');
|
|
||||||
if (newElSec) {
|
|
||||||
newElSec.innerHTML = siteConfigState.sectionsOrder.map(s => '<option value="' + s + '">' + s + ' Sektion</option>').join('');
|
|
||||||
}
|
|
||||||
if (filterSec) {
|
|
||||||
const current = filterSec.value;
|
|
||||||
filterSec.innerHTML = '<option value="all">Alle Sektionen</option>' + siteConfigState.sectionsOrder.map(s => '<option value="' + s + '">' + s + '</option>').join('');
|
|
||||||
filterSec.value = current;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- UIComponent Management: Add, Edit, Delete, Position ---
|
|
||||||
|
|
||||||
const addBtn = document.getElementById('add-el-btn');
|
|
||||||
if (addBtn) {
|
|
||||||
addBtn.addEventListener('click', () => {
|
|
||||||
const section = document.getElementById('new-el-section').value;
|
|
||||||
const slotId = document.getElementById('new-el-slot').value;
|
|
||||||
const type = document.getElementById('new-el-type').value;
|
|
||||||
const anim = document.getElementById('new-el-anim').value;
|
|
||||||
const content = document.getElementById('new-el-content').value || 'Neues Element';
|
|
||||||
|
|
||||||
const newComp = {
|
|
||||||
id: 'el-' + Date.now(),
|
|
||||||
slotId: section + '-' + slotId,
|
|
||||||
type,
|
|
||||||
content,
|
|
||||||
animation0: anim || undefined,
|
|
||||||
style: 'primary'
|
|
||||||
};
|
|
||||||
|
|
||||||
siteConfigState.hero.elements.push(newComp);
|
|
||||||
document.getElementById('new-el-content').value = '';
|
|
||||||
renderElementsContainer();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.deleteElement = function(elId) {
|
|
||||||
siteConfigState.hero.elements = siteConfigState.hero.elements.filter(e => e.id !== elId);
|
|
||||||
renderElementsContainer();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.moveElement = function(elId, dir) {
|
|
||||||
const arr = siteConfigState.hero.elements;
|
|
||||||
const idx = arr.findIndex(e => e.id === elId);
|
|
||||||
if (idx === -1) return;
|
|
||||||
const targetIdx = idx + dir;
|
|
||||||
if (targetIdx < 0 || targetIdx >= arr.length) return;
|
|
||||||
|
|
||||||
const temp = arr[idx];
|
|
||||||
arr[idx] = arr[targetIdx];
|
|
||||||
arr[targetIdx] = temp;
|
|
||||||
|
|
||||||
renderElementsContainer();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.updateElementContent = function(elId) {
|
|
||||||
const input = document.getElementById('edit-input-' + elId);
|
|
||||||
if (!input) return;
|
|
||||||
const el = siteConfigState.hero.elements.find(e => e.id === elId);
|
|
||||||
if (el) {
|
|
||||||
el.content = input.value;
|
|
||||||
const label = document.getElementById('el-label-' + elId);
|
|
||||||
if (label) label.textContent = input.value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterSecSelect = document.getElementById('filter-section-select');
|
|
||||||
if (filterSecSelect) {
|
|
||||||
filterSecSelect.addEventListener('change', renderElementsContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderElementsContainer() {
|
|
||||||
const container = document.getElementById('elements-container');
|
|
||||||
const countEl = document.getElementById('elements-count');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const filterSec = filterSecSelect ? filterSecSelect.value : 'all';
|
|
||||||
let els = siteConfigState.hero.elements || [];
|
|
||||||
|
|
||||||
if (filterSec !== 'all') {
|
|
||||||
els = els.filter(e => e.slotId && e.slotId.startsWith(filterSec));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (countEl) countEl.textContent = els.length;
|
|
||||||
|
|
||||||
container.innerHTML = els.map((el, idx) => (
|
|
||||||
'<div data-id="' + el.id + '" draggable="true" class="element-item p-3 rounded-lg bg-slate-900/70 border border-white/10 space-y-2 text-xs group cursor-grab active:cursor-grabbing">' +
|
|
||||||
'<div class="flex items-center justify-between">' +
|
|
||||||
'<div>' +
|
|
||||||
'<span class="font-bold text-sky-400 uppercase text-[10px] block">' + el.type + ' (Slot: ' + el.slotId + ')</span>' +
|
|
||||||
'<span id="el-label-' + el.id + '" class="text-slate-200 font-semibold">' + el.content + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="flex items-center gap-1">' +
|
|
||||||
'<button type="button" onclick="moveElement(\'' + el.id + '\', -1)" class="p-1 hover:text-sky-400 text-xs font-bold" title="Nach oben">Up</button>' +
|
|
||||||
'<button type="button" onclick="moveElement(\'' + el.id + '\', 1)" class="p-1 hover:text-sky-400 text-xs font-bold" title="Nach unten">Down</button>' +
|
|
||||||
'<button type="button" onclick="deleteElement(\'' + el.id + '\')" class="p-1 text-red-400/80 hover:text-red-400 text-xs font-bold ml-1" title="Loeschen">Trash</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="pt-2 border-t border-white/5 flex gap-2">' +
|
|
||||||
'<input type="text" id="edit-input-' + el.id + '" value="' + el.content.replace(/"/g, '"') + '" class="flex-1 glass-panel bg-slate-950 rounded px-2 py-1 text-xs text-white border border-white/10 focus:border-sky-400" />' +
|
|
||||||
'<button type="button" onclick="updateElementContent(\'' + el.id + '\')" class="px-2 py-1 rounded bg-sky-400/20 text-sky-400 border border-sky-400/30 text-[10px] font-bold hover:bg-sky-400 hover:text-slate-950 transition-all">Save</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>'
|
|
||||||
)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stylePresetSelect = document.getElementById('style-preset-select');
|
|
||||||
if (stylePresetSelect) {
|
|
||||||
stylePresetSelect.addEventListener('change', () => {
|
|
||||||
themeConfigState.activePreset = stylePresetSelect.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveBtn = document.getElementById('save-editor-btn');
|
|
||||||
const alertBox = document.getElementById('editor-alert');
|
|
||||||
|
|
||||||
if (saveBtn) {
|
|
||||||
saveBtn.addEventListener('click', async () => {
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
saveBtn.textContent = 'Wird gespeichert...';
|
|
||||||
|
|
||||||
// Collect Hero Section Inputs & Animation
|
|
||||||
const heroTitle = document.getElementById('hero-title-input')?.value;
|
|
||||||
const heroSubtitle = document.getElementById('hero-subtitle-input')?.value;
|
|
||||||
const heroBadge = document.getElementById('hero-badge-input')?.value;
|
|
||||||
const heroAnim = document.getElementById('hero-anim-select')?.value;
|
|
||||||
|
|
||||||
if (!siteConfigState.hero) siteConfigState.hero = {};
|
|
||||||
if (heroTitle !== undefined) siteConfigState.hero.title = heroTitle;
|
|
||||||
if (heroSubtitle !== undefined) siteConfigState.hero.subtitle = heroSubtitle;
|
|
||||||
if (heroBadge !== undefined) siteConfigState.hero.badge = heroBadge;
|
|
||||||
siteConfigState.hero.animation = heroAnim || undefined;
|
|
||||||
|
|
||||||
// Collect Bento Section Inputs & Animation
|
|
||||||
const bentoTitle = document.getElementById('bento-title-input')?.value;
|
|
||||||
const bentoSubtitle = document.getElementById('bento-subtitle-input')?.value;
|
|
||||||
const bentoAnim = document.getElementById('bento-anim-select')?.value;
|
|
||||||
|
|
||||||
if (!siteConfigState.bento) siteConfigState.bento = {};
|
|
||||||
if (bentoTitle !== undefined) siteConfigState.bento.sectionTitle = bentoTitle;
|
|
||||||
if (bentoSubtitle !== undefined) siteConfigState.bento.sectionSubtitle = bentoSubtitle;
|
|
||||||
siteConfigState.bento.animation = bentoAnim || undefined;
|
|
||||||
|
|
||||||
// Collect Header Inputs
|
|
||||||
const brandName = document.getElementById('header-brand-name')?.value;
|
|
||||||
const brandHighlight = document.getElementById('header-brand-highlight')?.value;
|
|
||||||
const ctaText = document.getElementById('header-cta-text')?.value;
|
|
||||||
|
|
||||||
if (!siteConfigState.header) siteConfigState.header = {};
|
|
||||||
if (brandName !== undefined) siteConfigState.header.brandName = brandName;
|
|
||||||
if (brandHighlight !== undefined) siteConfigState.header.brandHighlight = brandHighlight;
|
|
||||||
if (ctaText !== undefined) siteConfigState.header.ctaText = ctaText;
|
|
||||||
|
|
||||||
// Collect Footer Inputs
|
|
||||||
const footerDesc = document.getElementById('footer-description')?.value;
|
|
||||||
const footerCopy = document.getElementById('footer-copyright')?.value;
|
|
||||||
|
|
||||||
if (!siteConfigState.footer) siteConfigState.footer = {};
|
|
||||||
if (footerDesc !== undefined) siteConfigState.footer.brandDescription = footerDesc;
|
|
||||||
if (footerCopy !== undefined) siteConfigState.footer.copyrightText = footerCopy;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/update-config', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(siteConfigState)
|
|
||||||
});
|
|
||||||
await fetch('/api/config', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(themeConfigState)
|
|
||||||
});
|
|
||||||
|
|
||||||
alertBox.classList.remove('hidden');
|
|
||||||
alertBox.textContent = 'Speichern erfolgreich! Live-Preview aktualisiert.';
|
|
||||||
reloadIframe();
|
|
||||||
setTimeout(() => alertBox.classList.add('hidden'), 3000);
|
|
||||||
} catch (err) {
|
|
||||||
alert('Fehler beim Speichern.');
|
|
||||||
} finally {
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
saveBtn.textContent = 'Live Speichern';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Theme Builder Display Conditions Modal Management ---
|
|
||||||
let themeConditionsState = [
|
|
||||||
{ type: 'include', scope: 'entire_site' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const conditionsModal = document.getElementById('conditions-modal');
|
|
||||||
const conditionsBtn = document.getElementById('theme-conditions-btn');
|
|
||||||
const closeConditionsBtn = document.getElementById('close-conditions-modal');
|
|
||||||
const cancelConditionsBtn = document.getElementById('cancel-conditions-btn');
|
|
||||||
const addConditionBtn = document.getElementById('add-condition-btn');
|
|
||||||
const saveConditionsBtn = document.getElementById('save-conditions-btn');
|
|
||||||
|
|
||||||
function openConditionsModal() {
|
|
||||||
renderConditionsList();
|
|
||||||
conditionsModal?.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeConditionsModal() {
|
|
||||||
conditionsModal?.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderConditionsList() {
|
|
||||||
const listContainer = document.getElementById('conditions-list');
|
|
||||||
if (!listContainer) return;
|
|
||||||
|
|
||||||
listContainer.innerHTML = themeConditionsState.map((cond, idx) => (
|
|
||||||
'<div class="flex gap-2 items-center bg-slate-950 p-2.5 rounded-xl border border-white/5 text-xs">' +
|
|
||||||
'<select onchange="updateCondition(' + idx + ', \'type\', this.value)" class="bg-slate-900 text-xs text-white p-2 rounded-lg border border-white/10 focus:border-sky-400">' +
|
|
||||||
'<option value="include"' + (cond.type === 'include' ? ' selected' : '') + '>INCLUDE</option>' +
|
|
||||||
'<option value="exclude"' + (cond.type === 'exclude' ? ' selected' : '') + '>EXCLUDE</option>' +
|
|
||||||
'</select>' +
|
|
||||||
'<select onchange="updateCondition(' + idx + ', \'scope\', this.value)" class="flex-1 bg-slate-900 text-xs text-white p-2 rounded-lg border border-white/10 focus:border-sky-400">' +
|
|
||||||
'<option value="entire_site"' + (cond.scope === 'entire_site' ? ' selected' : '') + '>Entire Site (Ganze Website)</option>' +
|
|
||||||
'<option value="singular"' + (cond.scope === 'singular' ? ' selected' : '') + '>Singular (Einzelseiten)</option>' +
|
|
||||||
'<option value="page_404"' + (cond.scope === 'page_404' ? ' selected' : '') + '>404 Error Page</option>' +
|
|
||||||
'</select>' +
|
|
||||||
'<button type="button" onclick="removeCondition(' + idx + ')" class="text-red-400 hover:text-red-300 font-bold px-2 py-1">✕</button>' +
|
|
||||||
'</div>'
|
|
||||||
)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.updateCondition = function(idx, field, value) {
|
|
||||||
if (themeConditionsState[idx]) {
|
|
||||||
themeConditionsState[idx][field] = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.removeCondition = function(idx) {
|
|
||||||
themeConditionsState.splice(idx, 1);
|
|
||||||
renderConditionsList();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (addConditionBtn) {
|
|
||||||
addConditionBtn.addEventListener('click', () => {
|
|
||||||
themeConditionsState.push({ type: 'include', scope: 'entire_site' });
|
|
||||||
renderConditionsList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
conditionsBtn?.addEventListener('click', openConditionsModal);
|
|
||||||
closeConditionsBtn?.addEventListener('click', closeConditionsModal);
|
|
||||||
cancelConditionsBtn?.addEventListener('click', closeConditionsModal);
|
|
||||||
|
|
||||||
if (saveConditionsBtn) {
|
|
||||||
saveConditionsBtn.addEventListener('click', () => {
|
|
||||||
closeConditionsModal();
|
|
||||||
if (alertBox) {
|
|
||||||
alertBox.classList.remove('hidden');
|
|
||||||
alertBox.textContent = 'Display Conditions erfolgreich gespeichert!';
|
|
||||||
setTimeout(() => alertBox.classList.add('hidden'), 3000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
141
src/pages/admin/pages.astro
Normal file
141
src/pages/admin/pages.astro
Normal 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>
|
||||||
53
src/pages/api/admin/pages/create.ts
Normal file
53
src/pages/api/admin/pages/create.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
34
src/pages/api/admin/pages/set-homepage.ts
Normal file
34
src/pages/api/admin/pages/set-homepage.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
82
src/pages/api/editor/add-section.ts
Normal file
82
src/pages/api/editor/add-section.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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');
|
||||||
|
|
||||||
|
// Vordefinierte Standard-Templates für neue Sektionen
|
||||||
|
const SECTION_TEMPLATES: Record<string, any> = {
|
||||||
|
HeroSection: {
|
||||||
|
type: 'HeroSection',
|
||||||
|
settings: {
|
||||||
|
title: 'Neue Überschrift',
|
||||||
|
subtitle: 'Beschreibungstext hier eingeben.',
|
||||||
|
cta_text: 'Jetzt anfragen',
|
||||||
|
cta_url: '#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' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ContactSection: {
|
||||||
|
type: 'ContactSection',
|
||||||
|
settings: {
|
||||||
|
title: 'Kontaktieren Sie uns',
|
||||||
|
email_recipient: 'info@kunden-domain.de'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const { page_id, section_type } = await request.json();
|
||||||
|
|
||||||
|
if (!page_id || !section_type || !SECTION_TEMPLATES[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');
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Fehler beim Hinzufügen der Sektion' }), { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
44
src/pages/api/editor/delete-section.ts
Normal file
44
src/pages/api/editor/delete-section.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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, 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 draftPath = path.join(DATA_DIR, 'site.config.draft.json');
|
||||||
|
const livePath = path.join(DATA_DIR, 'site.config.json');
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true, message: 'Sektion gelöscht' }), { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Fehler beim Löschen der Sektion' }), { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
28
src/pages/api/editor/publish.ts
Normal file
28
src/pages/api/editor/publish.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
25
src/pages/api/editor/save-draft.ts
Normal file
25
src/pages/api/editor/save-draft.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
49
src/pages/api/editor/update-section.ts
Normal file
49
src/pages/api/editor/update-section.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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, 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 draftPath = path.join(DATA_DIR, 'site.config.draft.json');
|
||||||
|
const livePath = path.join(DATA_DIR, 'site.config.json');
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, message: 'Sektionen im Entwurf aktualisiert' }),
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Fehler beim Aktualisieren der Sektionen' }), { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
39
src/pages/api/editor/update.ts
Normal file
39
src/pages/api/editor/update.ts
Normal 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' } });
|
||||||
|
};
|
||||||
@@ -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 dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
|
||||||
const device = detectDeviceType(userAgent);
|
const device = detectDeviceType(userAgent);
|
||||||
|
|
||||||
|
const draftSitePath = path.join(dataDir, 'site.config.draft.json');
|
||||||
const deviceSitePath = path.join(dataDir, `site.${device}.config.json`);
|
const deviceSitePath = path.join(dataDir, `site.${device}.config.json`);
|
||||||
const genericSitePath = path.join(dataDir, 'site.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 deviceThemePath = path.join(dataDir, `theme.${device}.config.json`);
|
||||||
const genericThemePath = path.join(dataDir, 'theme.config.json');
|
const genericThemePath = path.join(dataDir, 'theme.config.json');
|
||||||
@@ -239,3 +247,8 @@ export function loadWaasConfigs(userAgent: string = '') {
|
|||||||
|
|
||||||
return { siteConfig, themeConfig, smtpConfig };
|
return { siteConfig, themeConfig, smtpConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSiteConfig(isDraft: boolean = false) {
|
||||||
|
return loadWaasConfigs('', isDraft).siteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
48
src/utils/editorController.ts
Normal file
48
src/utils/editorController.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Editor controller for drag‑and‑drop 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 {};
|
||||||
|
}
|
||||||
38
tests/editorSections.test.ts
Normal file
38
tests/editorSections.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user