Merge feature/filtering-search: topic filtering, Cmd+K search

# Conflicts:
#	site/src/layouts/Layout.astro
This commit is contained in:
Ryan Malloy 2026-02-13 06:22:02 -07:00
commit 717bf83459
9 changed files with 585 additions and 26 deletions

55
site/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<Set<string>>(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 (
<div className="space-y-6">
{/* Topic filter bar */}
<div className="flex flex-wrap items-center gap-2">
{allTopics.map((topic) => {
const isActive = selectedTopics.has(topic);
return (
<button
key={topic}
onClick={() => toggleTopic(topic)}
className={`inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
isActive
? 'bg-primary text-primary-foreground border-transparent'
: 'bg-secondary text-secondary-foreground border-transparent hover:bg-secondary/80'
}`}
>
{topic.replace(/-/g, ' ')}
</button>
);
})}
{selectedTopics.size > 0 && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1 rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
Clear filters
</button>
)}
</div>
{/* Count */}
{selectedTopics.size > 0 && (
<p className="text-sm text-muted-foreground">
Showing {filteredBooks.length} of {books.length} {books.length === 1 ? 'book' : 'books'}
</p>
)}
{/* Book grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredBooks.map((book) => (
<a
key={book.slug}
href={`/${book.collection}/${book.slug}`}
className="book-card block bg-card rounded-lg border border-border overflow-hidden hover:border-primary/50"
>
<div className="relative">
{book.coverImage ? (
<img
src={book.coverImage}
alt={`Cover of ${book.shortTitle}`}
className="w-full aspect-[3/4] object-cover object-top"
loading="lazy"
/>
) : (
<div className="w-full aspect-[3/4] bg-muted flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
</div>
)}
{book.year && (
<div className="absolute top-2 right-2 bg-card/90 backdrop-blur-sm text-xs font-medium px-2 py-1 rounded border border-border">
{book.year}
</div>
)}
</div>
<div className="p-4 space-y-3">
<div>
<h3 className="font-semibold text-foreground line-clamp-2 leading-tight" style={{ fontFamily: "Georgia, 'Times New Roman', serif", letterSpacing: '-0.02em' }}>
{book.shortTitle}
</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{book.description}
</p>
</div>
<div className="flex flex-wrap gap-1">
{book.topics.slice(0, 3).map((topic) => (
<Badge key={topic} variant="secondary" className="text-xs">
{topic.replace(/-/g, ' ')}
</Badge>
))}
</div>
<div className="pt-2 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
PDF
</span>
<span className="text-primary font-medium">View </span>
</div>
</div>
</a>
))}
</div>
{filteredBooks.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<p className="text-lg font-medium">No books match the selected topics</p>
<button onClick={clearFilters} className="mt-2 text-primary hover:underline text-sm cursor-pointer">
Clear filters
</button>
</div>
)}
</div>
);
}

View File

@ -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<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(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 */}
<button
onClick={() => setOpen(true)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
aria-label="Search books"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<span className="hidden md:inline">Search</span>
<kbd className="hidden md:inline-flex items-center gap-0.5 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>K
</kbd>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl p-0 gap-0 overflow-hidden">
<DialogTitle className="sr-only">Search books</DialogTitle>
{/* Search input */}
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground shrink-0">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={inputRef}
type="text"
placeholder="Search books, topics..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
{query && (
<button
onClick={() => setQuery('')}
className="text-muted-foreground hover:text-foreground"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
{/* Results */}
<div ref={listRef} className="max-h-80 overflow-y-auto p-2">
{filtered.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No results found for "{query}"
</div>
) : (
filtered.map((book, index) => (
<button
key={book.slug}
onClick={() => navigate(book)}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors cursor-pointer ${
index === selectedIndex ? 'bg-accent text-accent-foreground' : ''
}`}
>
{book.coverImage ? (
<img
src={book.coverImage}
alt=""
className="w-10 h-13 object-cover rounded border border-border shrink-0"
/>
) : (
<div className="w-10 h-13 bg-muted rounded border border-border flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-muted-foreground">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{book.shortTitle}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
{book.collection === 'mims' ? 'Mims' : "Ugly's"}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{book.description}</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground shrink-0">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
))
)}
</div>
{/* Footer hints */}
<div className="border-t border-border px-4 py-2 flex items-center gap-4 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded border border-border bg-muted text-[10px]"></kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded border border-border bg-muted text-[10px]"></kbd>
Open
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded border border-border bg-muted text-[10px]">Esc</kbd>
Close
</span>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -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<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -1,6 +1,9 @@
---
import '@/styles/global.css';
import ThemeToggle from '@/components/ThemeToggle';
import { getCollection } from 'astro:content';
import { serializeBook } from '@/lib/types';
import SearchDialog from '@/components/SearchDialog';
interface Props {
title: string;
@ -8,6 +11,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);
---
<!doctype html>
@ -61,6 +67,7 @@ const { title, description = "Classic electronics reference notebooks from Forre
>
Ugly's
</a>
<SearchDialog books={allBooksData} client:load />
<ThemeToggle client:load />
<a
href="https://archive.org"

29
site/src/lib/types.ts Normal file
View File

@ -0,0 +1,29 @@
import type { CollectionEntry } from 'astro:content';
export interface BookData {
slug: string;
collection: 'mims' | 'uglys' | 'other';
title: string;
shortTitle: string;
description: string;
topics: string[];
localPdf: string;
coverImage?: string;
year?: number;
sortOrder: number;
}
export function serializeBook(entry: CollectionEntry<'books'>): 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,
};
}

View File

@ -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);
---
<Layout
@ -48,17 +50,8 @@ const allTopics = [...new Set(mimsBooks.flatMap(book => book.data.topics))].sort
</div>
</div>
<!-- Topic tags -->
<div class="flex flex-wrap gap-2">
{allTopics.map((topic) => (
<span class="px-3 py-1 text-xs font-medium rounded-full bg-muted text-muted-foreground">
{topic.replace(/-/g, ' ')}
</span>
))}
</div>
<!-- Grid -->
<BookGrid books={mimsBooks} />
<!-- Filterable grid with topic tags -->
<FilterableBookGrid books={serializedBooks} allTopics={allTopics} collectionSlug="mims" client:load />
<!-- About the Author -->
<section class="mt-12 p-6 md:p-8 rounded-lg bg-card border border-border space-y-8">

View File

@ -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);
---
<Layout
@ -48,19 +50,8 @@ const allTopics = [...new Set(uglysBooks.flatMap(book => book.data.topics))].sor
</div>
</div>
<!-- Topic tags -->
{allTopics.length > 0 && (
<div class="flex flex-wrap gap-2">
{allTopics.map((topic) => (
<span class="px-3 py-1 text-xs font-medium rounded-full bg-muted text-muted-foreground">
{topic.replace(/-/g, ' ')}
</span>
))}
</div>
)}
<!-- Grid -->
<BookGrid books={uglysBooks} />
<!-- Filterable grid with topic tags -->
<FilterableBookGrid books={serializedBooks} allTopics={allTopics} collectionSlug="uglys" client:load />
<!-- Info box -->
<div class="mt-12 p-6 rounded-lg bg-card border border-border">