"use client"; import { Loading, Search as SearchIcon, Close as XIcon, } from "@openstatus/icons"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList, CommandLoading, CommandSeparator, CommandShortcut, } from "@openstatus/ui/components/ui/command"; import { Dialog, DialogContent, DialogTitle, } from "@openstatus/ui/components/ui/dialog"; import { useDebounce } from "@openstatus/ui/hooks/use-debounce"; import { cn } from "@openstatus/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import * as React from "react"; import { CORPUS_LABELS, type Corpus, type SearchResult } from "./search-meta"; import { buildHighlightRegex } from "./utils/search-match"; 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)[]; }; const CONFIG: ConfigSection[] = [ { type: "group", heading: "Resources", items: [ { type: "item", label: "Go to Docs", href: "/docs", shortcut: "⌘D", }, { type: "item", label: "Go to Home", href: "/", shortcut: "⌘H", }, { type: "item", label: "Go to Pricing", href: "/pricing", shortcut: "⌘P", }, { type: "item", label: "Go to Global Speed Checker", href: "/play/checker", shortcut: "⌘G", }, { type: "group", label: "Search in all pages...", heading: "All pages", page: "all", }, { type: "group", label: "Search in Docs...", heading: "Docs", page: "docs", }, { 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 Tooling...", heading: "Tooling", page: "tooling", }, { type: "group", label: "Search in Compare...", heading: "Compare", page: "compare", }, { type: "group", label: "Search in Guides...", heading: "Guides", page: "guides", }, { type: "group", label: "Search in Use Cases...", heading: "Use Cases", page: "use-case", }, { type: "group", label: "Search in Customers...", heading: "Customers", page: "customers", }, { 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: "/twitter", }, { type: "item", label: "BlueSky", href: "/bsky", }, { type: "item", label: "YouTube", href: "/youtube", }, { type: "item", label: "LinkedIn", href: "/linkedin", }, ], }, ]; export function CmdK({ defaultPage, className, }: { defaultPage?: string; className?: string } = {}) { 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( defaultPage ? [defaultPage] : [], ); const debouncedSearch = useDebounce(search, 150); const query = debouncedSearch.trim(); const router = useRouter(); // Explicitly pinned corpus (e.g. "Search in Docs…" or the docs-page default). const page = pages.length > 0 ? pages[pages.length - 1] : null; // Typing with nothing pinned searches everything — no need to pick a corpus first. const scope: Corpus | "all" | null = (page as Corpus | null) ?? (search.trim() ? "all" : null); // Commands (Go to…, links, theme) stay matchable while typing, unless a corpus // is pinned — then the palette is focused on searching within that corpus. const showCommands = !page; const { data: items = [], isLoading: loading, isFetching: fetching, } = useQuery({ queryKey: ["search", scope, query], queryFn: async () => { if (!scope) return []; const searchParams = new URLSearchParams(); searchParams.set("p", scope); if (query) searchParams.set("q", query); const res = await fetch(`/api/search?${searchParams.toString()}`); return res.json(); }, // A pinned corpus can browse with an empty query; unpinned "all" waits for input. enabled: !!page || !!query, 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]); // 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(defaultPage ? [defaultPage] : []); }, DELAY); } if (open && resetTimerRef.current) { clearTimeout(resetTimerRef.current); resetTimerRef.current = undefined; } return () => { if (resetTimerRef.current) { clearTimeout(resetTimerRef.current); } }; }, [open, items.length, defaultPage]); return ( <> { e.preventDefault(); inputRef.current?.focus(); }} className="top-[15%] translate-y-0 overflow-hidden rounded-none p-0 font-mono shadow-2xl lg:max-w-2xl xl:max-w-3xl" > Search { // e.key === "Escape" || if (e.key === "Backspace" && !search) { e.preventDefault(); setPages((pages) => pages.slice(0, -1)); } }} shouldFilter={false} className="rounded-none" >
{loading || fetching ? ( ) : ( )} {page ? ( {CORPUS_LABELS[page as Corpus] ?? page} ) : scope === "all" ? ( all ) : null}
{(loading || fetching) && scope && !items.length ? ( Searching... ) : null} {!(loading || fetching) ? ( No results found. ) : null} {showCommands ? ( ) : null} {scope && items.length > 0 ? ( ) : null}
); } function Home({ search, setPages, resetSearch, setOpen, }: { search: string; setPages: React.Dispatch>; resetSearch: () => void; setOpen: React.Dispatch>; }) { const router = useRouter(); const { resolvedTheme, setTheme } = useTheme(); const matches = (label: string) => !search || label.toLowerCase().includes(search.toLowerCase()); const themeLabel = `Switch to ${resolvedTheme === "dark" ? "light" : "dark"} theme`; // Build the visible sections up front so separators only sit between // sections that actually render (no leading/trailing/doubled dividers). const sections = CONFIG.map((group) => ({ heading: group.heading, items: group.items.filter((item) => matches(item.label)), })).filter((section) => section.items.length > 0); if (matches(themeLabel)) { sections.push({ heading: "Settings", items: [] }); } return ( <> {sections.map((section, index) => ( {index > 0 && } {section.heading === "Settings" ? ( setTheme(resolvedTheme === "dark" ? "light" : "dark") } > {themeLabel} ) : ( section.items.map((item) => { if (item.type === "item") { return ( { router.push(item.href); setOpen(false); }} > {item.label} {item.shortcut && ( {item.shortcut} )} ); } return ( { setPages((pages) => [...pages, item.page]); resetSearch(); }} > {item.label} ); }) )} ))} ); } // Bucket, preserving the server's rank order — first key seen is the best-scoring one. function bucket(list: SearchResult[], keyOf: (item: SearchResult) => K) { const groups: { key: K; items: SearchResult[] }[] = []; const map = new Map(); for (const item of list) { const key = keyOf(item); let b = map.get(key); if (!b) { b = []; map.set(key, b); groups.push({ key, items: b }); } b.push(item); } return groups; } function SearchResults({ items, search, setOpen, scope, }: { items: SearchResult[]; search: string; setOpen: React.Dispatch>; scope: Corpus | "all"; }) { const router = useRouter(); const highlightRe = React.useMemo( () => buildHighlightRegex(search), [search], ); const highlight = (text: string) => highlightRe ? text.replace(highlightRe, (match) => `${match}`) : text; const renderRow = (item: SearchResult) => ( { router.push(item.href); setOpen(false); }} >
{item.content && search ? ( ) : null}
); // Split full vs partial matches, then sub-group each tier: by corpus in "all" // scope (kept flat — no category level), by category within a single corpus. const keyOf: (item: SearchResult) => string = scope === "all" ? (item) => CORPUS_LABELS[item.type] : (item) => item.metadata.category || CORPUS_LABELS[item.type]; const full = bucket( items.filter((i) => i.tier !== "partial"), keyOf, ); const partial = bucket( items.filter((i) => i.tier === "partial"), keyOf, ); const renderGroups = ( groups: { key: string; items: SearchResult[] }[], prefix: string, ) => groups.map((group, index) => ( {index > 0 && } {group.items.map(renderRow)} )); return ( <> {renderGroups(full, "full")} {partial.length > 0 ? renderGroups(partial, "partial") : null} ); }