Fix issues and add linting

This commit is contained in:
khalid@traclabs.com
2026-04-22 22:44:03 -05:00
parent 498d873c47
commit bcd047bc54
21 changed files with 10634 additions and 134 deletions

View File

@@ -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: &ldquo;Update the hero headline to say Grand Opening This Weekend&rdquo;
or &ldquo;Hide the promo banner&rdquo; or &ldquo;Add a new event on May 15th&rdquo;.
</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>

View File

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