From eae849fe7a2d30094e48956f9284eedb0afe0b38 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 14 Feb 2026 20:45:24 -0700 Subject: [PATCH] Redesign homepage with hero, features, and categorized notebook gallery SSR the notebook list in Astro frontmatter (eliminating the client-side loading spinner). Add hero section with oscilloscope graticule background, 4-column feature highlights, and a React island gallery with category filter pills, tag search, and grouped/flat view modes. --- frontend/astro.config.mjs | 2 +- frontend/src/components/NotebookCard.tsx | 65 ++++++ frontend/src/components/NotebookGallery.tsx | 229 ++++++++++++++++++++ frontend/src/lib/server-api.ts | 21 +- frontend/src/pages/index.astro | 112 ++++++++-- frontend/src/styles/homepage.css | 31 +++ 6 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/NotebookCard.tsx create mode 100644 frontend/src/components/NotebookGallery.tsx create mode 100644 frontend/src/styles/homepage.css diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs index 4d5a83e..0af7e13 100644 --- a/frontend/astro.config.mjs +++ b/frontend/astro.config.mjs @@ -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 }, diff --git a/frontend/src/components/NotebookCard.tsx b/frontend/src/components/NotebookCard.tsx new file mode 100644 index 0000000..2061eac --- /dev/null +++ b/frontend/src/components/NotebookCard.tsx @@ -0,0 +1,65 @@ +import { LayoutGrid } from 'lucide-react'; +import { Badge } from './ui/Badge'; +import type { NotebookSummary } from '../lib/types'; + +const engineVariant: Record = { + 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 ( + +
+

+ {title} +

+ + {engine} + +
+ +
+ + + {cell_count} cell{cell_count !== 1 ? 's' : ''} + + {formatDate(modified)} +
+ + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/NotebookGallery.tsx b/frontend/src/components/NotebookGallery.tsx new file mode 100644 index 0000000..65b63b2 --- /dev/null +++ b/frontend/src/components/NotebookGallery.tsx @@ -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 { + const grouped = new Map(); + for (const cat of CATEGORIES) grouped.set(cat.key, []); + + const assigned = new Set(); + + // 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(); + 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 ( +
+
+ +
+

Could not load notebooks

+

{error}

+
+
+
+ ); + } + + if (initialNotebooks.length === 0) { + return ( +
+

No notebooks yet.

+ + Create your first notebook + +
+ ); + } + + return ( +
+ {/* Filter bar */} +
+
+ + {activeCategories.map((cat) => { + const Icon = cat.icon; + const count = filteredGrouped.get(cat.key)?.length ?? 0; + return ( + + ); + })} +
+ + {/* Tag search */} +
+ + 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 && ( + + )} +
+
+ + {/* Category-grouped "All" view */} + {activeCategory === 'all' && ( +
+ {activeCategories.map((cat) => { + const nbs = filteredGrouped.get(cat.key) ?? []; + if (nbs.length === 0) return null; + const Icon = cat.icon; + return ( +
+

+ + {cat.label} + ({nbs.length}) +

+
+ {nbs.map((nb) => ( + + ))} +
+
+ ); + })} +
+ )} + + {/* Single-category flat view */} + {activeCategory !== 'all' && flatFiltered && ( +
+ {flatFiltered.length === 0 ? ( +

No notebooks match this filter.

+ ) : ( +
+ {flatFiltered.map((nb) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/lib/server-api.ts b/frontend/src/lib/server-api.ts index a15b198..dba6665 100644 --- a/frontend/src/lib/server-api.ts +++ b/frontend/src/lib/server-api.ts @@ -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(label: string, url: string): Promise { 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 { + const url = `${INTERNAL_API_BASE}/api/notebooks`; + return (await ssrFetch('fetchNotebookList', url)) ?? []; +} diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro index 1da3215..f08819e 100644 --- a/frontend/src/pages/index.astro +++ b/frontend/src/pages/index.astro @@ -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'; +} --- -
- -
-
-
-

SpiceBook

-

Notebook interface for SPICE circuit simulation

-
+ + + +
+
+

SpiceBook

+

+ Circuit Simulation Notebooks +

+

+ Write SPICE netlists, run ngspice simulations, and visualize waveforms in a single document. +

+ -
+
+ - - - + +
+
+
+ +

SPICE Simulation

+

+ Write netlists and run ngspice. Transient, AC, DC sweep, operating point. +

+
+
+ +

Waveform Plots

+

+ Results render as interactive plots below each netlist. +

+
+
+ +

Schematic Generation

+

+ Netlists produce SVG circuit diagrams automatically. +

+
+
+ +

Notebook Format

+

+ Markdown, SPICE, and Python cells in a single document. +

+
+
+
+ + +
+

Notebooks

+ +
+ + +
diff --git a/frontend/src/styles/homepage.css b/frontend/src/styles/homepage.css new file mode 100644 index 0000000..19df94f --- /dev/null +++ b/frontend/src/styles/homepage.css @@ -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; +}