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.
189 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|