Ryan Malloy 8abd7719bf Initial SpiceBook MVP: notebook interface for circuit simulation
Phase 1 implementation with ngspice backend and Astro/React frontend:

Backend (FastAPI):
- ngspice subprocess engine with custom .raw file parser
- Notebook CRUD with .spicebook JSON format (filesystem storage)
- Simulation endpoints (standalone + cell-in-notebook)
- SVG waveform export endpoint
- 18 REST API routes, 5 passing tests

Frontend (Astro 5 + React 19):
- Notebook editor as React island with Zustand state management
- CodeMirror 6 with custom SPICE language mode (syntax highlighting
  for dot commands, components, engineering notation, comments)
- uPlot waveform viewer with transient and AC/Bode plot modes
- Markdown cells with edit/preview toggle
- Notebook list with card grid UI
- Dark theme, Tailwind CSS 4, Lucide icons

Infrastructure:
- Docker Compose with dev/prod targets
- Caddy-based frontend prod serving
- 3 example notebooks (RC filter, voltage divider, CE amplifier)
2026-02-13 01:44:38 -07:00

183 lines
4.9 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import {
Save,
Play,
Plus,
ArrowLeft,
FileText,
Zap,
Code,
Loader2,
} from 'lucide-react';
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
import { Dropdown } from '../../ui/Dropdown';
import { useNotebookStore } from '../../../lib/notebook-store';
import type { CellType } from '../../../lib/types';
export function NotebookToolbar() {
const {
notebook,
dirty,
saving,
runningCells,
saveNotebook,
addCell,
runAllCells,
updateTitle,
updateEngine,
} = useNotebookStore();
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState('');
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [editingTitle]);
if (!notebook) return null;
const isRunning = runningCells.size > 0;
function handleTitleClick() {
setTitleDraft(notebook!.metadata.title);
setEditingTitle(true);
}
function commitTitle() {
const trimmed = titleDraft.trim();
if (trimmed && trimmed !== notebook!.metadata.title) {
updateTitle(trimmed);
}
setEditingTitle(false);
}
function handleTitleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') {
commitTitle();
} else if (e.key === 'Escape') {
setEditingTitle(false);
}
}
const addCellItems = [
{
label: 'Markdown',
icon: <FileText className="w-4 h-4" />,
onClick: () => addCell('markdown' as CellType),
},
{
label: 'SPICE',
icon: <Zap className="w-4 h-4" />,
onClick: () => addCell('spice' as CellType),
},
{
label: 'Python',
icon: <Code className="w-4 h-4" />,
onClick: () => addCell('python' as CellType),
},
];
return (
<div className="sticky top-0 z-40 border-b border-slate-700 bg-slate-900/95 backdrop-blur supports-[backdrop-filter]:bg-slate-900/80">
<div className="max-w-5xl mx-auto px-4 py-2 flex items-center gap-3">
{/* Back link */}
<a
href="/"
className="text-slate-500 hover:text-slate-300 transition-colors p-1"
title="Back to notebooks"
>
<ArrowLeft className="w-4 h-4" />
</a>
{/* Editable title */}
<div className="flex-1 min-w-0">
{editingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={handleTitleKeyDown}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm text-slate-100 w-full max-w-md focus:outline-none focus:border-blue-500"
/>
) : (
<button
onClick={handleTitleClick}
className="text-sm font-semibold text-slate-200 hover:text-slate-100 transition-colors truncate max-w-md block text-left"
title="Click to edit title"
>
{notebook.metadata.title}
</button>
)}
</div>
{/* Engine badge */}
<select
value={notebook.metadata.engine}
onChange={(e) => updateEngine(e.target.value)}
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>
</select>
{/* Dirty indicator */}
{dirty && (
<Badge variant="amber">unsaved</Badge>
)}
{/* Add Cell dropdown */}
<Dropdown
trigger={
<Button variant="ghost" size="sm">
<Plus className="w-3.5 h-3.5" />
Add Cell
</Button>
}
items={addCellItems}
/>
{/* Run All */}
<Button
variant="ghost"
size="sm"
onClick={runAllCells}
disabled={isRunning}
title="Run all SPICE cells"
>
{isRunning ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
)}
Run All
</Button>
{/* Save */}
<Button
variant={dirty ? 'primary' : 'ghost'}
size="sm"
onClick={saveNotebook}
disabled={saving || !dirty}
title="Save notebook (Ctrl+S)"
>
{saving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</div>
);
}