Add SpiceBook simulation embeds for interactive circuit exploration
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.
This commit is contained in:
parent
278946fb10
commit
9e5a826ca1
@ -13,3 +13,6 @@ CADDY_HOST=mims.localhost
|
||||
|
||||
# Site URL for sitemap generation and OG meta tags
|
||||
# SITE_URL=https://forrest.warehack.ing
|
||||
|
||||
# SpiceBook URL for interactive circuit simulations
|
||||
# PUBLIC_SPICEBOOK_URL=https://spicebook.warehack.ing
|
||||
|
||||
@ -21,7 +21,9 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
ARG SITE_URL=https://forrest.warehack.ing
|
||||
ARG PUBLIC_SPICEBOOK_URL=https://spicebook.warehack.ing
|
||||
ENV SITE_URL=${SITE_URL}
|
||||
ENV PUBLIC_SPICEBOOK_URL=${PUBLIC_SPICEBOOK_URL}
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
@ -6,6 +6,7 @@ services:
|
||||
target: ${MODE:-production}
|
||||
args:
|
||||
- SITE_URL=${SITE_URL:-https://forrest.warehack.ing}
|
||||
- PUBLIC_SPICEBOOK_URL=${PUBLIC_SPICEBOOK_URL:-https://spicebook.warehack.ing}
|
||||
container_name: mims-library
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
250
site/src/components/SimulationEmbed.tsx
Normal file
250
site/src/components/SimulationEmbed.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -15,6 +15,15 @@ formats:
|
||||
filename: "03_555_Timer_Circuits.pdf"
|
||||
- type: "DjVu"
|
||||
filename: "electronics - Forrest Mims-engineer's mini-notebook 555 timer circuits (radio shack electronics).djvu"
|
||||
simulations:
|
||||
- notebookId: "555-astable-blinker"
|
||||
title: "555 Astable LED Blinker"
|
||||
description: "Classic astable multivibrator — adjust R1, R2, C1 to set blink rate"
|
||||
pageRef: "p. 8"
|
||||
- notebookId: "555-monostable-pulse"
|
||||
title: "555 Monostable Pulse Generator"
|
||||
description: "One-shot timer producing precise pulse widths"
|
||||
pageRef: "p. 4"
|
||||
---
|
||||
|
||||
The 555 timer is one of the most versatile ICs ever made. This notebook shows you dozens of ways to use it, from simple LED blinkers to complex timing circuits.
|
||||
|
||||
@ -17,6 +17,13 @@ formats:
|
||||
filename: "electronics - Forrest Mims-engineer's mini-notebook basic semiconductor circuits (radio shack electronics).djvu"
|
||||
- type: "Text"
|
||||
filename: "electronics - Forrest Mims-engineer's mini-notebook basic semiconductor circuits (radio shack electronics)_djvu.txt"
|
||||
simulations:
|
||||
- notebookId: "common-emitter-amplifier"
|
||||
title: "Common Emitter Amplifier"
|
||||
description: "NPN amplifier with bias network — AC gain and DC operating point"
|
||||
- notebookId: "voltage-divider"
|
||||
title: "Resistive Voltage Divider"
|
||||
description: "The most fundamental circuit — verify the ratio under load"
|
||||
---
|
||||
|
||||
The foundational notebook covering transistor basics, diode applications, and simple semiconductor circuits. Perfect for beginners learning the fundamentals of solid-state electronics.
|
||||
|
||||
@ -15,6 +15,14 @@ formats:
|
||||
filename: "05_Communications_Projects.pdf"
|
||||
- type: "DjVu"
|
||||
filename: "Forrest Mims-Engineer's Mini-Notebook - Communications Projects (Radio Shack Electronics).djvu"
|
||||
simulations:
|
||||
- notebookId: "am-radio-receiver"
|
||||
title: "AM Envelope Detector"
|
||||
description: "Diode detector extracts audio from amplitude-modulated carrier"
|
||||
pageRef: "p. 10"
|
||||
- notebookId: "colpitts-oscillator"
|
||||
title: "Colpitts RF Oscillator"
|
||||
description: "LC oscillator startup and steady-state at ~1 MHz"
|
||||
---
|
||||
|
||||
Build your own radio circuits! From simple crystal radios to FM transmitters and receivers, this notebook covers the fundamentals of wireless communication.
|
||||
|
||||
@ -15,6 +15,13 @@ formats:
|
||||
filename: "04_Formulas_Tables_Basic_Circuits.pdf"
|
||||
- type: "DjVu"
|
||||
filename: "Forrest Mims-Engineer's Mini-Notebook Formulas Tables Basic Circuits (Radio Shack Electronics).djvu"
|
||||
simulations:
|
||||
- notebookId: "rc-lowpass-filter"
|
||||
title: "RC Lowpass Filter"
|
||||
description: "First-order filter with Bode plot and step response"
|
||||
- notebookId: "voltage-divider"
|
||||
title: "Voltage Divider Under Load"
|
||||
description: "What happens to the divider ratio when you connect a real load"
|
||||
---
|
||||
|
||||
The ultimate pocket reference. Ohm's Law, resistor color codes, capacitor values, schematic symbols, and dozens of basic circuits - all in Mims' clear, hand-drawn style.
|
||||
|
||||
@ -15,6 +15,15 @@ formats:
|
||||
filename: "02_Op_Amp_IC_Circuits.pdf"
|
||||
- type: "DjVu"
|
||||
filename: "Forrest Mims-Engineer's Mini-Notebook Op Amp Ic Circuits (Radio Shack Electronics)(1).djvu"
|
||||
simulations:
|
||||
- notebookId: "inverting-op-amp"
|
||||
title: "Inverting Amplifier"
|
||||
description: "See gain, phase inversion, and bandwidth in one AC sweep"
|
||||
pageRef: "p. 6"
|
||||
- notebookId: "op-amp-comparator"
|
||||
title: "Op-Amp Voltage Comparator"
|
||||
description: "Threshold detection with hysteresis via positive feedback"
|
||||
pageRef: "p. 14"
|
||||
---
|
||||
|
||||
Master the versatile operational amplifier with circuits ranging from simple gain stages to precision instrumentation amplifiers.
|
||||
|
||||
@ -15,6 +15,13 @@ formats:
|
||||
filename: "08_Sensor_Projects.pdf"
|
||||
- type: "DjVu"
|
||||
filename: "Forrest Mims-engineer's mini-notebook sensor projects (radio shack electronics).djvu"
|
||||
simulations:
|
||||
- notebookId: "thermistor-bridge"
|
||||
title: "Thermistor Bridge Circuit"
|
||||
description: "Wheatstone bridge with NTC thermistor — voltage vs temperature"
|
||||
- notebookId: "photodiode-amplifier"
|
||||
title: "Photodiode Transimpedance Amplifier"
|
||||
description: "Convert light intensity to voltage with op-amp current-to-voltage converter"
|
||||
---
|
||||
|
||||
Sensors let your circuits interact with the real world. This notebook covers thermistors, photocells, microphones, and more - essential for any maker project.
|
||||
|
||||
@ -19,6 +19,15 @@ const booksCollection = defineCollection({
|
||||
type: z.string(),
|
||||
filename: z.string(),
|
||||
url: z.string().optional()
|
||||
})).optional(),
|
||||
simulations: z.array(z.object({
|
||||
notebookId: z.string().min(1).max(100).regex(
|
||||
/^[a-z0-9][a-z0-9-]*[a-z0-9]$/,
|
||||
'notebookId must be lowercase alphanumeric with hyphens, no leading/trailing hyphens'
|
||||
),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pageRef: z.string().optional(),
|
||||
})).optional()
|
||||
})
|
||||
});
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
export interface SimulationRef {
|
||||
notebookId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pageRef?: string;
|
||||
}
|
||||
|
||||
export interface BookData {
|
||||
slug: string;
|
||||
collection: 'mims' | 'uglys' | 'other';
|
||||
@ -11,6 +18,7 @@ export interface BookData {
|
||||
coverImage?: string;
|
||||
year?: number;
|
||||
sortOrder: number;
|
||||
simulations?: SimulationRef[];
|
||||
}
|
||||
|
||||
export function serializeBook(entry: CollectionEntry<'books'>): BookData {
|
||||
@ -25,5 +33,6 @@ export function serializeBook(entry: CollectionEntry<'books'>): BookData {
|
||||
coverImage: entry.data.coverImage,
|
||||
year: entry.data.year,
|
||||
sortOrder: entry.data.sortOrder,
|
||||
simulations: entry.data.simulations,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import EBookReader from '@/components/EBookReader';
|
||||
import SimulationEmbed from '@/components/SimulationEmbed';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
import { getRelatedBooks } from '@/lib/related-books';
|
||||
import RelatedBooks from '@/components/RelatedBooks.astro';
|
||||
@ -22,7 +23,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { book } = Astro.props;
|
||||
const { title, shortTitle, description, topics, localPdf, coverImage, year, archiveOrgUrl, formats } = book.data;
|
||||
const { title, shortTitle, description, topics, localPdf, coverImage, year, archiveOrgUrl, formats, simulations } = book.data;
|
||||
|
||||
// Get all mims books for navigation
|
||||
const allBooks = await getCollection('books');
|
||||
@ -151,6 +152,15 @@ const breadcrumbJsonLd: WithContext<BreadcrumbList> = {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Interactive Simulations -->
|
||||
{simulations && simulations.length > 0 && (
|
||||
<SimulationEmbed
|
||||
simulations={simulations}
|
||||
spicebookUrl={import.meta.env.PUBLIC_SPICEBOOK_URL || 'https://spicebook.warehack.ing'}
|
||||
client:visible
|
||||
/>
|
||||
)}
|
||||
|
||||
<!-- Related Books -->
|
||||
<RelatedBooks relatedBooks={relatedBooks} currentCollection="mims" />
|
||||
|
||||
|
||||
@ -224,6 +224,27 @@
|
||||
background: oklch(0.65 0.15 145);
|
||||
}
|
||||
|
||||
/* Simulation loading animation — diagonal circuit-board-green stripes */
|
||||
.sim-loading {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
oklch(0.65 0.15 160 / 0.08) 10px,
|
||||
oklch(0.65 0.15 160 / 0.08) 20px
|
||||
);
|
||||
animation: sim-loading-shift 1s linear infinite;
|
||||
}
|
||||
@keyframes sim-loading-shift {
|
||||
to { background-position: 28px 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sim-loading {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth theme transition */
|
||||
html {
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user