Openstatus www.openstatus.dev
at main 465 lines 13 kB view raw
1"use client"; 2 3import type { MDXData } from "@/content/utils"; 4import { useDebounce } from "@/hooks/use-debounce"; 5import { cn } from "@/lib/utils"; 6import { 7 Command, 8 CommandEmpty, 9 CommandGroup, 10 CommandItem, 11 CommandList, 12 CommandLoading, 13 CommandSeparator, 14 CommandShortcut, 15 Dialog, 16 DialogContent, 17 DialogTitle, 18} from "@openstatus/ui"; 19import { useQuery } from "@tanstack/react-query"; 20import { Command as CommandPrimitive } from "cmdk"; 21import { Loader2, Search } from "lucide-react"; 22import { useTheme } from "next-themes"; 23import { useRouter } from "next/navigation"; 24import * as React from "react"; 25 26type ConfigItem = { 27 type: "item"; 28 label: string; 29 href: string; 30 shortcut?: string; 31}; 32 33type ConfigGroup = { 34 type: "group"; 35 label: string; 36 heading: string; 37 page: string; 38}; 39 40type ConfigSection = { 41 type: "group"; 42 heading: string; 43 items: (ConfigItem | ConfigGroup)[]; 44}; 45 46// TODO: missing shortcuts 47const CONFIG: ConfigSection[] = [ 48 { 49 type: "group", 50 heading: "Resources", 51 items: [ 52 { 53 type: "group", 54 label: "Search in all pages...", 55 heading: "All pages", 56 page: "all", 57 }, 58 { 59 type: "item", 60 label: "Go to Home", 61 href: "/", 62 }, 63 { 64 type: "item", 65 label: "Go to Pricing", 66 href: "/pricing", 67 }, 68 { 69 type: "item", 70 label: "Go to Docs", 71 href: "https://docs.openstatus.dev", 72 }, 73 { 74 type: "item", 75 label: "Go to Global Speed Checker", 76 href: "/play/checker", 77 shortcut: "⌘G", 78 }, 79 { 80 type: "group", 81 label: "Search in Products...", 82 heading: "Products", 83 page: "product", 84 }, 85 { 86 type: "group", 87 label: "Search in Blog...", 88 heading: "Blog", 89 page: "blog", 90 }, 91 { 92 type: "group", 93 label: "Search in Changelog...", 94 heading: "Changelog", 95 page: "changelog", 96 }, 97 { 98 type: "group", 99 label: "Search in Tools...", 100 heading: "Tools", 101 page: "tools", 102 }, 103 { 104 type: "group", 105 label: "Search in Compare...", 106 heading: "Compare", 107 page: "compare", 108 }, 109 { 110 type: "group", 111 label: "Search in Guides...", 112 heading: "Guides", 113 page: "guides", 114 }, 115 { 116 type: "item", 117 label: "Go to About", 118 href: "/about", 119 }, 120 { 121 type: "item", 122 label: "Book a call", 123 href: "/cal", 124 }, 125 ], 126 }, 127 128 { 129 type: "group", 130 heading: "Community", 131 items: [ 132 { 133 type: "item", 134 label: "Discord", 135 href: "/discord", 136 }, 137 { 138 type: "item", 139 label: "GitHub", 140 href: "/github", 141 }, 142 { 143 type: "item", 144 label: "X", 145 href: "/x", 146 }, 147 { 148 type: "item", 149 label: "BlueSky", 150 href: "/bluesky", 151 }, 152 { 153 type: "item", 154 label: "YouTube", 155 href: "/youtube", 156 }, 157 { 158 type: "item", 159 label: "LinkedIn", 160 href: "/linkedin", 161 }, 162 ], 163 }, 164]; 165 166export function CmdK() { 167 const [open, setOpen] = React.useState(false); 168 const inputRef = React.useRef<HTMLInputElement | null>(null); 169 const listRef = React.useRef<HTMLDivElement | null>(null); 170 const resetTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined); 171 const [search, setSearch] = React.useState(""); 172 const [pages, setPages] = React.useState<string[]>([]); 173 const debouncedSearch = useDebounce(search, 300); 174 const router = useRouter(); 175 176 const page = pages.length > 0 ? pages[pages.length - 1] : null; 177 178 const { 179 data: items = [], 180 isLoading: loading, 181 isFetching: fetching, 182 } = useQuery({ 183 queryKey: ["search", page, debouncedSearch], 184 queryFn: async () => { 185 if (!page) return []; 186 const searchParams = new URLSearchParams(); 187 searchParams.set("p", page); 188 if (debouncedSearch) searchParams.set("q", debouncedSearch); 189 const promise = fetch(`/api/search?${searchParams.toString()}`); 190 // NOTE: artificial delay to avoid flickering 191 const delay = new Promise((r) => setTimeout(r, 300)); 192 const [res, _] = await Promise.all([promise, delay]); 193 return res.json(); 194 }, 195 placeholderData: (previousData) => previousData, 196 }); 197 198 const resetSearch = React.useCallback(() => setSearch(""), []); 199 200 React.useEffect(() => { 201 const down = (e: KeyboardEvent) => { 202 if (e.key === "k" && (e.metaKey || e.ctrlKey)) { 203 e.preventDefault(); 204 setOpen((open) => !open); 205 } 206 207 // Handle shortcuts when dialog is open 208 if (open && (e.metaKey || e.ctrlKey)) { 209 const key = e.key.toLowerCase(); 210 211 // Find matching shortcut in CONFIG 212 for (const section of CONFIG) { 213 for (const item of section.items) { 214 if (item.type === "item" && item.shortcut) { 215 const shortcutKey = item.shortcut.replace("⌘", "").toLowerCase(); 216 if (key === shortcutKey) { 217 e.preventDefault(); 218 router.push(item.href); 219 setOpen(false); 220 return; 221 } 222 } 223 } 224 } 225 } 226 }; 227 228 document.addEventListener("keydown", down); 229 return () => document.removeEventListener("keydown", down); 230 }, [open, router]); 231 232 React.useEffect(() => { 233 inputRef.current?.focus(); 234 }, []); 235 236 // NOTE: Reset search and pages after dialog closes (with delay for animation) 237 // - if within 1 second of closing, the dialog will not reset 238 React.useEffect(() => { 239 const DELAY = 1000; 240 241 if (!open && items.length > 0) { 242 if (resetTimerRef.current) { 243 clearTimeout(resetTimerRef.current); 244 } 245 resetTimerRef.current = setTimeout(() => { 246 setSearch(""); 247 setPages([]); 248 }, DELAY); 249 } 250 251 if (open && resetTimerRef.current) { 252 clearTimeout(resetTimerRef.current); 253 resetTimerRef.current = undefined; 254 } 255 256 return () => { 257 if (resetTimerRef.current) { 258 clearTimeout(resetTimerRef.current); 259 } 260 }; 261 }, [open, items.length]); 262 263 return ( 264 <> 265 <button 266 type="button" 267 className={cn( 268 "flex w-full items-center text-left hover:bg-muted", 269 open && "bg-muted!", 270 )} 271 onClick={() => setOpen(true)} 272 > 273 <span className="truncate text-muted-foreground"> 274 Search<span className="text-xs">...</span> 275 </span> 276 <kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-1 border bg-muted px-1.5 font-medium font-mono text-[10px] text-muted-foreground opacity-100"> 277 <span className="text-xs"></span>K 278 </kbd> 279 </button> 280 <Dialog open={open} onOpenChange={setOpen}> 281 <DialogContent className="top-[15%] translate-y-0 overflow-hidden rounded-none p-0 font-mono shadow-2xl"> 282 <DialogTitle className="sr-only">Search</DialogTitle> 283 <Command 284 onKeyDown={(e) => { 285 // e.key === "Escape" || 286 if (e.key === "Backspace" && !search) { 287 e.preventDefault(); 288 setPages((pages) => pages.slice(0, -1)); 289 } 290 }} 291 shouldFilter={!page} 292 className="rounded-none" 293 > 294 <div 295 className="flex items-center border-b px-3" 296 cmdk-input-wrapper="" 297 > 298 {loading || fetching ? ( 299 <Loader2 className="mr-2 h-4 w-4 shrink-0 animate-spin opacity-50" /> 300 ) : ( 301 <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> 302 )} 303 <CommandPrimitive.Input 304 className="flex h-11 w-full rounded-none bg-transparent py-3 text-sm outline-hidden placeholder:text-foreground-muted disabled:cursor-not-allowed disabled:opacity-50" 305 placeholder="Type to search…" 306 value={search} 307 onValueChange={setSearch} 308 /> 309 </div> 310 <CommandList ref={listRef} className="[&_[cmdk-item]]:rounded-none"> 311 {(loading || fetching) && page && !items.length ? ( 312 <CommandLoading>Searching...</CommandLoading> 313 ) : null} 314 {!(loading || fetching) ? ( 315 <CommandEmpty>No results found.</CommandEmpty> 316 ) : null} 317 {!page ? ( 318 <Home 319 setPages={setPages} 320 resetSearch={resetSearch} 321 setOpen={setOpen} 322 /> 323 ) : null} 324 {items.length > 0 ? ( 325 <SearchResults 326 items={items} 327 search={search} 328 setOpen={setOpen} 329 page={page} 330 /> 331 ) : null} 332 </CommandList> 333 </Command> 334 </DialogContent> 335 </Dialog> 336 </> 337 ); 338} 339 340function Home({ 341 setPages, 342 resetSearch, 343 setOpen, 344}: { 345 setPages: React.Dispatch<React.SetStateAction<string[]>>; 346 resetSearch: () => void; 347 setOpen: React.Dispatch<React.SetStateAction<boolean>>; 348}) { 349 const router = useRouter(); 350 const { resolvedTheme, setTheme } = useTheme(); 351 352 return ( 353 <> 354 {CONFIG.map((group, groupIndex) => ( 355 <React.Fragment key={group.heading}> 356 {groupIndex > 0 && <CommandSeparator />} 357 <CommandGroup heading={group.heading}> 358 {group.items.map((item) => { 359 if (item.type === "item") { 360 return ( 361 <CommandItem 362 key={item.label} 363 onSelect={() => { 364 router.push(item.href); 365 setOpen(false); 366 }} 367 > 368 <span>{item.label}</span> 369 {item.shortcut && ( 370 <CommandShortcut>{item.shortcut}</CommandShortcut> 371 )} 372 </CommandItem> 373 ); 374 } 375 if (item.type === "group") { 376 return ( 377 <CommandItem 378 key={item.page} 379 onSelect={() => { 380 setPages((pages) => [...pages, item.page]); 381 resetSearch(); 382 }} 383 > 384 <span>{item.label}</span> 385 </CommandItem> 386 ); 387 } 388 return null; 389 })} 390 </CommandGroup> 391 </React.Fragment> 392 ))} 393 <CommandSeparator /> 394 <CommandGroup heading="Settings"> 395 <CommandItem 396 onSelect={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")} 397 > 398 <span> 399 Switch to {resolvedTheme === "dark" ? "light" : "dark"} theme 400 </span> 401 </CommandItem> 402 </CommandGroup> 403 </> 404 ); 405} 406 407function SearchResults({ 408 items, 409 search, 410 setOpen, 411 page, 412}: { 413 items: MDXData[]; 414 search: string; 415 setOpen: React.Dispatch<React.SetStateAction<boolean>>; 416 page: string | null; 417}) { 418 const router = useRouter(); 419 420 const _page = CONFIG[0].items.find( 421 (item) => item.type === "group" && item.page === page, 422 ) as ConfigGroup | undefined; 423 424 return ( 425 <CommandGroup heading={_page?.heading ?? "Search Results"}> 426 {items.map((item) => { 427 // Highlight search term match in the title, case-insensitive 428 const title = item.metadata.title.replace( 429 new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), 430 (match) => `<mark>${match}</mark>`, 431 ); 432 const html = item.content.replace( 433 new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), 434 (match) => `<mark>${match}</mark>`, 435 ); 436 437 return ( 438 <CommandItem 439 key={item.slug} 440 keywords={[item.metadata.title, item.content, search]} 441 onSelect={() => { 442 router.push(item.href); 443 setOpen(false); 444 }} 445 > 446 <div className="grid min-w-0"> 447 <span 448 className="block truncate" 449 // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 450 dangerouslySetInnerHTML={{ __html: title }} 451 /> 452 {item.content && search ? ( 453 <span 454 className="block truncate text-muted-foreground text-xs" 455 // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 456 dangerouslySetInnerHTML={{ __html: html }} 457 /> 458 ) : null} 459 </div> 460 </CommandItem> 461 ); 462 })} 463 </CommandGroup> 464 ); 465}