Add interactive topic filtering and Cmd+K search
This commit is contained in:
parent
287debeafc
commit
269ec0253c
55
site/package-lock.json
generated
55
site/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
167
site/src/components/FilterableBookGrid.tsx
Normal file
167
site/src/components/FilterableBookGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
site/src/components/SearchDialog.tsx
Normal file
195
site/src/components/SearchDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
site/src/components/ui/dialog.tsx
Normal file
121
site/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
@ -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);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@ -48,6 +54,7 @@ const { title, description = "Classic electronics reference notebooks from Forre
|
||||
>
|
||||
Ugly's
|
||||
</a>
|
||||
<SearchDialog books={allBooksData} client:load />
|
||||
<a
|
||||
href="https://archive.org"
|
||||
target="_blank"
|
||||
|
||||
29
site/src/lib/types.ts
Normal file
29
site/src/lib/types.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user