From 269ec0253c3e5e5fcab940e31b207259add6b02c Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 13 Feb 2026 06:21:06 -0700 Subject: [PATCH] Add interactive topic filtering and Cmd+K search --- site/package-lock.json | 55 ++++++ site/package.json | 1 + site/src/components/FilterableBookGrid.tsx | 167 ++++++++++++++++++ site/src/components/SearchDialog.tsx | 195 +++++++++++++++++++++ site/src/components/ui/dialog.tsx | 121 +++++++++++++ site/src/layouts/Layout.astro | 7 + site/src/lib/types.ts | 29 +++ site/src/pages/mims/index.astro | 17 +- site/src/pages/uglys/index.astro | 19 +- 9 files changed, 585 insertions(+), 26 deletions(-) create mode 100644 site/src/components/FilterableBookGrid.tsx create mode 100644 site/src/components/SearchDialog.tsx create mode 100644 site/src/components/ui/dialog.tsx create mode 100644 site/src/lib/types.ts diff --git a/site/package-lock.json b/site/package-lock.json index b9a72b1..1afef59 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@astrojs/react": "^4.4.2", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", @@ -1756,6 +1757,60 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", diff --git a/site/package.json b/site/package.json index d9bd10f..3e8d37a 100644 --- a/site/package.json +++ b/site/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@astrojs/react": "^4.4.2", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/site/src/components/FilterableBookGrid.tsx b/site/src/components/FilterableBookGrid.tsx new file mode 100644 index 0000000..b3ef068 --- /dev/null +++ b/site/src/components/FilterableBookGrid.tsx @@ -0,0 +1,167 @@ +import { useState, useEffect } from 'react'; +import { Badge } from '@/components/ui/badge'; +import type { BookData } from '@/lib/types'; + +interface FilterableBookGridProps { + books: BookData[]; + allTopics: string[]; + collectionSlug: string; +} + +export default function FilterableBookGrid({ books, allTopics, collectionSlug }: FilterableBookGridProps) { + const [selectedTopics, setSelectedTopics] = useState>(new Set()); + + // Read URL params on mount + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const topicsParam = params.get('topics'); + if (topicsParam) { + setSelectedTopics(new Set(topicsParam.split(',').filter(Boolean))); + } + }, []); + + // Sync to URL + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (selectedTopics.size > 0) { + params.set('topics', Array.from(selectedTopics).join(',')); + } else { + params.delete('topics'); + } + const newUrl = params.toString() + ? `${window.location.pathname}?${params}` + : window.location.pathname; + history.replaceState(null, '', newUrl); + }, [selectedTopics]); + + const toggleTopic = (topic: string) => { + setSelectedTopics((prev) => { + const next = new Set(prev); + if (next.has(topic)) { + next.delete(topic); + } else { + next.add(topic); + } + return next; + }); + }; + + const clearFilters = () => setSelectedTopics(new Set()); + + // OR filtering: show books matching ANY selected topic + const filteredBooks = selectedTopics.size === 0 + ? books + : books.filter((book) => book.topics.some((t) => selectedTopics.has(t))); + + return ( +
+ {/* Topic filter bar */} +
+ {allTopics.map((topic) => { + const isActive = selectedTopics.has(topic); + return ( + + ); + })} + {selectedTopics.size > 0 && ( + + )} +
+ + {/* Count */} + {selectedTopics.size > 0 && ( +

+ Showing {filteredBooks.length} of {books.length} {books.length === 1 ? 'book' : 'books'} +

+ )} + + {/* Book grid */} +
+ {filteredBooks.map((book) => ( + +
+ {book.coverImage ? ( + {`Cover + ) : ( +
+ + + + +
+ )} + {book.year && ( +
+ {book.year} +
+ )} +
+
+
+

+ {book.shortTitle} +

+

+ {book.description} +

+
+
+ {book.topics.slice(0, 3).map((topic) => ( + + {topic.replace(/-/g, ' ')} + + ))} +
+
+ + + + + + PDF + + View → +
+
+
+ ))} +
+ + {filteredBooks.length === 0 && ( +
+

No books match the selected topics

+ +
+ )} +
+ ); +} diff --git a/site/src/components/SearchDialog.tsx b/site/src/components/SearchDialog.tsx new file mode 100644 index 0000000..77e3397 --- /dev/null +++ b/site/src/components/SearchDialog.tsx @@ -0,0 +1,195 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import type { BookData } from '@/lib/types'; + +interface SearchDialogProps { + books: BookData[]; +} + +export default function SearchDialog({ books }: SearchDialogProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Cmd+K / Ctrl+K to open + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, []); + + // Focus input on open + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 0); + setQuery(''); + setSelectedIndex(0); + } + }, [open]); + + const filtered = query.trim() + ? books.filter((book) => { + const q = query.toLowerCase(); + return ( + book.title.toLowerCase().includes(q) || + book.shortTitle.toLowerCase().includes(q) || + book.description.toLowerCase().includes(q) || + book.topics.some((t) => t.toLowerCase().includes(q)) + ); + }) + : books; + + // Reset index when query changes + useEffect(() => { + setSelectedIndex(0); + }, [query]); + + const navigate = useCallback((book: BookData) => { + setOpen(false); + window.location.href = `/${book.collection}/${book.slug}`; + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' && filtered[selectedIndex]) { + e.preventDefault(); + navigate(filtered[selectedIndex]); + } + }; + + // Scroll selected into view + useEffect(() => { + const list = listRef.current; + if (!list) return; + const selected = list.children[selectedIndex] as HTMLElement | undefined; + selected?.scrollIntoView({ block: 'nearest' }); + }, [selectedIndex]); + + return ( + <> + {/* Trigger button */} + + + + + Search books + {/* Search input */} +
+ + + + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + {query && ( + + )} +
+ + {/* Results */} +
+ {filtered.length === 0 ? ( +
+ No results found for "{query}" +
+ ) : ( + filtered.map((book, index) => ( + + )) + )} +
+ + {/* Footer hints */} +
+ + ↑↓ + Navigate + + + + Open + + + Esc + Close + +
+
+
+ + ); +} diff --git a/site/src/components/ui/dialog.tsx b/site/src/components/ui/dialog.tsx new file mode 100644 index 0000000..23b5771 --- /dev/null +++ b/site/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" +import { cn } from "@/lib/utils" + +function Dialog({ ...props }: React.ComponentProps) { + return +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return +} + +function DialogClose({ ...props }: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/site/src/layouts/Layout.astro b/site/src/layouts/Layout.astro index 7ac7e94..34fa0ef 100644 --- a/site/src/layouts/Layout.astro +++ b/site/src/layouts/Layout.astro @@ -1,5 +1,8 @@ --- import '@/styles/global.css'; +import { getCollection } from 'astro:content'; +import { serializeBook } from '@/lib/types'; +import SearchDialog from '@/components/SearchDialog'; interface Props { title: string; @@ -7,6 +10,9 @@ interface Props { } const { title, description = "Classic electronics reference notebooks from Forrest M. Mims III" } = Astro.props; + +const allBooks = await getCollection('books'); +const allBooksData = allBooks.map(serializeBook); --- @@ -48,6 +54,7 @@ const { title, description = "Classic electronics reference notebooks from Forre > Ugly's + ): BookData { + return { + slug: entry.slug.split('/').pop()!, + collection: entry.data.collection, + title: entry.data.title, + shortTitle: entry.data.shortTitle, + description: entry.data.description, + topics: entry.data.topics, + localPdf: entry.data.localPdf, + coverImage: entry.data.coverImage, + year: entry.data.year, + sortOrder: entry.data.sortOrder, + }; +} diff --git a/site/src/pages/mims/index.astro b/site/src/pages/mims/index.astro index 6107b2d..a014396 100644 --- a/site/src/pages/mims/index.astro +++ b/site/src/pages/mims/index.astro @@ -1,7 +1,8 @@ --- import Layout from '@/layouts/Layout.astro'; -import BookGrid from '@/components/BookGrid.astro'; +import FilterableBookGrid from '@/components/FilterableBookGrid'; import { getCollection } from 'astro:content'; +import { serializeBook } from '@/lib/types'; const allBooks = await getCollection('books'); const mimsBooks = allBooks @@ -10,6 +11,7 @@ const mimsBooks = allBooks // Get unique topics for filtering const allTopics = [...new Set(mimsBooks.flatMap(book => book.data.topics))].sort(); +const serializedBooks = mimsBooks.map(serializeBook); --- book.data.topics))].sort
- -
- {allTopics.map((topic) => ( - - {topic.replace(/-/g, ' ')} - - ))} -
- - - + +
diff --git a/site/src/pages/uglys/index.astro b/site/src/pages/uglys/index.astro index 9b1de86..83b6280 100644 --- a/site/src/pages/uglys/index.astro +++ b/site/src/pages/uglys/index.astro @@ -1,7 +1,8 @@ --- import Layout from '@/layouts/Layout.astro'; -import BookGrid from '@/components/BookGrid.astro'; +import FilterableBookGrid from '@/components/FilterableBookGrid'; import { getCollection } from 'astro:content'; +import { serializeBook } from '@/lib/types'; const allBooks = await getCollection('books'); const uglysBooks = allBooks @@ -10,6 +11,7 @@ const uglysBooks = allBooks // Get unique topics for filtering const allTopics = [...new Set(uglysBooks.flatMap(book => book.data.topics))].sort(); +const serializedBooks = uglysBooks.map(serializeBook); --- book.data.topics))].sor - - {allTopics.length > 0 && ( -
- {allTopics.map((topic) => ( - - {topic.replace(/-/g, ' ')} - - ))} -
- )} - - - + +