Fix issues and add linting
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface ManifestEntry {
|
||||
id: string;
|
||||
@@ -28,28 +28,39 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
const [proposalId, setProposalId] = useState<string | null>(null);
|
||||
const [proposalSummary, setProposalSummary] = useState<string>('');
|
||||
|
||||
const headers = {
|
||||
// Keep a ref to the polling interval so we can clean it up
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const headers = useRef({
|
||||
'Authorization': `Bearer ${apiSecret}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
});
|
||||
|
||||
// Clean up polling interval on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchManifest = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers });
|
||||
const res = await fetch(`${orchestratorUrl}/api/manifest`, { headers: headers.current });
|
||||
const data = await res.json();
|
||||
setSections(data.sections || []);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setStatus('Failed to load sections');
|
||||
}
|
||||
}, [orchestratorUrl, apiSecret]);
|
||||
}, [orchestratorUrl]);
|
||||
|
||||
useEffect(() => { fetchManifest(); }, [fetchManifest]);
|
||||
|
||||
const loadSection = async (entry: ManifestEntry) => {
|
||||
setSelectedSection(entry);
|
||||
setView('edit');
|
||||
setStatus('');
|
||||
try {
|
||||
const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers });
|
||||
const res = await fetch(`${orchestratorUrl}/api/section?path=${encodeURIComponent(entry.repo_relative_path)}`, { headers: headers.current });
|
||||
const data = await res.json();
|
||||
setSectionJson(JSON.stringify(data, null, 2));
|
||||
} catch {
|
||||
@@ -60,21 +71,32 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
const saveSection = async () => {
|
||||
if (!selectedSection) return;
|
||||
setLoading(true);
|
||||
setStatus('Saving...');
|
||||
setStatus('Validating & saving...');
|
||||
try {
|
||||
const parsed = JSON.parse(sectionJson);
|
||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
|
||||
// Write the JSON directly via PUT /api/section
|
||||
const res = await fetch(`${orchestratorUrl}/api/section`, {
|
||||
method: 'PUT',
|
||||
headers: headers.current,
|
||||
body: JSON.stringify({
|
||||
message: `Direct JSON update to ${selectedSection.repo_relative_path}`,
|
||||
repo_relative_path: selectedSection.repo_relative_path,
|
||||
path: selectedSection.repo_relative_path,
|
||||
data: parsed,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setStatus(data.status === 'processing' ? 'Edit submitted — processing...' : `Status: ${data.status}`);
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('Saved successfully.');
|
||||
fetchManifest();
|
||||
} else {
|
||||
const details = result.details
|
||||
? result.details.map((d: { path: string[]; message: string }) => `${d.path.join('.')}: ${d.message}`).join(', ')
|
||||
: result.error;
|
||||
setStatus(`Save failed: ${details}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Save failed');
|
||||
setStatus(`Invalid JSON: ${(err as Error).message}`);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -85,16 +107,22 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
setStatus('Sending to AI...');
|
||||
setProposalId(null);
|
||||
setProposalSummary('');
|
||||
|
||||
// Clear any existing poll
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: headers.current,
|
||||
body: JSON.stringify({ message: nlMessage }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.job_id) {
|
||||
setStatus('Processing... checking for proposal.');
|
||||
// Poll for proposal
|
||||
setStatus('Processing... waiting for AI proposal.');
|
||||
pollForProposal(data.job_id);
|
||||
} else {
|
||||
setStatus(`Response: ${JSON.stringify(data)}`);
|
||||
@@ -105,22 +133,36 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const pollForProposal = async (jobId: string) => {
|
||||
// Simple poll: check recent proposals. In production, use SSE or websockets.
|
||||
const pollForProposal = (jobId: string) => {
|
||||
let attempts = 0;
|
||||
const interval = setInterval(async () => {
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
attempts++;
|
||||
if (attempts > 30) {
|
||||
clearInterval(interval);
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
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 res = await fetch(`${orchestratorUrl}/api/job/${jobId}`, { headers: headers.current });
|
||||
const data = await res.json();
|
||||
setSections(data.sections || []);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (data.status === 'pending' && data.proposal_id) {
|
||||
// Proposal is ready — show confirm UI
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
setProposalId(data.proposal_id);
|
||||
setProposalSummary(data.summary || 'Change proposed.');
|
||||
setStatus('');
|
||||
} else if (data.status === 'applied' || data.status === 'rejected' || data.status === 'expired') {
|
||||
// Already resolved
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
setStatus(`Proposal was ${data.status}.`);
|
||||
}
|
||||
// status === 'processing' → keep polling
|
||||
} catch { /* ignore network errors during polling */ }
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
@@ -130,13 +172,14 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
try {
|
||||
const res = await fetch(`${orchestratorUrl}/api/edit`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ message: '', confirm, proposal_id: proposalId }),
|
||||
headers: headers.current,
|
||||
body: JSON.stringify({ confirm, proposal_id: proposalId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
await res.json();
|
||||
setStatus(confirm === 'yes' ? 'Applied! Refreshing...' : 'Cancelled.');
|
||||
setProposalId(null);
|
||||
setProposalSummary('');
|
||||
setNlMessage('');
|
||||
if (confirm === 'yes') {
|
||||
setTimeout(() => { fetchManifest(); }, 1000);
|
||||
}
|
||||
@@ -232,8 +275,10 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
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>
|
||||
<button style={styles.btn} onClick={saveSection} disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button style={styles.btnOutline} onClick={() => { setView('sections'); setSelectedSection(null); setStatus(''); }}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -243,15 +288,15 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
<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".
|
||||
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()}
|
||||
onKeyDown={e => e.key === 'Enter' && !loading && submitNlEdit()}
|
||||
/>
|
||||
<button style={styles.btn} onClick={submitNlEdit} disabled={loading || !nlMessage.trim()}>
|
||||
{loading ? 'Processing...' : 'Submit Edit'}
|
||||
@@ -262,8 +307,8 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
<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>
|
||||
<button style={styles.btn} onClick={() => confirmProposal('yes')} disabled={loading}>Yes, Apply</button>
|
||||
<button style={styles.btnDanger} onClick={() => confirmProposal('no')} disabled={loading}>No, Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -271,7 +316,7 @@ export function VisualEditorIsland({ orchestratorUrl, apiSecret }: Props) {
|
||||
)}
|
||||
|
||||
{/* ── Create Section ── */}
|
||||
{view === 'create' && <CreateSection orchestratorUrl={orchestratorUrl} headers={headers} onCreated={() => { setView('sections'); fetchManifest(); }} />}
|
||||
{view === 'create' && <CreateSection orchestratorUrl={orchestratorUrl} headers={headers.current} onCreated={() => { setView('sections'); fetchManifest(); }} />}
|
||||
|
||||
{status && <p style={styles.status}>{status}</p>}
|
||||
</div>
|
||||
|
||||
@@ -3,37 +3,63 @@ 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);
|
||||
const TTL = parseInt(process.env.SITE_DATA_TTL_MS || '500', 10);
|
||||
|
||||
let cached: { data: SiteBundle; loadedAt: number } | null = null;
|
||||
|
||||
/** Fallback bundle used when content files are missing or unreadable. */
|
||||
function fallbackBundle(): SiteBundle {
|
||||
return {
|
||||
siteContext: {
|
||||
businessName: 'Site',
|
||||
tone: 'professional and friendly',
|
||||
},
|
||||
sections: [],
|
||||
events: { events: [] },
|
||||
};
|
||||
}
|
||||
|
||||
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')
|
||||
);
|
||||
try {
|
||||
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'))) {
|
||||
let eventsRaw: unknown = { events: [] };
|
||||
const eventsPath = path.join(REPO_ROOT, 'content/events.json');
|
||||
if (fs.existsSync(eventsPath)) {
|
||||
try {
|
||||
sectionRaws.push(JSON.parse(fs.readFileSync(path.join(sectionsDir, file), 'utf-8')));
|
||||
eventsRaw = JSON.parse(fs.readFileSync(eventsPath, 'utf-8'));
|
||||
} catch {
|
||||
// skip invalid files
|
||||
// Use empty events if file is corrupt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = parseSiteBundle(siteContextRaw, eventsRaw, sectionRaws);
|
||||
cached = { data, loadedAt: now };
|
||||
return data;
|
||||
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;
|
||||
} catch {
|
||||
// If site-context.json is missing or corrupt, return a minimal fallback
|
||||
// so the SSR server doesn't crash on every request.
|
||||
const fb = fallbackBundle();
|
||||
cached = { data: fb, loadedAt: now };
|
||||
return fb;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user