search.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. "use client";
  2. import { useCallback, useEffect, useRef, useState } from "react";
  3. import { useRouter } from "next/navigation";
  4. import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
  5. import { cn } from "@/lib/utils";
  6. type SearchResult = {
  7. title: string;
  8. href: string;
  9. section: string;
  10. snippet: string;
  11. };
  12. export function Search() {
  13. const router = useRouter();
  14. const [open, setOpen] = useState(false);
  15. const [query, setQuery] = useState("");
  16. const [results, setResults] = useState<SearchResult[]>([]);
  17. const [loading, setLoading] = useState(false);
  18. const [activeIndex, setActiveIndex] = useState(0);
  19. const inputRef = useRef<HTMLInputElement>(null);
  20. const listRef = useRef<HTMLDivElement>(null);
  21. const abortRef = useRef<AbortController | null>(null);
  22. const navigate = useCallback(
  23. (href: string) => {
  24. setOpen(false);
  25. setQuery("");
  26. setResults([]);
  27. router.push(href);
  28. },
  29. [router],
  30. );
  31. useEffect(() => {
  32. function onKeyDown(e: KeyboardEvent) {
  33. if ((e.metaKey || e.ctrlKey) && e.key === "k") {
  34. e.preventDefault();
  35. setOpen((prev) => !prev);
  36. }
  37. }
  38. document.addEventListener("keydown", onKeyDown);
  39. return () => document.removeEventListener("keydown", onKeyDown);
  40. }, []);
  41. useEffect(() => {
  42. if (open) {
  43. setTimeout(() => inputRef.current?.focus(), 0);
  44. } else {
  45. setQuery("");
  46. setResults([]);
  47. }
  48. }, [open]);
  49. useEffect(() => {
  50. const q = query.trim();
  51. if (!q) {
  52. setResults([]);
  53. setLoading(false);
  54. return;
  55. }
  56. setLoading(true);
  57. abortRef.current?.abort();
  58. const controller = new AbortController();
  59. abortRef.current = controller;
  60. const timeout = setTimeout(async () => {
  61. try {
  62. const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
  63. signal: controller.signal,
  64. });
  65. if (res.ok) {
  66. const data = await res.json();
  67. setResults(data.results);
  68. }
  69. } catch {
  70. // aborted or network error
  71. } finally {
  72. if (!controller.signal.aborted) {
  73. setLoading(false);
  74. }
  75. }
  76. }, 150);
  77. return () => {
  78. clearTimeout(timeout);
  79. controller.abort();
  80. };
  81. }, [query]);
  82. useEffect(() => {
  83. setActiveIndex(0);
  84. }, [results]);
  85. function handleKeyDown(e: React.KeyboardEvent) {
  86. if (e.key === "ArrowDown") {
  87. e.preventDefault();
  88. setActiveIndex((i) => Math.min(i + 1, results.length - 1));
  89. } else if (e.key === "ArrowUp") {
  90. e.preventDefault();
  91. setActiveIndex((i) => Math.max(i - 1, 0));
  92. } else if (e.key === "Enter" && results[activeIndex]) {
  93. e.preventDefault();
  94. navigate(results[activeIndex].href);
  95. }
  96. }
  97. useEffect(() => {
  98. const active = listRef.current?.querySelector("[data-active='true']");
  99. active?.scrollIntoView({ block: "nearest" });
  100. }, [activeIndex]);
  101. const hasQuery = query.trim().length > 0;
  102. return (
  103. <>
  104. <button
  105. onClick={() => setOpen(true)}
  106. 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"
  107. >
  108. <svg
  109. xmlns="http://www.w3.org/2000/svg"
  110. width="14"
  111. height="14"
  112. viewBox="0 0 24 24"
  113. fill="none"
  114. stroke="currentColor"
  115. strokeWidth="2"
  116. strokeLinecap="round"
  117. strokeLinejoin="round"
  118. >
  119. <circle cx="11" cy="11" r="8" />
  120. <path d="m21 21-4.3-4.3" />
  121. </svg>
  122. Search docs
  123. <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">
  124. <span>&#8984;</span>K
  125. </kbd>
  126. </button>
  127. <button
  128. onClick={() => setOpen(true)}
  129. className="sm:hidden flex items-center text-muted-foreground hover:text-foreground transition-colors"
  130. aria-label="Search docs"
  131. >
  132. <svg
  133. xmlns="http://www.w3.org/2000/svg"
  134. width="16"
  135. height="16"
  136. viewBox="0 0 24 24"
  137. fill="none"
  138. stroke="currentColor"
  139. strokeWidth="2"
  140. strokeLinecap="round"
  141. strokeLinejoin="round"
  142. >
  143. <circle cx="11" cy="11" r="8" />
  144. <path d="m21 21-4.3-4.3" />
  145. </svg>
  146. </button>
  147. <Dialog open={open} onOpenChange={setOpen}>
  148. <DialogContent
  149. showCloseButton={false}
  150. className="gap-0 p-0 sm:max-w-lg"
  151. >
  152. <DialogTitle className="sr-only">Search documentation</DialogTitle>
  153. <div className="flex items-center gap-2 border-b px-3">
  154. <svg
  155. xmlns="http://www.w3.org/2000/svg"
  156. width="16"
  157. height="16"
  158. viewBox="0 0 24 24"
  159. fill="none"
  160. stroke="currentColor"
  161. strokeWidth="2"
  162. strokeLinecap="round"
  163. strokeLinejoin="round"
  164. className="shrink-0 text-muted-foreground"
  165. >
  166. <circle cx="11" cy="11" r="8" />
  167. <path d="m21 21-4.3-4.3" />
  168. </svg>
  169. <input
  170. ref={inputRef}
  171. value={query}
  172. onChange={(e) => setQuery(e.target.value)}
  173. onKeyDown={handleKeyDown}
  174. placeholder="Search docs..."
  175. className="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
  176. />
  177. {query && (
  178. <button
  179. onClick={() => setQuery("")}
  180. className="text-muted-foreground hover:text-foreground"
  181. >
  182. <svg
  183. xmlns="http://www.w3.org/2000/svg"
  184. width="14"
  185. height="14"
  186. viewBox="0 0 24 24"
  187. fill="none"
  188. stroke="currentColor"
  189. strokeWidth="2"
  190. strokeLinecap="round"
  191. strokeLinejoin="round"
  192. >
  193. <path d="M18 6 6 18" />
  194. <path d="m6 6 12 12" />
  195. </svg>
  196. </button>
  197. )}
  198. </div>
  199. <div
  200. ref={listRef}
  201. className="max-h-[min(60vh,400px)] overflow-y-auto p-2"
  202. >
  203. {loading && hasQuery ? (
  204. <div className="flex items-center justify-center py-6">
  205. <div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
  206. </div>
  207. ) : hasQuery && results.length === 0 ? (
  208. <p className="py-6 text-center text-sm text-muted-foreground">
  209. No results found.
  210. </p>
  211. ) : !hasQuery ? (
  212. <p className="py-6 text-center text-sm text-muted-foreground">
  213. Type to search documentation...
  214. </p>
  215. ) : (
  216. results.map((item, i) => (
  217. <button
  218. key={item.href}
  219. data-active={i === activeIndex}
  220. onClick={() => navigate(item.href)}
  221. onMouseEnter={() => setActiveIndex(i)}
  222. className={cn(
  223. "flex w-full flex-col gap-1 rounded-md px-3 py-2 text-left transition-colors",
  224. i === activeIndex
  225. ? "bg-accent text-accent-foreground"
  226. : "text-foreground",
  227. )}
  228. >
  229. <div className="flex items-center justify-between gap-2">
  230. <span className="text-sm font-medium">{item.title}</span>
  231. <span className="shrink-0 text-xs text-muted-foreground">
  232. {item.section}
  233. </span>
  234. </div>
  235. {item.snippet && (
  236. <span className="line-clamp-2 text-xs text-muted-foreground leading-relaxed">
  237. {item.snippet}
  238. </span>
  239. )}
  240. </button>
  241. ))
  242. )}
  243. </div>
  244. </DialogContent>
  245. </Dialog>
  246. </>
  247. );
  248. }