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)
183 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|