266 lines
8.2 KiB
TypeScript
266 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type SearchResult = {
|
|
title: string;
|
|
href: string;
|
|
section: string;
|
|
snippet: string;
|
|
};
|
|
|
|
export function Search() {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
const navigate = useCallback(
|
|
(href: string) => {
|
|
setOpen(false);
|
|
setQuery("");
|
|
setResults([]);
|
|
router.push(href);
|
|
},
|
|
[router],
|
|
);
|
|
|
|
useEffect(() => {
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
e.preventDefault();
|
|
setOpen((prev) => !prev);
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
} else {
|
|
setQuery("");
|
|
setResults([]);
|
|
}
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
const q = query.trim();
|
|
if (!q) {
|
|
setResults([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
abortRef.current?.abort();
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
const timeout = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
|
|
signal: controller.signal,
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setResults(data.results);
|
|
}
|
|
} catch {
|
|
// aborted or network error
|
|
} finally {
|
|
if (!controller.signal.aborted) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, 150);
|
|
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
controller.abort();
|
|
};
|
|
}, [query]);
|
|
|
|
useEffect(() => {
|
|
setActiveIndex(0);
|
|
}, [results]);
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
} else if (e.key === "Enter" && results[activeIndex]) {
|
|
e.preventDefault();
|
|
navigate(results[activeIndex].href);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const active = listRef.current?.querySelector("[data-active='true']");
|
|
active?.scrollIntoView({ block: "nearest" });
|
|
}, [activeIndex]);
|
|
|
|
const hasQuery = query.trim().length > 0;
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="hidden sm:flex items-center gap-2 rounded-md border border-border/50 bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:border-foreground/25 transition-colors"
|
|
>
|
|
<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"
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
Search docs
|
|
<kbd className="pointer-events-none ml-1 inline-flex items-center gap-0.5 rounded border border-border/50 bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
<span>⌘</span>K
|
|
</kbd>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="sm:hidden flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
|
aria-label="Search docs"
|
|
>
|
|
<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>
|
|
</button>
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent showCloseButton={false} className="gap-0 p-0 sm:max-w-lg">
|
|
<DialogTitle className="sr-only">Search documentation</DialogTitle>
|
|
<div className="flex items-center gap-2 border-b border-border/50 px-3">
|
|
<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"
|
|
className="shrink-0 text-muted-foreground"
|
|
>
|
|
<circle cx="11" cy="11" r="8" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Search docs..."
|
|
className="flex-1 bg-transparent py-3 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>
|
|
|
|
<div
|
|
ref={listRef}
|
|
className="max-h-[min(60vh,400px)] overflow-y-auto p-2"
|
|
>
|
|
{loading && hasQuery ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
|
</div>
|
|
) : hasQuery && results.length === 0 ? (
|
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
No results found.
|
|
</p>
|
|
) : !hasQuery ? (
|
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
Type to search documentation...
|
|
</p>
|
|
) : (
|
|
results.map((item, i) => (
|
|
<button
|
|
key={item.href}
|
|
data-active={i === activeIndex}
|
|
onClick={() => navigate(item.href)}
|
|
onMouseEnter={() => setActiveIndex(i)}
|
|
className={cn(
|
|
"flex w-full flex-col gap-1 rounded-md px-3 py-2 text-left transition-colors",
|
|
i === activeIndex
|
|
? "bg-muted text-foreground"
|
|
: "text-foreground",
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium">{item.title}</span>
|
|
{item.section && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{item.section}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.snippet && (
|
|
<span className="line-clamp-2 text-xs text-muted-foreground leading-relaxed">
|
|
{item.snippet}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|