First cut

This commit is contained in:
kadil
2026-04-17 16:08:31 -05:00
parent d10105ac00
commit 4ee4cb8e7c
58 changed files with 3243 additions and 1 deletions

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback } from 'react';
interface ManifestEntry {
id: string;
type: string;
title?: string;
headline?: string;
heading?: string;
repo_relative_path: string;
visible: boolean;
}
interface Props {
orchestratorUrl: string;
apiSecret: string;
}
type EditorView = 'sections' | 'edit' | 'create' | 'nl-edit';
export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
const [view, setView] = useState<EditorView>('sections');
const [sections, setSections] = useState<ManifestEntry[]>([]);
const [selectedSection, setSelectedSection] = useState<ManifestEntry | null>(null);
const [sectionJson, setSectionJson] = useState<string>('');
const [nlMessage, setNlMessage] = useState('');
const [status, setStatus] = useState<string>('');
const [loading, setLoading] = useState(false);
const [proposalId, setProposalId] = useState<string | null>(null);
const [proposalSummary, setProposalSummary] = useState<string>('');
const headers = {
'Authorization': `Bearer ${apiSecret}`,
'Content-Type': 'application/json',
};
const fetchManifest = useCallback(async () => {
try {
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
const data = await res.json();
setSections(data.sections || []);
} catch (err) {
setStatus('Failed to load sections');
}
}, [orchestratorUrl, apiSecret]);
useEffect(() => { fetchManifest(); }, [fetchManifest]);
const loadSection = async (entry: ManifestEntry) => {
setSelectedSection(entry);
setView('edit');
try {
const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers });
const data = await res.json();
setSectionJson(JSON.stringify(data, null, 2));
} catch {
setStatus('Failed to load section');
}
};
const saveSection = async () => {
if (!selectedSection) return;
setLoading(true);
setStatus('Saving...');
try {
const parsed = JSON.parse(sectionJson);
const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST',
headers,
body: JSON.stringify({
message: `Direct JSON update to ${selectedSection.repo_relative_path}`,
repo_relative_path: selectedSection.repo_relative_path,
}),
});
const data = await res.json();
setStatus(data.status === 'processing' ? 'Edit submitted — processing...' : `Status: ${data.status}`);
} catch (err) {
setStatus('Save failed');
}
setLoading(false);
};
const submitNlEdit = async () => {
if (!nlMessage.trim()) return;
setLoading(true);
setStatus('Sending to AI...');
setProposalId(null);
setProposalSummary('');
try {
const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST',
headers,
body: JSON.stringify({ message: nlMessage }),
});
const data = await res.json();
if (data.job_id) {
setStatus('Processing... checking for proposal.');
// Poll for proposal
pollForProposal(data.job_id);
} else {
setStatus(`Response: ${JSON.stringify(data)}`);
}
} catch {
setStatus('Request failed');
}
setLoading(false);
};
const pollForProposal = async (jobId: string) => {
// Simple poll: check recent proposals. In production, use SSE or websockets.
let attempts = 0;
const interval = setInterval(async () => {
attempts++;
if (attempts > 30) {
clearInterval(interval);
setStatus('Timed out waiting for proposal. The AI may still be processing.');
return;
}
try {
// Re-fetch manifest to see if anything changed, or check proposal endpoint
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
const data = await res.json();
setSections(data.sections || []);
} catch { /* ignore */ }
}, 2000);
};
const confirmProposal = async (confirm: 'yes' | 'no') => {
if (!proposalId) return;
setLoading(true);
try {
const res = await fetch(`${orchestratorUrl}/api/edit`, {
method: 'POST',
headers,
body: JSON.stringify({ message: '', confirm, proposal_id: proposalId }),
});
const data = await res.json();
setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.');
setProposalId(null);
setProposalSummary('');
if (confirm === 'yes') {
setTimeout(() => { fetchManifest(); }, 1000);
}
} catch {
setStatus('Confirm failed');
}
setLoading(false);
};
const sectionLabel = (s: ManifestEntry) =>
s.headline || s.title || s.heading || s.id;
// ── Styles ──
const styles = {
wrapper: { fontFamily: "'Source Sans 3', system-ui, sans-serif" } as React.CSSProperties,
nav: { display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' as const },
navBtn: (active: boolean) => ({
padding: '0.5rem 1rem', border: '1px solid #e5e0d8', borderRadius: '4px', cursor: 'pointer',
background: active ? '#2d5016' : 'white', color: active ? 'white' : '#2c2c2c',
fontSize: '0.85rem', fontWeight: 500 as const, fontFamily: 'inherit',
}),
card: {
padding: '1rem', border: '1px solid #e5e0d8', borderRadius: '6px', marginBottom: '0.75rem',
display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'white', cursor: 'pointer',
} as React.CSSProperties,
badge: (visible: boolean) => ({
fontSize: '0.7rem', padding: '0.2rem 0.5rem', borderRadius: '3px',
background: visible ? '#e8f5e1' : '#fce8e8', color: visible ? '#2d5016' : '#a33',
fontWeight: 600 as const,
}),
textarea: {
width: '100%', minHeight: '300px', fontFamily: "'Source Code Pro', monospace", fontSize: '0.85rem',
padding: '1rem', border: '1px solid #e5e0d8', borderRadius: '6px', resize: 'vertical' as const,
},
input: {
width: '100%', padding: '0.6rem 0.8rem', border: '1px solid #e5e0d8', borderRadius: '4px',
fontSize: '0.95rem', fontFamily: 'inherit', marginBottom: '0.75rem',
},
btn: {
padding: '0.5rem 1.25rem', background: '#2d5016', color: 'white', border: 'none',
borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit',
},
btnDanger: {
padding: '0.5rem 1.25rem', background: '#a33', color: 'white', border: 'none',
borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit',
},
btnOutline: {
padding: '0.5rem 1.25rem', background: 'white', color: '#2d5016', border: '1px solid #2d5016',
borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500 as const, fontFamily: 'inherit',
},
status: { marginTop: '1rem', fontSize: '0.85rem', color: '#6b6b6b', fontStyle: 'italic' as const },
heading: { fontFamily: "'DM Serif Display', Georgia, serif", fontSize: '1.4rem', color: '#1a3a0a', marginBottom: '1rem' },
subhead: { fontFamily: "'DM Serif Display', Georgia, serif", fontSize: '1.1rem', color: '#2d5016', marginBottom: '0.75rem' },
};
return (
<div style={styles.wrapper}>
<h2 style={styles.heading}>Content Editor</h2>
<div style={styles.nav}>
<button style={styles.navBtn(view === 'sections')} onClick={() => { setView('sections'); fetchManifest(); }}>Sections</button>
<button style={styles.navBtn(view === 'nl-edit')} onClick={() => setView('nl-edit')}>Natural Language Edit</button>
<button style={styles.navBtn(view === 'create')} onClick={() => setView('create')}>New Section</button>
<a href="/" style={{ ...styles.navBtn(false), textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}>View Site &rarr;</a>
</div>
{/* ── Section List ── */}
{view === 'sections' && (
<div>
<h3 style={styles.subhead}>All Sections</h3>
{sections.length === 0 && <p>Loading sections...</p>}
{sections.map(s => (
<div key={s.repo_relative_path} style={styles.card} onClick={() => loadSection(s)}>
<div>
<strong>{sectionLabel(s)}</strong>
<span style={{ fontSize: '0.8rem', color: '#6b6b6b', marginLeft: '0.5rem' }}>({s.type})</span>
<div style={{ fontSize: '0.75rem', color: '#999', marginTop: '0.2rem' }}>{s.repo_relative_path}</div>
</div>
<span style={styles.badge(s.visible)}>{s.visible ? 'Visible' : 'Hidden'}</span>
</div>
))}
</div>
)}
{/* ── JSON Editor ── */}
{view === 'edit' && selectedSection && (
<div>
<h3 style={styles.subhead}>Editing: {sectionLabel(selectedSection)}</h3>
<p style={{ fontSize: '0.8rem', color: '#999', marginBottom: '0.75rem' }}>{selectedSection.repo_relative_path}</p>
<textarea
style={styles.textarea}
value={sectionJson}
onChange={e => setSectionJson(e.target.value)}
/>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
<button style={styles.btn} onClick={saveSection} disabled={loading}>Save</button>
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); }}>Back</button>
</div>
</div>
)}
{/* ── Natural Language Edit ── */}
{view === 'nl-edit' && (
<div>
<h3 style={styles.subhead}>Describe Your Edit</h3>
<p style={{ fontSize: '0.9rem', color: '#6b6b6b', marginBottom: '1rem' }}>
Tell the AI what you want to change. For example: "Update the hero headline to say Grand Opening This Weekend"
or "Hide the promo banner" or "Add a new event on May 15th".
</p>
<input
style={styles.input}
placeholder="Describe what you want to change..."
value={nlMessage}
onChange={e => setNlMessage(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submitNlEdit()}
/>
<button style={styles.btn} onClick={submitNlEdit} disabled={loading || !nlMessage.trim()}>
{loading ? 'Processing...' : 'Submit Edit'}
</button>
{proposalId && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#fefce8', border: '1px solid #eab308', borderRadius: '6px' }}>
<strong>Proposed change:</strong>
<p style={{ margin: '0.5rem 0' }}>{proposalSummary}</p>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button style={styles.btn} onClick={() => confirmProposal('yes')}>Yes, Apply</button>
<button style={styles.btnDanger} onClick={() => confirmProposal('no')}>No, Cancel</button>
</div>
</div>
)}
</div>
)}
{/* ── Create Section ── */}
{view === 'create' && <CreateSection orchestratorUrl={orchestratorUrl} headers={headers} onCreated={() => { setView('sections'); fetchManifest(); }} />}
{status && <p style={styles.status}>{status}</p>}
</div>
);
}
// ── Create Section Sub-component ──
function CreateSection({ orchestratorUrl, headers, onCreated }: {
orchestratorUrl: string;
headers: Record<string, string>;
onCreated: () => void;
}) {
const [sectionType, setSectionType] = useState('text');
const [sectionId, setSectionId] = useState('');
const [formStatus, setFormStatus] = useState('');
const defaults: Record<string, unknown> = {
text: { type: 'text', id: '', heading: 'New Section', content: 'Content goes here.', order: 10, visible: true },
hero: { type: 'hero', id: '', headline: 'Headline', subheading: 'Subheading', ctaText: 'Learn More', ctaLink: '/', order: 0, visible: true },
about: { type: 'about', id: '', title: 'About', content: 'About content.', order: 10, visible: true },
features: { type: 'features', id: '', title: 'Features', items: [{ title: 'Feature 1', description: 'Description' }], order: 10, visible: true },
testimonials: { type: 'testimonials', id: '', title: 'Testimonials', items: [{ quote: 'Great service!', author: 'Customer', role: 'Client' }], order: 10, visible: true },
};
const handleCreate = async () => {
if (!sectionId.trim()) { setFormStatus('Please enter a section ID'); return; }
const slug = sectionId.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const data = { ...defaults[sectionType] as Record<string, unknown>, id: slug };
try {
const res = await fetch(`${orchestratorUrl}/api/edit/create-section`, {
method: 'POST',
headers,
body: JSON.stringify({ filename: slug, data }),
});
const result = await res.json();
if (res.ok) {
setFormStatus('Created!');
setTimeout(onCreated, 500);
} else {
setFormStatus(result.error || 'Failed');
}
} catch {
setFormStatus('Request failed');
}
};
const inputStyle = {
width: '100%', padding: '0.6rem 0.8rem', border: '1px solid #e5e0d8', borderRadius: '4px',
fontSize: '0.95rem', fontFamily: 'inherit', marginBottom: '0.75rem',
};
const selectStyle = { ...inputStyle, background: 'white' };
return (
<div>
<h3 style={{ fontFamily: "'DM Serif Display', Georgia, serif", fontSize: '1.1rem', color: '#2d5016', marginBottom: '0.75rem' }}>Create New Section</h3>
<select style={selectStyle} value={sectionType} onChange={e => setSectionType(e.target.value)}>
<option value="text">Text / Banner</option>
<option value="hero">Hero</option>
<option value="about">About</option>
<option value="features">Features</option>
<option value="testimonials">Testimonials</option>
</select>
<input style={inputStyle} placeholder="Section ID (e.g. spring-promo)" value={sectionId} onChange={e => setSectionId(e.target.value)} />
<button style={{ padding: '0.5rem 1.25rem', background: '#2d5016', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '0.9rem', fontWeight: 500, fontFamily: 'inherit' }} onClick={handleCreate}>Create Section</button>
{formStatus && <p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: '#6b6b6b' }}>{formStatus}</p>}
</div>
);
}

View File

@@ -0,0 +1,29 @@
---
interface Props {
title: string;
content: string;
}
const { title, content } = Astro.props;
---
<section class="about">
<div class="container">
<h2>{title}</h2>
<p>{content}</p>
</div>
</section>
<style>
.about h2 {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--color-primary-dark);
margin-bottom: 1rem;
}
.about p {
max-width: 680px;
color: var(--color-text);
font-size: 1.05rem;
line-height: 1.85;
font-weight: 300;
}
</style>

View File

@@ -0,0 +1,112 @@
---
interface EventItem {
id: string;
title: string;
description?: string;
date: string;
time?: string;
location?: string;
}
interface Props {
events: EventItem[];
}
const { events } = Astro.props;
function formatDate(dateStr: string): string {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
}
---
{events.length > 0 && (
<section class="events">
<div class="container">
<h2>Upcoming Events</h2>
<div class="events-list">
{events.map((event) => (
<div class="event-card">
<div class="event-date">
<span class="event-day">{new Date(event.date + 'T00:00:00').getDate()}</span>
<span class="event-month">{new Date(event.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short' })}</span>
</div>
<div class="event-info">
<h3>{event.title}</h3>
{event.description && <p>{event.description}</p>}
<div class="event-meta">
{event.time && <span>{event.time}</span>}
{event.location && <span>{event.location}</span>}
</div>
</div>
</div>
))}
</div>
</div>
</section>
)}
<style>
.events h2 {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--color-primary-dark);
margin-bottom: 2rem;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-card {
display: flex;
gap: 1.25rem;
padding: 1.25rem;
background: white;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.event-date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 56px;
padding: 0.5rem;
background: var(--color-primary-light);
border-radius: 4px;
}
.event-day {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-primary-dark);
line-height: 1;
}
.event-month {
font-size: 0.75rem;
color: var(--color-primary);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
}
.event-info h3 {
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-text);
margin-bottom: 0.35rem;
}
.event-info p {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.6;
font-weight: 300;
margin-bottom: 0.5rem;
}
.event-meta {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: var(--color-primary);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,57 @@
---
interface FeatureItem {
title: string;
description: string;
icon?: string;
}
interface Props {
title: string;
items: FeatureItem[];
}
const { title, items } = Astro.props;
---
<section class="features">
<div class="container">
<h2>{title}</h2>
<div class="features-grid">
{items.map((item) => (
<div class="feature-card">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
))}
</div>
</div>
</section>
<style>
.features h2 {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--color-primary-dark);
margin-bottom: 2rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
}
.feature-card {
padding: 1.5rem;
background: white;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.feature-card h3 {
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.feature-card p {
font-size: 0.92rem;
color: var(--color-text-muted);
line-height: 1.65;
font-weight: 300;
}
</style>

View File

@@ -0,0 +1,61 @@
---
interface Props {
headline: string;
subheading?: string;
ctaText?: string;
ctaLink?: string;
primaryColor?: string;
}
const { headline, subheading, ctaText, ctaLink } = Astro.props;
---
<section class="hero">
<div class="container">
<h1>{headline}</h1>
{subheading && <p class="hero-sub">{subheading}</p>}
{ctaText && ctaLink && (
<a href={ctaLink} class="hero-cta">{ctaText}</a>
)}
</div>
</section>
<style>
.hero {
padding: 5rem 0 4rem;
text-align: center;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary), white 92%) 0%,
var(--color-bg) 100%
);
border-bottom: none;
}
.hero h1 {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 3.2rem);
color: var(--color-primary-dark);
line-height: 1.15;
margin-bottom: 1rem;
}
.hero-sub {
font-size: 1.15rem;
color: var(--color-text-muted);
max-width: 560px;
margin: 0 auto 2rem;
font-weight: 300;
line-height: 1.8;
}
.hero-cta {
display: inline-block;
padding: 0.75rem 2rem;
background: var(--color-primary);
color: white;
text-decoration: none;
border-radius: 4px;
font-weight: 500;
font-size: 0.95rem;
transition: background 0.2s;
}
.hero-cta:hover {
background: var(--color-primary-dark);
}
</style>

View File

@@ -0,0 +1,65 @@
---
interface TestimonialItem {
quote: string;
author: string;
role?: string;
}
interface Props {
title: string;
items: TestimonialItem[];
}
const { title, items } = Astro.props;
---
<section class="testimonials">
<div class="container">
<h2>{title}</h2>
<div class="testimonials-grid">
{items.map((item) => (
<blockquote class="testimonial-card">
<p>"{item.quote}"</p>
<footer>
<strong>{item.author}</strong>
{item.role && <span>{item.role}</span>}
</footer>
</blockquote>
))}
</div>
</div>
</section>
<style>
.testimonials h2 {
font-family: var(--font-display);
font-size: 1.8rem;
color: var(--color-primary-dark);
margin-bottom: 2rem;
}
.testimonials-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem;
}
.testimonial-card {
padding: 1.5rem;
background: white;
border-left: 3px solid var(--color-primary);
border-radius: 0 6px 6px 0;
}
.testimonial-card p {
font-size: 0.95rem;
font-style: italic;
color: var(--color-text);
line-height: 1.7;
margin-bottom: 1rem;
font-weight: 300;
}
.testimonial-card footer strong {
display: block;
font-size: 0.9rem;
color: var(--color-primary-dark);
}
.testimonial-card footer span {
font-size: 0.8rem;
color: var(--color-text-muted);
}
</style>

View File

@@ -0,0 +1,41 @@
---
interface Props {
heading?: string;
content: string;
id: string;
}
const { heading, content, id } = Astro.props;
const isPromo = id.includes('promo') || id.includes('banner');
---
<section class:list={["text-section", { promo: isPromo }]}>
<div class="container">
{heading && <h2>{heading}</h2>}
<p>{content}</p>
</div>
</section>
<style>
.text-section h2 {
font-family: var(--font-display);
font-size: 1.5rem;
color: var(--color-primary-dark);
margin-bottom: 0.75rem;
}
.text-section p {
font-size: 1rem;
color: var(--color-text);
line-height: 1.8;
font-weight: 300;
max-width: 680px;
}
.promo {
background: color-mix(in srgb, var(--color-primary), white 90%);
border-left: 4px solid var(--color-primary);
border-radius: 0 6px 6px 0;
margin: 0 auto;
}
.promo .container {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,103 @@
---
interface Props {
title: string;
primaryColor?: string;
}
const { title, primaryColor = '#2d5016' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Source+Sans+3:wght@300;400;500;600&display=swap" rel="stylesheet" />
<style define:vars={{ primaryColor }}>
:root {
--color-primary: var(--primaryColor);
--color-primary-dark: color-mix(in srgb, var(--primaryColor), black 20%);
--color-primary-light: color-mix(in srgb, var(--primaryColor), white 85%);
--color-bg: #faf8f5;
--color-text: #2c2c2c;
--color-text-muted: #6b6b6b;
--color-border: #e5e0d8;
--font-display: 'DM Serif Display', Georgia, serif;
--font-body: 'Source Sans 3', system-ui, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-bg);
line-height: 1.7;
font-weight: 400;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 1.5rem;
}
.site-header {
padding: 1.25rem 0;
border-bottom: 1px solid var(--color-border);
background: white;
}
.site-header .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.site-logo {
font-family: var(--font-display);
font-size: 1.35rem;
color: var(--color-primary);
text-decoration: none;
}
.site-tagline {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 300;
letter-spacing: 0.03em;
}
.site-footer {
margin-top: 4rem;
padding: 2rem 0;
border-top: 1px solid var(--color-border);
text-align: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
section {
padding: 3.5rem 0;
}
section + section {
border-top: 1px solid var(--color-border);
}
</style>
</head>
<body>
<header class="site-header">
<div class="container">
<a href="/" class="site-logo"><slot name="logo">Dynamic Site</slot></a>
<span class="site-tagline"><slot name="tagline" /></span>
</div>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="container">
<slot name="footer">© {new Date().getFullYear()}</slot>
</div>
</footer>
</body>
</html>

35
src/lib/site-bundle.ts Normal file
View File

@@ -0,0 +1,35 @@
import {
siteContextSchema,
eventsFileSchema,
sectionFileSchema,
type SiteContext,
type SectionFile,
type EventsFile,
} from '@dynamic-sites/shared';
export interface SiteBundle {
siteContext: SiteContext;
sections: SectionFile[];
events: EventsFile;
}
export function parseSiteBundle(
siteContextRaw: unknown,
eventsRaw: unknown,
sectionRaws: unknown[]
): SiteBundle {
const siteContext = siteContextSchema.parse(siteContextRaw);
const events = eventsFileSchema.parse(eventsRaw);
const sections: SectionFile[] = [];
for (const raw of sectionRaws) {
const result = sectionFileSchema.safeParse(raw);
if (result.success && result.data.visible) {
sections.push(result.data);
}
}
// Sort by order, then id as tiebreaker
sections.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
return { siteContext, sections, events };
}

39
src/lib/site-data.ts Normal file
View File

@@ -0,0 +1,39 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseSiteBundle, type SiteBundle } from './site-bundle.ts';
const REPO_ROOT = process.env.REPO_ROOT || '.';
const TTL = parseInt(process.env.SITE_DATA_TTL_MS || '2000', 10);
let cached: { data: SiteBundle; loadedAt: number } | null = null;
export function loadSiteData(): SiteBundle {
const now = Date.now();
if (cached && now - cached.loadedAt < TTL) {
return cached.data;
}
const siteContextRaw = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'site-context.json'), 'utf-8')
);
const eventsRaw = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'content/events.json'), 'utf-8')
);
const sectionsDir = path.join(REPO_ROOT, 'content/sections');
const sectionRaws: unknown[] = [];
if (fs.existsSync(sectionsDir)) {
for (const file of fs.readdirSync(sectionsDir).filter(f => f.endsWith('.json'))) {
try {
sectionRaws.push(JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8')));
} catch {
// skip invalid files
}
}
}
const data = parseSiteBundle(siteContextRaw, eventsRaw, sectionRaws);
cached = { data, loadedAt: now };
return data;
}

134
src/pages/editor.astro Normal file
View File

@@ -0,0 +1,134 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { loadSiteData } from '../lib/site-data.ts';
const { siteContext } = loadSiteData();
const secret = import.meta.env.EDITOR_SESSION_SECRET || process.env.EDITOR_SESSION_SECRET || 'dev-secret';
const sessionCookie = Astro.cookies.get('editor_session')?.value;
let isAuthed = sessionCookie === secret;
if (!isAuthed && Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const password = formData.get('password') as string;
const editSecret = import.meta.env.API_EDIT_SECRET || process.env.API_EDIT_SECRET || '';
if (password === editSecret) {
Astro.cookies.set('editor_session', secret, {
httpOnly: true,
sameSite: 'strict',
maxAge: 8 * 60 * 60,
path: '/',
});
isAuthed = true;
}
}
const orchestratorUrl = import.meta.env.PUBLIC_ORCHESTRATOR_URL || process.env.PUBLIC_ORCHESTRATOR_URL || 'http://localhost:3001';
const apiSecret = import.meta.env.API_EDIT_SECRET || process.env.API_EDIT_SECRET || '';
---
<BaseLayout title={`Editor — ${siteContext.businessName}`} primaryColor={siteContext.primaryColor}>
<Fragment slot="logo">{siteContext.businessName}</Fragment>
<Fragment slot="tagline">Content Editor</Fragment>
{!isAuthed ? (
<section class="login-section">
<div class="container">
<div class="login-box">
<h2>Editor Login</h2>
<p>Enter the site edit password to continue.</p>
<form method="POST">
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Log In</button>
</form>
</div>
</div>
</section>
) : (
<section class="editor-section">
<div class="container">
<div id="editor-root"
data-orchestrator-url={orchestratorUrl}
data-api-secret={apiSecret}
></div>
</div>
</section>
)}
<Fragment slot="footer">
&copy; {new Date().getFullYear()} {siteContext.businessName} &middot; Editor
</Fragment>
</BaseLayout>
<style>
.login-section {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
}
.login-box {
max-width: 360px;
padding: 2rem;
background: white;
border: 1px solid var(--color-border);
border-radius: 8px;
text-align: center;
}
.login-box h2 {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--color-primary-dark);
margin-bottom: 0.5rem;
}
.login-box p {
font-size: 0.9rem;
color: var(--color-text-muted);
margin-bottom: 1.5rem;
}
.login-box input {
display: block;
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 4px;
font-size: 0.95rem;
margin-bottom: 1rem;
font-family: var(--font-body);
}
.login-box button {
width: 100%;
padding: 0.65rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 0.95rem;
cursor: pointer;
font-family: var(--font-body);
font-weight: 500;
}
.login-box button:hover {
background: var(--color-primary-dark);
}
.editor-section {
padding: 2rem 0;
}
</style>
{isAuthed && (
<script>
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { VisualEditorIsland } from '../components/editor/VisualEditorIsland';
const el = document.getElementById('editor-root');
if (el) {
const root = createRoot(el);
root.render(createElement(VisualEditorIsland, {
orchestratorUrl: el.dataset.orchestratorUrl || '',
apiSecret: el.dataset.apiSecret || '',
}));
}
</script>
)}

43
src/pages/index.astro Normal file
View File

@@ -0,0 +1,43 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import HeroSection from '../components/sections/HeroSection.astro';
import AboutSection from '../components/sections/AboutSection.astro';
import FeaturesSection from '../components/sections/FeaturesSection.astro';
import TestimonialsSection from '../components/sections/TestimonialsSection.astro';
import TextSection from '../components/sections/TextSection.astro';
import EventsList from '../components/sections/EventsList.astro';
import { loadSiteData } from '../lib/site-data.ts';
const { siteContext, sections, events } = loadSiteData();
---
<BaseLayout title={siteContext.businessName} primaryColor={siteContext.primaryColor}>
<Fragment slot="logo">{siteContext.businessName}</Fragment>
<Fragment slot="tagline">{siteContext.tagline}</Fragment>
{sections.map((section) => {
switch (section.type) {
case 'hero':
return <HeroSection
headline={section.headline}
subheading={section.subheading}
ctaText={section.ctaText}
ctaLink={section.ctaLink}
/>;
case 'about':
return <AboutSection title={section.title} content={section.content} />;
case 'features':
return <FeaturesSection title={section.title} items={section.items} />;
case 'testimonials':
return <TestimonialsSection title={section.title} items={section.items} />;
case 'text':
return <TextSection heading={section.heading} content={section.content} id={section.id} />;
}
})}
<EventsList events={events.events} />
<Fragment slot="footer">
© {new Date().getFullYear()} {siteContext.businessName}
{siteContext.contactEmail && <span> · {siteContext.contactEmail}</span>}
</Fragment>
</BaseLayout>