Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 459 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: "item", 111 label: "Go to About", 112 href: "/about", 113 }, 114 { 115 type: "item", 116 label: "Book a call", 117 href: "/cal", 118 }, 119 ], 120 }, 121 122 { 123 type: "group", 124 heading: "Community", 125 items: [ 126 { 127 type: "item", 128 label: "Discord", 129 href: "/discord", 130 }, 131 { 132 type: "item", 133 label: "GitHub", 134 href: "/github", 135 }, 136 { 137 type: "item", 138 label: "X", 139 href: "/x", 140 }, 141 { 142 type: "item", 143 label: "BlueSky", 144 href: "/bluesky", 145 }, 146 { 147 type: "item", 148 label: "YouTube", 149 href: "/youtube", 150 }, 151 { 152 type: "item", 153 label: "LinkedIn", 154 href: "/linkedin", 155 }, 156 ], 157 }, 158]; 159 160export function CmdK() { 161 const [open, setOpen] = React.useState(false); 162 const inputRef = React.useRef<HTMLInputElement | null>(null); 163 const listRef = React.useRef<HTMLDivElement | null>(null); 164 const resetTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined); 165 const [search, setSearch] = React.useState(""); 166 const [pages, setPages] = React.useState<string[]>([]); 167 const debouncedSearch = useDebounce(search, 300); 168 const router = useRouter(); 169 170 const page = pages.length > 0 ? pages[pages.length - 1] : null; 171 172 const { 173 data: items = [], 174 isLoading: loading, 175 isFetching: fetching, 176 } = useQuery({ 177 queryKey: ["search", page, debouncedSearch], 178 queryFn: async () => { 179 if (!page) return []; 180 const searchParams = new URLSearchParams(); 181 searchParams.set("p", page); 182 if (debouncedSearch) searchParams.set("q", debouncedSearch); 183 const promise = fetch(`/api/search?${searchParams.toString()}`); 184 // NOTE: artificial delay to avoid flickering 185 const delay = new Promise((r) => setTimeout(r, 300)); 186 const [res, _] = await Promise.all([promise, delay]); 187 return res.json(); 188 }, 189 placeholderData: (previousData) => previousData, 190 }); 191 192 const resetSearch = React.useCallback(() => setSearch(""), []); 193 194 React.useEffect(() => { 195 const down = (e: KeyboardEvent) => { 196 if (e.key === "k" && (e.metaKey || e.ctrlKey)) { 197 e.preventDefault(); 198 setOpen((open) => !open); 199 } 200 201 // Handle shortcuts when dialog is open 202 if (open && (e.metaKey || e.ctrlKey)) { 203 const key = e.key.toLowerCase(); 204 205 // Find matching shortcut in CONFIG 206 for (const section of CONFIG) { 207 for (const item of section.items) { 208 if (item.type === "item" && item.shortcut) { 209 const shortcutKey = item.shortcut.replace("⌘", "").toLowerCase(); 210 if (key === shortcutKey) { 211 e.preventDefault(); 212 router.push(item.href); 213 setOpen(false); 214 return; 215 } 216 } 217 } 218 } 219 } 220 }; 221 222 document.addEventListener("keydown", down); 223 return () => document.removeEventListener("keydown", down); 224 }, [open, router]); 225 226 React.useEffect(() => { 227 inputRef.current?.focus(); 228 }, []); 229 230 // NOTE: Reset search and pages after dialog closes (with delay for animation) 231 // - if within 1 second of closing, the dialog will not reset 232 React.useEffect(() => { 233 const DELAY = 1000; 234 235 if (!open && items.length > 0) { 236 if (resetTimerRef.current) { 237 clearTimeout(resetTimerRef.current); 238 } 239 resetTimerRef.current = setTimeout(() => { 240 setSearch(""); 241 setPages([]); 242 }, DELAY); 243 } 244 245 if (open && resetTimerRef.current) { 246 clearTimeout(resetTimerRef.current); 247 resetTimerRef.current = undefined; 248 } 249 250 return () => { 251 if (resetTimerRef.current) { 252 clearTimeout(resetTimerRef.current); 253 } 254 }; 255 }, [open, items.length]); 256 257 return ( 258 <> 259 <button 260 type="button" 261 className={cn( 262 "flex w-full items-center text-left hover:bg-muted", 263 open && "bg-muted!", 264 )} 265 onClick={() => setOpen(true)} 266 > 267 <span className="truncate text-muted-foreground"> 268 Search<span className="text-xs">...</span> 269 </span> 270 <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"> 271 <span className="text-xs"></span>K 272 </kbd> 273 </button> 274 <Dialog open={open} onOpenChange={setOpen}> 275 <DialogContent className="top-[15%] translate-y-0 overflow-hidden rounded-none p-0 font-mono shadow-2xl"> 276 <DialogTitle className="sr-only">Search</DialogTitle> 277 <Command 278 onKeyDown={(e) => { 279 // e.key === "Escape" || 280 if (e.key === "Backspace" && !search) { 281 e.preventDefault(); 282 setPages((pages) => pages.slice(0, -1)); 283 } 284 }} 285 shouldFilter={!page} 286 className="rounded-none" 287 > 288 <div 289 className="flex items-center border-b px-3" 290 cmdk-input-wrapper="" 291 > 292 {loading || fetching ? ( 293 <Loader2 className="mr-2 h-4 w-4 shrink-0 animate-spin opacity-50" /> 294 ) : ( 295 <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> 296 )} 297 <CommandPrimitive.Input 298 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" 299 placeholder="Type to search…" 300 value={search} 301 onValueChange={setSearch} 302 /> 303 </div> 304 <CommandList ref={listRef} className="[&_[cmdk-item]]:rounded-none"> 305 {(loading || fetching) && page && !items.length ? ( 306 <CommandLoading>Searching...</CommandLoading> 307 ) : null} 308 {!(loading || fetching) ? ( 309 <CommandEmpty>No results found.</CommandEmpty> 310 ) : null} 311 {!page ? ( 312 <Home 313 setPages={setPages} 314 resetSearch={resetSearch} 315 setOpen={setOpen} 316 /> 317 ) : null} 318 {items.length > 0 ? ( 319 <SearchResults 320 items={items} 321 search={search} 322 setOpen={setOpen} 323 page={page} 324 /> 325 ) : null} 326 </CommandList> 327 </Command> 328 </DialogContent> 329 </Dialog> 330 </> 331 ); 332} 333 334function Home({ 335 setPages, 336 resetSearch, 337 setOpen, 338}: { 339 setPages: React.Dispatch<React.SetStateAction<string[]>>; 340 resetSearch: () => void; 341 setOpen: React.Dispatch<React.SetStateAction<boolean>>; 342}) { 343 const router = useRouter(); 344 const { resolvedTheme, setTheme } = useTheme(); 345 346 return ( 347 <> 348 {CONFIG.map((group, groupIndex) => ( 349 <React.Fragment key={group.heading}> 350 {groupIndex > 0 && <CommandSeparator />} 351 <CommandGroup heading={group.heading}> 352 {group.items.map((item) => { 353 if (item.type === "item") { 354 return ( 355 <CommandItem 356 key={item.label} 357 onSelect={() => { 358 router.push(item.href); 359 setOpen(false); 360 }} 361 > 362 <span>{item.label}</span> 363 {item.shortcut && ( 364 <CommandShortcut>{item.shortcut}</CommandShortcut> 365 )} 366 </CommandItem> 367 ); 368 } 369 if (item.type === "group") { 370 return ( 371 <CommandItem 372 key={item.page} 373 onSelect={() => { 374 setPages((pages) => [...pages, item.page]); 375 resetSearch(); 376 }} 377 > 378 <span>{item.label}</span> 379 </CommandItem> 380 ); 381 } 382 return null; 383 })} 384 </CommandGroup> 385 </React.Fragment> 386 ))} 387 <CommandSeparator /> 388 <CommandGroup heading="Settings"> 389 <CommandItem 390 onSelect={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")} 391 > 392 <span> 393 Switch to {resolvedTheme === "dark" ? "light" : "dark"} theme 394 </span> 395 </CommandItem> 396 </CommandGroup> 397 </> 398 ); 399} 400 401function SearchResults({ 402 items, 403 search, 404 setOpen, 405 page, 406}: { 407 items: MDXData[]; 408 search: string; 409 setOpen: React.Dispatch<React.SetStateAction<boolean>>; 410 page: string | null; 411}) { 412 const router = useRouter(); 413 414 const _page = CONFIG[0].items.find( 415 (item) => item.type === "group" && item.page === page, 416 ) as ConfigGroup | undefined; 417 418 return ( 419 <CommandGroup heading={_page?.heading ?? "Search Results"}> 420 {items.map((item) => { 421 // Highlight search term match in the title, case-insensitive 422 const title = item.metadata.title.replace( 423 new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), 424 (match) => `<mark>${match}</mark>`, 425 ); 426 const html = item.content.replace( 427 new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), 428 (match) => `<mark>${match}</mark>`, 429 ); 430 431 return ( 432 <CommandItem 433 key={item.slug} 434 keywords={[item.metadata.title, item.content, search]} 435 onSelect={() => { 436 router.push(item.href); 437 setOpen(false); 438 }} 439 > 440 <div className="grid min-w-0"> 441 <span 442 className="block truncate" 443 // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 444 dangerouslySetInnerHTML={{ __html: title }} 445 /> 446 {item.content && search ? ( 447 <span 448 className="block truncate text-muted-foreground text-xs" 449 // biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation> 450 dangerouslySetInnerHTML={{ __html: html }} 451 /> 452 ) : null} 453 </div> 454 </CommandItem> 455 ); 456 })} 457 </CommandGroup> 458 ); 459}