First cut
This commit is contained in:
343
src/components/editor/VisualEditorIsland.tsx
Normal file
343
src/components/editor/VisualEditorIsland.tsx
Normal 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 →</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>
|
||||
);
|
||||
}
|
||||
29
src/components/sections/AboutSection.astro
Normal file
29
src/components/sections/AboutSection.astro
Normal 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>
|
||||
112
src/components/sections/EventsList.astro
Normal file
112
src/components/sections/EventsList.astro
Normal 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>
|
||||
57
src/components/sections/FeaturesSection.astro
Normal file
57
src/components/sections/FeaturesSection.astro
Normal 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>
|
||||
61
src/components/sections/HeroSection.astro
Normal file
61
src/components/sections/HeroSection.astro
Normal 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>
|
||||
65
src/components/sections/TestimonialsSection.astro
Normal file
65
src/components/sections/TestimonialsSection.astro
Normal 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>
|
||||
41
src/components/sections/TextSection.astro
Normal file
41
src/components/sections/TextSection.astro
Normal 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>
|
||||
103
src/layouts/BaseLayout.astro
Normal file
103
src/layouts/BaseLayout.astro
Normal 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
35
src/lib/site-bundle.ts
Normal 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
39
src/lib/site-data.ts
Normal 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
134
src/pages/editor.astro
Normal 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">
|
||||
© {new Date().getFullYear()} {siteContext.businessName} · 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
43
src/pages/index.astro
Normal 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>
|
||||
Reference in New Issue
Block a user