[Feature] Add site-wide search (⌘K command palette) #2

Merged
iamdoubz merged 1 commits from feature_search into main 2026-06-26 08:56:50 -05:00
5 changed files with 296 additions and 0 deletions

No files matched your search

+8
View File
@@ -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());
}
+2
View File
@@ -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 */}
+184
View File
@@ -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>
);
}
+37
View File
@@ -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");
}
+65
View File
@@ -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;
}