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 for sitemap generation and OG meta tags
|
||||||
# SITE_URL=https://forrest.warehack.ing
|
# 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
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG SITE_URL=https://forrest.warehack.ing
|
ARG SITE_URL=https://forrest.warehack.ing
|
||||||
|
ARG PUBLIC_SPICEBOOK_URL=https://spicebook.warehack.ing
|
||||||
ENV SITE_URL=${SITE_URL}
|
ENV SITE_URL=${SITE_URL}
|
||||||
|
ENV PUBLIC_SPICEBOOK_URL=${PUBLIC_SPICEBOOK_URL}
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@ -6,6 +6,7 @@ services:
|
|||||||
target: ${MODE:-production}
|
target: ${MODE:-production}
|
||||||
args:
|
args:
|
||||||
- SITE_URL=${SITE_URL:-https://forrest.warehack.ing}
|
- SITE_URL=${SITE_URL:-https://forrest.warehack.ing}
|
||||||
|
- PUBLIC_SPICEBOOK_URL=${PUBLIC_SPICEBOOK_URL:-https://spicebook.warehack.ing}
|
||||||
container_name: mims-library
|
container_name: mims-library
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
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"
|
filename: "03_555_Timer_Circuits.pdf"
|
||||||
- type: "DjVu"
|
- type: "DjVu"
|
||||||
filename: "electronics - Forrest Mims-engineer's mini-notebook 555 timer circuits (radio shack electronics).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.
|
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"
|
filename: "electronics - Forrest Mims-engineer's mini-notebook basic semiconductor circuits (radio shack electronics).djvu"
|
||||||
- type: "Text"
|
- type: "Text"
|
||||||
filename: "electronics - Forrest Mims-engineer's mini-notebook basic semiconductor circuits (radio shack electronics)_djvu.txt"
|
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.
|
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"
|
filename: "05_Communications_Projects.pdf"
|
||||||
- type: "DjVu"
|
- type: "DjVu"
|
||||||
filename: "Forrest Mims-Engineer's Mini-Notebook - Communications Projects (Radio Shack Electronics).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.
|
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"
|
filename: "04_Formulas_Tables_Basic_Circuits.pdf"
|
||||||
- type: "DjVu"
|
- type: "DjVu"
|
||||||
filename: "Forrest Mims-Engineer's Mini-Notebook Formulas Tables Basic Circuits (Radio Shack Electronics).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.
|
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"
|
filename: "02_Op_Amp_IC_Circuits.pdf"
|
||||||
- type: "DjVu"
|
- type: "DjVu"
|
||||||
filename: "Forrest Mims-Engineer's Mini-Notebook Op Amp Ic Circuits (Radio Shack Electronics)(1).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.
|
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"
|
filename: "08_Sensor_Projects.pdf"
|
||||||
- type: "DjVu"
|
- type: "DjVu"
|
||||||
filename: "Forrest Mims-engineer's mini-notebook sensor projects (radio shack electronics).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.
|
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(),
|
type: z.string(),
|
||||||
filename: z.string(),
|
filename: z.string(),
|
||||||
url: z.string().optional()
|
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()
|
})).optional()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
|
||||||
|
export interface SimulationRef {
|
||||||
|
notebookId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pageRef?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookData {
|
export interface BookData {
|
||||||
slug: string;
|
slug: string;
|
||||||
collection: 'mims' | 'uglys' | 'other';
|
collection: 'mims' | 'uglys' | 'other';
|
||||||
@ -11,6 +18,7 @@ export interface BookData {
|
|||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
simulations?: SimulationRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeBook(entry: CollectionEntry<'books'>): BookData {
|
export function serializeBook(entry: CollectionEntry<'books'>): BookData {
|
||||||
@ -25,5 +33,6 @@ export function serializeBook(entry: CollectionEntry<'books'>): BookData {
|
|||||||
coverImage: entry.data.coverImage,
|
coverImage: entry.data.coverImage,
|
||||||
year: entry.data.year,
|
year: entry.data.year,
|
||||||
sortOrder: entry.data.sortOrder,
|
sortOrder: entry.data.sortOrder,
|
||||||
|
simulations: entry.data.simulations,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import Layout from '@/layouts/Layout.astro';
|
import Layout from '@/layouts/Layout.astro';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import EBookReader from '@/components/EBookReader';
|
import EBookReader from '@/components/EBookReader';
|
||||||
|
import SimulationEmbed from '@/components/SimulationEmbed';
|
||||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||||
import { getRelatedBooks } from '@/lib/related-books';
|
import { getRelatedBooks } from '@/lib/related-books';
|
||||||
import RelatedBooks from '@/components/RelatedBooks.astro';
|
import RelatedBooks from '@/components/RelatedBooks.astro';
|
||||||
@ -22,7 +23,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { book } = Astro.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
|
// Get all mims books for navigation
|
||||||
const allBooks = await getCollection('books');
|
const allBooks = await getCollection('books');
|
||||||
@ -151,6 +152,15 @@ const breadcrumbJsonLd: WithContext<BreadcrumbList> = {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Related Books -->
|
||||||
<RelatedBooks relatedBooks={relatedBooks} currentCollection="mims" />
|
<RelatedBooks relatedBooks={relatedBooks} currentCollection="mims" />
|
||||||
|
|
||||||
|
|||||||
@ -224,6 +224,27 @@
|
|||||||
background: oklch(0.65 0.15 145);
|
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 */
|
/* Smooth theme transition */
|
||||||
html {
|
html {
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user