"use client"; import type { MDXData } from "@/content/utils"; import { useDebounce } from "@/hooks/use-debounce"; import { cn } from "@/lib/utils"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList, CommandLoading, CommandSeparator, CommandShortcut, Dialog, DialogContent, DialogTitle, } from "@openstatus/ui"; import { useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; import { Loader2, Search } from "lucide-react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import * as React from "react"; type ConfigItem = { type: "item"; label: string; href: string; shortcut?: string; }; type ConfigGroup = { type: "group"; label: string; heading: string; page: string; }; type ConfigSection = { type: "group"; heading: string; items: (ConfigItem | ConfigGroup)[]; }; // TODO: missing shortcuts const CONFIG: ConfigSection[] = [ { type: "group", heading: "Resources", items: [ { type: "group", label: "Search in all pages...", heading: "All pages", page: "all", }, { type: "item", label: "Go to Home", href: "/", }, { type: "item", label: "Go to Pricing", href: "/pricing", }, { type: "item", label: "Go to Docs", href: "https://docs.openstatus.dev", }, { type: "item", label: "Go to Global Speed Checker", href: "/play/checker", shortcut: "⌘G", }, { type: "group", label: "Search in Products...", heading: "Products", page: "product", }, { type: "group", label: "Search in Blog...", heading: "Blog", page: "blog", }, { type: "group", label: "Search in Changelog...", heading: "Changelog", page: "changelog", }, { type: "group", label: "Search in Tools...", heading: "Tools", page: "tools", }, { type: "group", label: "Search in Compare...", heading: "Compare", page: "compare", }, { type: "item", label: "Go to About", href: "/about", }, { type: "item", label: "Book a call", href: "/cal", }, ], }, { type: "group", heading: "Community", items: [ { type: "item", label: "Discord", href: "/discord", }, { type: "item", label: "GitHub", href: "/github", }, { type: "item", label: "X", href: "/x", }, { type: "item", label: "BlueSky", href: "/bluesky", }, { type: "item", label: "YouTube", href: "/youtube", }, { type: "item", label: "LinkedIn", href: "/linkedin", }, ], }, ]; export function CmdK() { const [open, setOpen] = React.useState(false); const inputRef = React.useRef(null); const listRef = React.useRef(null); const resetTimerRef = React.useRef(undefined); const [search, setSearch] = React.useState(""); const [pages, setPages] = React.useState([]); const debouncedSearch = useDebounce(search, 300); const router = useRouter(); const page = pages.length > 0 ? pages[pages.length - 1] : null; const { data: items = [], isLoading: loading, isFetching: fetching, } = useQuery({ queryKey: ["search", page, debouncedSearch], queryFn: async () => { if (!page) return []; const searchParams = new URLSearchParams(); searchParams.set("p", page); if (debouncedSearch) searchParams.set("q", debouncedSearch); const promise = fetch(`/api/search?${searchParams.toString()}`); // NOTE: artificial delay to avoid flickering const delay = new Promise((r) => setTimeout(r, 300)); const [res, _] = await Promise.all([promise, delay]); return res.json(); }, placeholderData: (previousData) => previousData, }); const resetSearch = React.useCallback(() => setSearch(""), []); React.useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((open) => !open); } // Handle shortcuts when dialog is open if (open && (e.metaKey || e.ctrlKey)) { const key = e.key.toLowerCase(); // Find matching shortcut in CONFIG for (const section of CONFIG) { for (const item of section.items) { if (item.type === "item" && item.shortcut) { const shortcutKey = item.shortcut.replace("⌘", "").toLowerCase(); if (key === shortcutKey) { e.preventDefault(); router.push(item.href); setOpen(false); return; } } } } } }; document.addEventListener("keydown", down); return () => document.removeEventListener("keydown", down); }, [open, router]); React.useEffect(() => { inputRef.current?.focus(); }, []); // NOTE: Reset search and pages after dialog closes (with delay for animation) // - if within 1 second of closing, the dialog will not reset React.useEffect(() => { const DELAY = 1000; if (!open && items.length > 0) { if (resetTimerRef.current) { clearTimeout(resetTimerRef.current); } resetTimerRef.current = setTimeout(() => { setSearch(""); setPages([]); }, DELAY); } if (open && resetTimerRef.current) { clearTimeout(resetTimerRef.current); resetTimerRef.current = undefined; } return () => { if (resetTimerRef.current) { clearTimeout(resetTimerRef.current); } }; }, [open, items.length]); return ( <> Search { // e.key === "Escape" || if (e.key === "Backspace" && !search) { e.preventDefault(); setPages((pages) => pages.slice(0, -1)); } }} shouldFilter={!page} className="rounded-none" >
{loading || fetching ? ( ) : ( )}
{(loading || fetching) && page && !items.length ? ( Searching... ) : null} {!(loading || fetching) ? ( No results found. ) : null} {!page ? ( ) : null} {items.length > 0 ? ( ) : null}
); } function Home({ setPages, resetSearch, setOpen, }: { setPages: React.Dispatch>; resetSearch: () => void; setOpen: React.Dispatch>; }) { const router = useRouter(); const { resolvedTheme, setTheme } = useTheme(); return ( <> {CONFIG.map((group, groupIndex) => ( {groupIndex > 0 && } {group.items.map((item) => { if (item.type === "item") { return ( { router.push(item.href); setOpen(false); }} > {item.label} {item.shortcut && ( {item.shortcut} )} ); } if (item.type === "group") { return ( { setPages((pages) => [...pages, item.page]); resetSearch(); }} > {item.label} ); } return null; })} ))} setTheme(resolvedTheme === "dark" ? "light" : "dark")} > Switch to {resolvedTheme === "dark" ? "light" : "dark"} theme ); } function SearchResults({ items, search, setOpen, page, }: { items: MDXData[]; search: string; setOpen: React.Dispatch>; page: string | null; }) { const router = useRouter(); const _page = CONFIG[0].items.find( (item) => item.type === "group" && item.page === page, ) as ConfigGroup | undefined; return ( {items.map((item) => { // Highlight search term match in the title, case-insensitive const title = item.metadata.title.replace( new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), (match) => `${match}`, ); const html = item.content.replace( new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), (match) => `${match}`, ); return ( { router.push(item.href); setOpen(false); }} >
dangerouslySetInnerHTML={{ __html: title }} /> {item.content && search ? ( dangerouslySetInnerHTML={{ __html: html }} /> ) : null}
); })}
); }