Ryan Malloy 69adfc5683 Open embed route to all origins, add embed snippet UI, enable LTspice
frame-ancestors * for /embed/* routes so any site can iframe notebooks.
Remove postMessage origin allowlist (theme toggle is cosmetic-only).
Add EmbedDialog popover with copy-paste iframe snippet and theme picker.
Enable ltspice in the engine dropdown now that the backend supports it.
2026-03-05 15:41:51 -07:00

189 lines
5.5 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import type { Notebook, CellOutput, SimulationResponse } from '../../lib/types';
import * as api from '../../lib/api';
import { EmbedCell } from './EmbedCell';
import { Loader2 } from 'lucide-react';
interface EmbedViewerProps {
notebookId: string;
initialTheme: string;
}
export default function EmbedViewer({ notebookId, initialTheme }: EmbedViewerProps) {
const [notebook, setNotebook] = useState<Notebook | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [runningCells, setRunningCells] = useState<Set<string>>(new Set());
const [theme, setTheme] = useState<'dark' | 'light'>(
initialTheme === 'dark' ? 'dark' : 'light',
);
// Sync theme class on <html> without stomping other classes
useEffect(() => {
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}, [theme]);
// Listen for postMessage theme changes from parent iframe host
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (event.data?.type === 'spicebook-theme') {
const incoming = event.data.theme;
if (incoming === 'dark' || incoming === 'light') {
setTheme(incoming);
}
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
// Keep a ref to notebook for use inside handleRun to avoid stale closures
const notebookRef = useRef(notebook);
notebookRef.current = notebook;
// Load notebook
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api.getNotebook(notebookId).then(
(nb) => {
if (!cancelled) {
setNotebook(nb);
setLoading(false);
}
},
(err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load notebook');
setLoading(false);
}
},
);
return () => { cancelled = true; };
}, [notebookId]);
// Run a SPICE cell — reads notebook via ref to avoid stale closures
const handleRun = useCallback(
async (cellId: string) => {
const nb = notebookRef.current;
if (!nb) return;
const cell = nb.cells.find((c) => c.id === cellId);
if (!cell || cell.type !== 'spice') return;
setRunningCells((prev) => new Set(prev).add(cellId));
try {
let result: SimulationResponse;
try {
result = await api.runCell(notebookId, cellId);
} catch {
result = await api.runSimulation(cell.source, nb.metadata.engine);
}
const output: CellOutput = {
output_type: result.success ? 'simulation_result' : 'error',
data: {
success: result.success,
waveform: result.waveform || null,
log: result.log,
error: result.error || null,
elapsed_seconds: result.elapsed_seconds,
},
timestamp: new Date().toISOString(),
};
setNotebook((prev) => {
if (!prev) return prev;
return {
...prev,
cells: prev.cells.map((c) => {
if (c.id !== cellId) return c;
const preserved = c.outputs.filter((o) => o.output_type === 'schematic');
return { ...c, outputs: [output, ...preserved] };
}),
};
});
} catch (err) {
const output: CellOutput = {
output_type: 'error',
data: {
success: false,
log: '',
error: err instanceof Error ? err.message : 'Simulation failed',
elapsed_seconds: 0,
},
timestamp: new Date().toISOString(),
};
setNotebook((prev) => {
if (!prev) return prev;
return {
...prev,
cells: prev.cells.map((c) => {
if (c.id !== cellId) return c;
const preserved = c.outputs.filter((o) => o.output_type === 'schematic');
return { ...c, outputs: [output, ...preserved] };
}),
};
});
} finally {
setRunningCells((prev) => {
const next = new Set(prev);
next.delete(cellId);
return next;
});
}
},
[notebookId],
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<div className="text-center">
<Loader2 className="w-6 h-6 animate-spin text-sb-accent mx-auto mb-2" />
<p className="text-sb-muted text-sm">Loading simulation...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[200px] px-4">
<div className="max-w-md w-full rounded-lg border border-red-500/30 bg-red-600/10 p-6 text-center">
<h2 className="text-lg font-semibold text-red-400 mb-2">
Simulation not found
</h2>
<p className="text-sm text-sb-muted">{error}</p>
</div>
</div>
);
}
if (!notebook) return null;
return (
<div className="max-w-4xl mx-auto px-4 py-4 space-y-4">
<h1 className="text-lg font-semibold text-sb-text-bright">
{notebook.metadata.title}
</h1>
{notebook.cells.map((cell) => (
<EmbedCell
key={cell.id}
cell={cell}
running={runningCells.has(cell.id)}
onRun={handleRun}
theme={theme}
/>
))}
</div>
);
}