[Feature] Add site-wide search (⌘K command palette) #2
No files matched your search
@@ -0,0 +1,8 @@
|
||||
import { getSearchIndex } from "@/lib/search";
|
||||
|
||||
// Static index built from config + blog posts at build time.
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return Response.json(getSearchIndex());
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { siteConfig } from "@/lib/config";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { SiteSearch } from "./SiteSearch";
|
||||
|
||||
export function GlassNav() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -85,6 +86,7 @@ export function GlassNav() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<SiteSearch />
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { searchDocs, type SearchDoc } from "@/lib/search-filter";
|
||||
|
||||
export function SiteSearch() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [index, setIndex] = useState<SearchDoc[] | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const results = index ? searchDocs(index, query).slice(0, 8) : [];
|
||||
|
||||
// Resetting on close keeps open trivial and avoids setState-in-effect.
|
||||
const closeSearch = useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
}, []);
|
||||
|
||||
// Fetch the index once, on first open.
|
||||
useEffect(() => {
|
||||
if (open && index === null) {
|
||||
fetch("/api/search")
|
||||
.then((r) => r.json())
|
||||
.then(setIndex)
|
||||
.catch(() => setIndex([]));
|
||||
}
|
||||
}, [open, index]);
|
||||
|
||||
// Global ⌘K / Ctrl+K toggle.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
if (open) closeSearch();
|
||||
else setOpen(true);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, closeSearch]);
|
||||
|
||||
// Lock scroll + focus the input while open (DOM side-effects only).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [open]);
|
||||
|
||||
const go = useCallback(
|
||||
(doc: SearchDoc) => {
|
||||
closeSearch();
|
||||
if (doc.url.startsWith("http")) window.open(doc.url, "_blank", "noopener");
|
||||
else router.push(doc.url);
|
||||
},
|
||||
[router, closeSearch],
|
||||
);
|
||||
|
||||
function onInputKey(e: React.KeyboardEvent) {
|
||||
if (e.key === "Escape") closeSearch();
|
||||
else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
} else if (e.key === "Enter" && results[active]) {
|
||||
e.preventDefault();
|
||||
go(results[active]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex h-9 items-center gap-2 rounded-full border border-border px-3 text-sm text-secondary transition-colors hover:text-primary"
|
||||
aria-label="Search site"
|
||||
>
|
||||
<SearchIcon />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<kbd className="hidden rounded border border-border px-1.5 text-xs text-muted md:inline">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/40 px-4 pt-[15vh] backdrop-blur-sm"
|
||||
onClick={closeSearch}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Site search"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -12, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full max-w-xl overflow-hidden rounded-2xl border border-glass-border bg-glass-bg shadow-2xl shadow-glass-shadow backdrop-blur-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-4">
|
||||
<SearchIcon />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onInputKey}
|
||||
placeholder="Search projects, posts, stack…"
|
||||
className="w-full bg-transparent py-4 text-primary placeholder:text-muted focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{query && (
|
||||
<ul className="max-h-[50vh] overflow-y-auto p-2">
|
||||
{results.length === 0 && (
|
||||
<li className="px-3 py-6 text-center text-sm text-muted">
|
||||
No results for “{query}”
|
||||
</li>
|
||||
)}
|
||||
{results.map((doc, i) => (
|
||||
<li key={`${doc.type}-${doc.url}-${doc.title}`}>
|
||||
<button
|
||||
onClick={() => go(doc)}
|
||||
onMouseMove={() => setActive(i)}
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left transition-colors ${
|
||||
i === active ? "bg-accent-subtle" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-primary">
|
||||
{doc.title}
|
||||
</span>
|
||||
{doc.description && (
|
||||
<span className="block truncate text-xs text-muted">
|
||||
{doc.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent">
|
||||
{doc.type}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Client-safe: no fs/server imports, so it can be bundled into the browser.
|
||||
|
||||
export interface SearchDoc {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
type: "Page" | "Project" | "Blog" | "Stack" | "Feed";
|
||||
// lowercased haystack used for matching — not displayed
|
||||
haystack: string;
|
||||
}
|
||||
|
||||
// Every whitespace-separated token must appear somewhere in the doc;
|
||||
// title matches rank above body-only matches.
|
||||
export function searchDocs(index: SearchDoc[], query: string): SearchDoc[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const tokens = q.split(/\s+/);
|
||||
|
||||
return index
|
||||
.filter((d) => tokens.every((t) => d.haystack.includes(t)))
|
||||
.sort((a, b) => {
|
||||
const at = a.title.toLowerCase().includes(q) ? 0 : 1;
|
||||
const bt = b.title.toLowerCase().includes(q) ? 0 : 1;
|
||||
return at - bt;
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
const idx: SearchDoc[] = [
|
||||
{ title: "Stack", description: "homelab", url: "/stack", type: "Page", haystack: "stack homelab" },
|
||||
{ title: "Hello World", description: "first post", url: "/blog/hello", type: "Blog", haystack: "hello world first post nextjs shaders" },
|
||||
];
|
||||
console.assert(searchDocs(idx, "").length === 0, "empty query → no results");
|
||||
console.assert(searchDocs(idx, "shaders")[0].title === "Hello World", "matches body text");
|
||||
console.assert(searchDocs(idx, "stack")[0].title === "Stack", "title match ranks first");
|
||||
console.assert(searchDocs(idx, "nextjs missing").length === 0, "all tokens must match");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { siteConfig } from "@/lib/config";
|
||||
import { getAllPosts, getPostBySlug } from "@/lib/blog";
|
||||
import type { SearchDoc } from "@/lib/search-filter";
|
||||
|
||||
// Builds the full searchable index from config + blog posts. Small site, so
|
||||
// the whole index is shipped once and filtered on the client (see search-filter).
|
||||
// ponytail: substring/token match, no search dependency. Add fuse.js only if
|
||||
// fuzzy/typo tolerance is ever actually requested.
|
||||
export function getSearchIndex(): SearchDoc[] {
|
||||
const docs: SearchDoc[] = [];
|
||||
const add = (d: Omit<SearchDoc, "haystack">, extra = "") =>
|
||||
docs.push({
|
||||
...d,
|
||||
haystack: `${d.title} ${d.description} ${extra}`.toLowerCase(),
|
||||
});
|
||||
|
||||
// Top-level pages / sections
|
||||
for (const item of siteConfig.nav) {
|
||||
add({ title: item.label, description: "", url: item.href, type: "Page" });
|
||||
}
|
||||
|
||||
// Projects
|
||||
for (const p of siteConfig.projects) {
|
||||
add(
|
||||
{
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
url: p.url || p.github || "/#projects",
|
||||
type: "Project",
|
||||
},
|
||||
p.tags.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
// Stack apps (only the ones shown)
|
||||
for (const s of siteConfig.stack) {
|
||||
if (!s.enabled) continue;
|
||||
add(
|
||||
{ title: s.name, description: s.description, url: "/stack", type: "Stack" },
|
||||
s.category,
|
||||
);
|
||||
}
|
||||
|
||||
// Interests / feed platforms (only the ones shown)
|
||||
for (const f of siteConfig.interests.platforms) {
|
||||
if (!f.enabled) continue;
|
||||
add({ title: f.name, description: "", url: "/interests", type: "Feed" });
|
||||
}
|
||||
|
||||
// Blog posts — full text (title + tags + body)
|
||||
for (const meta of getAllPosts()) {
|
||||
const post = getPostBySlug(meta.slug);
|
||||
add(
|
||||
{
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
url: `/blog/${meta.slug}`,
|
||||
type: "Blog",
|
||||
},
|
||||
`${meta.tags.join(" ")} ${post?.content ?? ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
Reference in new issue
Block a user