Merge homepage-redesign: hero, features, categorized gallery

This commit is contained in:
Ryan Malloy 2026-02-14 20:46:49 -07:00
commit 579f90487d
6 changed files with 439 additions and 21 deletions

View File

@ -9,7 +9,7 @@ export default defineConfig({
adapter: node({ mode: 'standalone' }),
integrations: [
react(),
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu'] } }),
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu', 'activity', 'circuit-board', 'file-text', 'chevron-down'] } }),
],
telemetry: false,
devToolbar: { enabled: false },

View File

@ -0,0 +1,65 @@
import { LayoutGrid } from 'lucide-react';
import { Badge } from './ui/Badge';
import type { NotebookSummary } from '../lib/types';
const engineVariant: Record<string, 'blue' | 'amber'> = {
ngspice: 'blue',
ltspice: 'amber',
};
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return iso;
}
}
interface NotebookCardProps {
notebook: NotebookSummary;
}
export function NotebookCard({ notebook }: NotebookCardProps) {
const { id, title, engine, tags, cell_count, modified } = notebook;
return (
<a
href={`/notebook/${encodeURIComponent(id)}`}
className="group block rounded-lg border border-slate-800 bg-slate-900/50 p-5 hover:border-blue-500/40 hover:bg-slate-800/70 transition-all"
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-base font-semibold text-slate-100 group-hover:text-blue-400 transition-colors line-clamp-1">
{title}
</h3>
<Badge variant={engineVariant[engine] ?? 'default'} className="shrink-0 ml-2">
{engine}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 mb-3">
<span className="inline-flex items-center gap-1">
<LayoutGrid className="w-3 h-3" />
{cell_count} cell{cell_count !== 1 ? 's' : ''}
</span>
<span>{formatDate(modified)}</span>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span
key={tag}
className="text-xs bg-slate-700/50 text-slate-400 px-2 py-0.5 rounded"
>
{tag}
</span>
))}
</div>
)}
</a>
);
}

View File

@ -0,0 +1,229 @@
import { useState, useMemo } from 'react';
import {
Timer,
Triangle,
Thermometer,
Radio,
BatteryCharging,
Volume2,
BookOpen,
Cpu,
Search,
X,
AlertTriangle,
} from 'lucide-react';
import { NotebookCard } from './NotebookCard';
import type { NotebookSummary } from '../lib/types';
// Category definitions: priority-ordered, matched by tag keywords
const CATEGORIES = [
{ key: 'timers', label: 'Timers & Oscillators', icon: Timer, matchTags: ['555', 'timer', 'oscillator'] },
{ key: 'opamp', label: 'Op-Amp Circuits', icon: Triangle, matchTags: ['op-amp'] },
{ key: 'sensors', label: 'Sensors & Measurement', icon: Thermometer, matchTags: ['sensor', 'measurement', 'bridge'] },
{ key: 'comms', label: 'Communications & RF', icon: Radio, matchTags: ['radio', 'rf', 'communications', 'am'] },
{ key: 'power', label: 'Power Electronics', icon: BatteryCharging, matchTags: ['power', 'converter', 'charge'] },
{ key: 'amplifiers', label: 'Amplifiers', icon: Volume2, matchTags: ['amplifier', 'bjt', 'audio'] },
{ key: 'passive', label: 'Passive & Beginner', icon: BookOpen, matchTags: ['passive', 'beginner', 'filter', 'dc'] },
{ key: 'advanced', label: 'Advanced Topics', icon: Cpu, matchTags: [] }, // fallback
] as const;
type CategoryKey = (typeof CATEGORIES)[number]['key'];
function categorize(notebooks: NotebookSummary[]): Map<CategoryKey, NotebookSummary[]> {
const grouped = new Map<CategoryKey, NotebookSummary[]>();
for (const cat of CATEGORIES) grouped.set(cat.key, []);
const assigned = new Set<string>();
// First pass: assign each notebook to the first matching category
for (const cat of CATEGORIES) {
if (cat.matchTags.length === 0) continue; // skip fallback
for (const nb of notebooks) {
if (assigned.has(nb.id)) continue;
const lower = nb.tags.map((t) => t.toLowerCase());
if (cat.matchTags.some((mt) => lower.includes(mt))) {
grouped.get(cat.key)!.push(nb);
assigned.add(nb.id);
}
}
}
// Second pass: unmatched → Advanced Topics
for (const nb of notebooks) {
if (!assigned.has(nb.id)) {
grouped.get('advanced')!.push(nb);
}
}
return grouped;
}
function matchesSearch(nb: NotebookSummary, query: string): boolean {
const q = query.toLowerCase();
return (
nb.title.toLowerCase().includes(q) ||
nb.tags.some((t) => t.toLowerCase().includes(q))
);
}
interface NotebookGalleryProps {
initialNotebooks: NotebookSummary[];
error?: string | null;
}
export default function NotebookGallery({ initialNotebooks, error }: NotebookGalleryProps) {
const [activeCategory, setActiveCategory] = useState<'all' | CategoryKey>('all');
const [searchQuery, setSearchQuery] = useState('');
const grouped = useMemo(() => categorize(initialNotebooks), [initialNotebooks]);
// Categories that actually have notebooks
const activeCategories = useMemo(
() => CATEGORIES.filter((cat) => (grouped.get(cat.key)?.length ?? 0) > 0),
[grouped],
);
// Apply search filter
const filteredGrouped = useMemo(() => {
if (!searchQuery) return grouped;
const result = new Map<CategoryKey, NotebookSummary[]>();
for (const [key, nbs] of grouped) {
result.set(key, nbs.filter((nb) => matchesSearch(nb, searchQuery)));
}
return result;
}, [grouped, searchQuery]);
// Flat list for single-category view
const flatFiltered = useMemo(() => {
if (activeCategory === 'all') return null;
const nbs = filteredGrouped.get(activeCategory) ?? [];
return nbs;
}, [activeCategory, filteredGrouped]);
if (error) {
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-600/10 p-6">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 mt-0.5 shrink-0" />
<div>
<h3 className="text-amber-400 font-medium">Could not load notebooks</h3>
<p className="text-amber-200/70 text-sm mt-1">{error}</p>
</div>
</div>
</div>
);
}
if (initialNotebooks.length === 0) {
return (
<div className="text-center py-16">
<p className="text-slate-500 mb-4">No notebooks yet.</p>
<a
href="/notebook/new"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
>
Create your first notebook
</a>
</div>
);
}
return (
<div>
{/* Filter bar */}
<div className="flex items-center gap-3 mb-8">
<div className="flex gap-2 overflow-x-auto filter-pills flex-1 pb-1">
<button
onClick={() => setActiveCategory('all')}
className={`shrink-0 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
activeCategory === 'all'
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-300'
}`}
>
All
</button>
{activeCategories.map((cat) => {
const Icon = cat.icon;
const count = filteredGrouped.get(cat.key)?.length ?? 0;
return (
<button
key={cat.key}
onClick={() => setActiveCategory(cat.key)}
className={`shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
activeCategory === cat.key
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-300'
}`}
>
<Icon className="w-3.5 h-3.5" />
{cat.label}
<span className="text-xs opacity-70">({count})</span>
</button>
);
})}
</div>
{/* Tag search */}
<div className="relative shrink-0">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Search tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-40 pl-8 pr-8 py-1.5 text-sm rounded-lg border border-slate-700 bg-slate-800/50 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-blue-500/50"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Category-grouped "All" view */}
{activeCategory === 'all' && (
<div className="space-y-10">
{activeCategories.map((cat) => {
const nbs = filteredGrouped.get(cat.key) ?? [];
if (nbs.length === 0) return null;
const Icon = cat.icon;
return (
<section key={cat.key}>
<h3 className="flex items-center gap-2 text-lg font-semibold text-slate-200 mb-4">
<Icon className="w-5 h-5 text-slate-400" />
{cat.label}
<span className="text-sm font-normal text-slate-500">({nbs.length})</span>
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{nbs.map((nb) => (
<NotebookCard key={nb.id} notebook={nb} />
))}
</div>
</section>
);
})}
</div>
)}
{/* Single-category flat view */}
{activeCategory !== 'all' && flatFiltered && (
<div>
{flatFiltered.length === 0 ? (
<p className="text-slate-500 text-center py-12">No notebooks match this filter.</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{flatFiltered.map((nb) => (
<NotebookCard key={nb.id} notebook={nb} />
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@ -1,10 +1,11 @@
import type { NotebookSummary } from './types';
// process.env is required here — Vite's import.meta.env only exposes
// PUBLIC_* prefixed vars. This module runs server-side only (SSR).
export const INTERNAL_API_BASE =
process.env.BACKEND_INTERNAL_URL || 'http://localhost:8099';
export async function fetchNotebookMeta(id: string) {
const url = `${INTERNAL_API_BASE}/api/notebooks/${encodeURIComponent(id)}`;
async function ssrFetch<T>(label: string, url: string): Promise<T | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
@ -12,19 +13,29 @@ export async function fetchNotebookMeta(id: string) {
clearTimeout(timeout);
if (!res.ok) {
console.warn(`[server-api] fetchNotebookMeta(${id}): HTTP ${res.status}`);
console.warn(`[server-api] ${label}: HTTP ${res.status}`);
return null;
}
return await res.json();
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
console.error(`[server-api] fetchNotebookMeta(${id}): timed out after 5s`);
console.error(`[server-api] ${label}: timed out after 5s`);
} else {
console.error(
`[server-api] fetchNotebookMeta(${id}):`,
`[server-api] ${label}:`,
err instanceof Error ? err.message : err
);
}
return null;
}
}
export async function fetchNotebookMeta(id: string) {
const url = `${INTERNAL_API_BASE}/api/notebooks/${encodeURIComponent(id)}`;
return ssrFetch(`fetchNotebookMeta(${id})`, url);
}
export async function fetchNotebookList(): Promise<NotebookSummary[]> {
const url = `${INTERNAL_API_BASE}/api/notebooks`;
return (await ssrFetch<NotebookSummary[]>('fetchNotebookList', url)) ?? [];
}

View File

@ -1,33 +1,115 @@
---
import { Icon } from 'astro-icon/components';
import NotebookLayout from '../layouts/NotebookLayout.astro';
import NotebookList from '../components/NotebookList';
import NotebookGallery from '../components/NotebookGallery';
import { fetchNotebookList } from '../lib/server-api';
import type { NotebookSummary } from '../lib/types';
let notebooks: NotebookSummary[] = [];
let fetchError: string | null = null;
try {
notebooks = await fetchNotebookList();
} catch (err) {
fetchError = err instanceof Error ? err.message : 'Backend unavailable';
}
---
<NotebookLayout
title="SpiceBook"
description="Notebook interface for SPICE circuit simulation"
description="Write SPICE netlists, run ngspice simulations, and visualize waveforms in a single document."
canonicalPath="/"
>
<div class="max-w-5xl mx-auto px-6 py-10">
<!-- Header (static) -->
<header class="mb-10">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-slate-100 tracking-tight">SpiceBook</h1>
<p class="text-slate-400 mt-1">Notebook interface for SPICE circuit simulation</p>
</div>
<style>
@import '../styles/homepage.css';
</style>
<!-- Hero -->
<section class="hero-graticule border-b border-slate-800/60">
<div class="relative max-w-6xl mx-auto px-6 pt-16 pb-20">
<p class="text-sm font-semibold tracking-widest text-blue-400 uppercase mb-4">SpiceBook</p>
<h1 class="text-4xl md:text-5xl font-bold text-slate-100 tracking-tight max-w-2xl">
Circuit Simulation Notebooks
</h1>
<p class="mt-4 text-lg text-slate-400 max-w-xl leading-relaxed">
Write SPICE netlists, run ngspice simulations, and visualize waveforms in a single document.
</p>
<div class="flex items-center gap-3 mt-8">
<a
href="/notebook/new"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
>
<Icon name="lucide:plus" class="w-4 h-4" />
New Notebook
</a>
<a
href="#notebooks"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg border border-slate-700 text-slate-300 hover:border-slate-500 hover:text-slate-100 font-medium transition-colors text-sm"
>
Browse Notebooks
<Icon name="lucide:chevron-down" class="w-4 h-4" />
</a>
</div>
</header>
</div>
</section>
<!-- Dynamic notebook list (client-side fetch) -->
<NotebookList client:load />
</div>
<!-- Feature Highlights -->
<section class="max-w-6xl mx-auto px-6 py-16">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="p-5 rounded-lg border border-slate-800 bg-slate-900/50">
<Icon name="lucide:zap" class="w-6 h-6 text-blue-400 mb-3" />
<h3 class="font-semibold text-slate-100 mb-1">SPICE Simulation</h3>
<p class="text-sm text-slate-400 leading-relaxed">
Write netlists and run ngspice. Transient, AC, DC sweep, operating point.
</p>
</div>
<div class="p-5 rounded-lg border border-slate-800 bg-slate-900/50">
<Icon name="lucide:activity" class="w-6 h-6 text-blue-400 mb-3" />
<h3 class="font-semibold text-slate-100 mb-1">Waveform Plots</h3>
<p class="text-sm text-slate-400 leading-relaxed">
Results render as interactive plots below each netlist.
</p>
</div>
<div class="p-5 rounded-lg border border-slate-800 bg-slate-900/50">
<Icon name="lucide:circuit-board" class="w-6 h-6 text-blue-400 mb-3" />
<h3 class="font-semibold text-slate-100 mb-1">Schematic Generation</h3>
<p class="text-sm text-slate-400 leading-relaxed">
Netlists produce SVG circuit diagrams automatically.
</p>
</div>
<div class="p-5 rounded-lg border border-slate-800 bg-slate-900/50">
<Icon name="lucide:file-text" class="w-6 h-6 text-blue-400 mb-3" />
<h3 class="font-semibold text-slate-100 mb-1">Notebook Format</h3>
<p class="text-sm text-slate-400 leading-relaxed">
Markdown, SPICE, and Python cells in a single document.
</p>
</div>
</div>
</section>
<!-- Notebook Gallery -->
<section id="notebooks" class="max-w-6xl mx-auto px-6 pb-20 scroll-mt-8">
<h2 class="text-2xl font-bold text-slate-100 mb-8">Notebooks</h2>
<NotebookGallery
client:load
initialNotebooks={notebooks}
error={fetchError}
/>
</section>
<!-- Footer CTA -->
<footer class="border-t border-slate-800/60">
<div class="max-w-6xl mx-auto px-6 py-16 text-center">
<h2 class="text-2xl font-bold text-slate-100 mb-3">Ready to simulate?</h2>
<a
href="/notebook/new"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm mt-4"
>
<Icon name="lucide:plus" class="w-4 h-4" />
New Notebook
</a>
<p class="text-sm text-slate-500 mt-6">
Built on ngspice · Part of <a href="https://warehack.ing" class="text-slate-400 hover:text-slate-300 underline underline-offset-2">warehack.ing</a>
</p>
</div>
</footer>
</NotebookLayout>

View File

@ -0,0 +1,31 @@
/* Oscilloscope graticule background for the hero section */
.hero-graticule {
--grid-color: rgba(51, 65, 85, 0.35);
--grid-size: 48px;
position: relative;
overflow: hidden;
}
.hero-graticule::before {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--grid-color) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 85%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 85%);
pointer-events: none;
}
/* Horizontal scrollable filter pills */
.filter-pills {
scrollbar-width: none;
-ms-overflow-style: none;
}
.filter-pills::-webkit-scrollbar {
display: none;
}