SimulationEmbed React island with click-to-activate iframes, theme sync via postMessage/MutationObserver, and graceful fallback when SpiceBook is unavailable. 12 simulations across 6 books (555 timer, op-amp, semiconductor, formulas, communications, sensor). Books without simulations render no extra markup. client:visible hydration defers JS cost until scrolled into view.
251 lines
8.9 KiB
TypeScript
251 lines
8.9 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { Cpu, Play, ExternalLink, AlertCircle } from 'lucide-react';
|
|
import type { SimulationRef } from '@/lib/types';
|
|
|
|
interface SimulationEmbedProps {
|
|
simulations: SimulationRef[];
|
|
spicebookUrl: string;
|
|
}
|
|
|
|
interface SimCardProps {
|
|
sim: SimulationRef;
|
|
spicebookUrl: string;
|
|
}
|
|
|
|
function SimCard({ sim, spicebookUrl }: SimCardProps) {
|
|
const [active, setActive] = useState(false);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [error, setError] = useState(false);
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
const loadedRef = useRef(false);
|
|
|
|
const embedUrl = `${spicebookUrl}/embed/${sim.notebookId}`;
|
|
|
|
// Detect current theme from <html> class
|
|
const getTheme = useCallback(() => {
|
|
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
|
}, []);
|
|
|
|
// Send theme to iframe via postMessage
|
|
const sendTheme = useCallback((theme: string) => {
|
|
if (iframeRef.current?.contentWindow) {
|
|
iframeRef.current.contentWindow.postMessage(
|
|
{ type: 'spicebook-theme', theme },
|
|
spicebookUrl
|
|
);
|
|
}
|
|
}, [spicebookUrl]);
|
|
|
|
// Watch for theme changes on <html> element
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
|
|
const observer = new MutationObserver(() => {
|
|
sendTheme(getTheme());
|
|
});
|
|
|
|
observer.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['class'],
|
|
});
|
|
|
|
return () => observer.disconnect();
|
|
}, [active, getTheme, sendTheme]);
|
|
|
|
// Keep loadedRef in sync so the timeout can check current state
|
|
// without capturing a stale closure value
|
|
useEffect(() => {
|
|
loadedRef.current = loaded;
|
|
}, [loaded]);
|
|
|
|
// Set a load timeout — if the iframe doesn't fire onload within 8s,
|
|
// assume SpiceBook is unreachable
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
if (!loadedRef.current) setError(true);
|
|
}, 8000);
|
|
|
|
return () => {
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
};
|
|
}, [active]);
|
|
|
|
// Stop in-flight network requests when unmounting the iframe
|
|
useEffect(() => {
|
|
return () => {
|
|
if (iframeRef.current) {
|
|
iframeRef.current.src = 'about:blank';
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const handleLoad = () => {
|
|
setLoaded(true);
|
|
setError(false);
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
sendTheme(getTheme());
|
|
};
|
|
|
|
const handleActivate = () => {
|
|
setActive(true);
|
|
setError(false);
|
|
setLoaded(false);
|
|
};
|
|
|
|
if (!active) {
|
|
// Preview card — click to activate
|
|
return (
|
|
<div className="group relative rounded-xl border border-border bg-card overflow-hidden transition-all hover:shadow-md hover:border-primary/30">
|
|
<div className="p-5 space-y-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="space-y-1 flex-1">
|
|
<h4 className="font-semibold text-card-foreground text-sm leading-tight">
|
|
{sim.title}
|
|
</h4>
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{sim.description}
|
|
</p>
|
|
</div>
|
|
{sim.pageRef && (
|
|
<span className="shrink-0 text-[10px] font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
|
{sim.pageRef}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleActivate}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors"
|
|
>
|
|
<Play size={14} />
|
|
Run Simulation
|
|
</button>
|
|
</div>
|
|
|
|
{/* Decorative circuit trace along bottom */}
|
|
<div className="h-0.5 bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Active state — iframe loaded (or loading/error)
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
|
{/* Header bar */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/30">
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${loaded ? 'bg-emerald-500' : error ? 'bg-red-400' : 'bg-amber-400 animate-pulse'}`} />
|
|
<span className="text-xs font-medium text-card-foreground truncate">
|
|
{sim.title}
|
|
</span>
|
|
{sim.pageRef && (
|
|
<span className="text-[10px] font-mono text-muted-foreground">
|
|
({sim.pageRef})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<a
|
|
href={`${spicebookUrl}/notebook/${sim.notebookId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
title="Open in SpiceBook"
|
|
>
|
|
<ExternalLink size={13} />
|
|
</a>
|
|
<button
|
|
onClick={() => setActive(false)}
|
|
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors text-xs font-medium"
|
|
title="Close simulation"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Iframe container with 16:10 aspect ratio */}
|
|
<div className="relative" style={{ aspectRatio: '16/10' }}>
|
|
{/* Loading state */}
|
|
{!loaded && !error && (
|
|
<div className="absolute inset-0 flex items-center justify-center sim-loading">
|
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
<Cpu size={24} className="animate-spin motion-reduce:animate-none" style={{ animationDuration: '3s' }} />
|
|
<span className="text-xs">Loading simulation...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error/fallback state */}
|
|
{error && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
|
<div className="flex flex-col items-center gap-3 text-center px-6">
|
|
<AlertCircle size={28} className="text-muted-foreground" />
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-card-foreground">Simulation coming soon</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
This notebook is being prepared.{' '}
|
|
<a
|
|
href={`${spicebookUrl}/notebook/${sim.notebookId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
Try SpiceBook directly
|
|
</a>
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setActive(false)}
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Back to preview
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* SECURITY: allow-same-origin is required so SpiceBook can access its
|
|
own storage/cookies. Safe because the iframe src is a different origin.
|
|
The parent MUST validate origin on any future postMessage listener.
|
|
NOTE: iframe onError does NOT fire for HTTP 4xx/5xx responses —
|
|
the 8s timeout is the primary error detection mechanism. A postMessage
|
|
handshake with SpiceBook would provide reliable HTTP error detection. */}
|
|
<iframe
|
|
ref={iframeRef}
|
|
src={`${embedUrl}?theme=${getTheme()}`}
|
|
className={`w-full h-full border-0 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
|
style={{ transition: 'opacity 0.3s ease' }}
|
|
title={`${sim.title} — SpiceBook Simulation`}
|
|
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
|
loading="lazy"
|
|
onLoad={handleLoad}
|
|
onError={() => setError(true)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SimulationEmbed({ simulations, spicebookUrl }: SimulationEmbedProps) {
|
|
return (
|
|
<section className="space-y-4">
|
|
<div className="flex items-center gap-2.5">
|
|
<Cpu size={20} className="text-emerald-600 dark:text-emerald-400" />
|
|
<h3 className="text-lg font-semibold text-foreground">Interactive Simulations</h3>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Run live SPICE simulations of circuits from this notebook. Adjust component values and see waveforms update in real time.
|
|
</p>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{simulations.map((sim, idx) => (
|
|
<SimCard key={`${sim.notebookId}-${idx}`} sim={sim} spicebookUrl={spicebookUrl} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|