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.
This commit is contained in:
Ryan Malloy 2026-03-05 15:41:51 -07:00
parent 896a8535cf
commit fb70b39173
4 changed files with 104 additions and 13 deletions

View File

@ -4,12 +4,6 @@ import * as api from '../../lib/api';
import { EmbedCell } from './EmbedCell';
import { Loader2 } from 'lucide-react';
// Origins allowed to send postMessage theme changes to the embed.
// Must stay in sync with CSP_FRAME_ANCESTORS / CORS_EXTRA_ORIGINS.
const ALLOWED_MESSAGE_ORIGINS = new Set([
'https://forrest.warehack.ing',
]);
interface EmbedViewerProps {
notebookId: string;
initialTheme: string;
@ -33,8 +27,6 @@ export default function EmbedViewer({ notebookId, initialTheme }: EmbedViewerPro
// Listen for postMessage theme changes from parent iframe host
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (!ALLOWED_MESSAGE_ORIGINS.has(event.origin)) return;
if (event.data?.type === 'spicebook-theme') {
const incoming = event.data.theme;
if (incoming === 'dark' || incoming === 'light') {

View File

@ -0,0 +1,97 @@
import { useState, useRef } from 'react';
import { Code, Check, Copy } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '../../ui/popover';
import { Button } from '../../ui/Button';
import { useNotebookStore } from '../../../lib/notebook-store';
export function EmbedDialog() {
const { notebookId } = useNotebookStore();
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
if (!notebookId) return null;
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const snippet = `<iframe
src="${origin}/embed/${notebookId}?theme=${theme}"
width="100%" height="600"
style="border: 1px solid #334155; border-radius: 8px;"
allow="clipboard-write"
></iframe>`;
function handleCopy() {
navigator.clipboard.writeText(snippet).then(() => {
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
});
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" title="Embed this notebook">
<Code className="w-3.5 h-3.5" />
Embed
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<div className="space-y-3">
<p className="text-xs font-medium text-slate-300">
Embed this notebook on any page
</p>
{/* Theme toggle */}
<div className="flex gap-1 rounded-md bg-slate-800 p-0.5">
<button
onClick={() => setTheme('dark')}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
theme === 'dark'
? 'bg-slate-600 text-slate-100'
: 'text-slate-400 hover:text-slate-300'
}`}
>
Dark
</button>
<button
onClick={() => setTheme('light')}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
theme === 'light'
? 'bg-slate-600 text-slate-100'
: 'text-slate-400 hover:text-slate-300'
}`}
>
Light
</button>
</div>
{/* Snippet */}
<textarea
readOnly
value={snippet}
rows={5}
className="w-full resize-none rounded-md border border-slate-600 bg-slate-800 p-2 font-mono text-[11px] text-slate-300 focus:outline-none focus:border-blue-500"
/>
{/* Copy button */}
<Button
variant="primary"
size="sm"
onClick={handleCopy}
className="w-full"
>
{copied ? (
<Check className="w-3.5 h-3.5" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
{copied ? 'Copied!' : 'Copy snippet'}
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -12,6 +12,7 @@ import {
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
import { Dropdown } from '../../ui/Dropdown';
import { EmbedDialog } from './EmbedDialog';
import { useNotebookStore } from '../../../lib/notebook-store';
import type { CellType } from '../../../lib/types';
@ -124,9 +125,7 @@ export function NotebookToolbar() {
className="bg-slate-800 border border-slate-600 rounded text-xs px-2 py-1 text-slate-300 focus:outline-none focus:border-blue-500 cursor-pointer"
>
<option value="ngspice">ngspice</option>
<option value="ltspice" disabled>
ltspice (soon)
</option>
<option value="ltspice">ltspice</option>
</select>
{/* Dirty indicator */}
@ -161,6 +160,9 @@ export function NotebookToolbar() {
Run All
</Button>
{/* Embed snippet */}
<EmbedDialog />
{/* Save */}
<Button
variant={dirty ? 'primary' : 'ghost'}

View File

@ -1,8 +1,8 @@
import { defineMiddleware } from 'astro:middleware';
// CSP frame-ancestors: controls which origins can embed this site in an iframe.
// Only applied to /embed/* routes (the main app doesn't need to be framed).
const FRAME_ANCESTORS = "'self' https://forrest.warehack.ing";
// /embed/* routes allow framing from any origin; the main app stays locked to 'self'.
const FRAME_ANCESTORS = '*';
export const onRequest = defineMiddleware(async ({ url }, next) => {
const response = await next();