| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- "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 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 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 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-accent text-accent-foreground"
- : "text-foreground",
- )}
- >
- <div className="flex items-center justify-between gap-2">
- <span className="text-sm font-medium">{item.title}</span>
- <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>
- </>
- );
- }
|