Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
17 kB ยท 599 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600"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<HTMLInputElement | null>(null); const listRef = React.useRef<HTMLDivElement | null>(null); const resetTimerRef = React.useRef<NodeJS.Timeout | undefined>(undefined); const [search, setSearch] = React.useState(""); const [pages, setPages] = React.useState<string[]>( 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<SearchResult[]>({ 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 ( <> <button type="button" className={cn( "hover:bg-muted flex w-full items-center text-left", open && "bg-muted!", className, )} onClick={() => setOpen(true)} > <span className="text-muted-foreground truncate"> Search<span className="text-xs">...</span> </span> <kbd className="bg-muted text-muted-foreground pointer-events-none ml-auto inline-flex h-5 items-center gap-1 border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none"> <span className="text-xs">โ</span>K </kbd> </button> <Dialog open={open} onOpenChange={setOpen}> <DialogContent onOpenAutoFocus={(e) => { 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" > <DialogTitle className="sr-only">Search</DialogTitle> <Command onKeyDown={(e) => { // e.key === "Escape" || if (e.key === "Backspace" && !search) { e.preventDefault(); setPages((pages) => pages.slice(0, -1)); } }} shouldFilter={false} className="rounded-none" > <div className="flex items-center gap-2 border-b px-3" cmdk-input-wrapper="" > {loading || fetching ? ( <Loading className="h-4 w-4 shrink-0 animate-spin opacity-50" /> ) : ( <SearchIcon className="h-4 w-4 shrink-0 opacity-50" /> )} {page ? ( <span className="bg-muted text-muted-foreground inline-flex shrink-0 items-center gap-1 border px-1.5 py-0.5 text-xs"> {CORPUS_LABELS[page as Corpus] ?? page} <button type="button" aria-label="Clear scope" className="opacity-60 hover:opacity-100" onClick={() => setPages([])} > <XIcon className="h-3 w-3" /> </button> </span> ) : scope === "all" ? ( <span className="bg-muted text-muted-foreground inline-flex shrink-0 items-center border px-1.5 py-0.5 text-xs"> all </span> ) : null} <CommandPrimitive.Input ref={inputRef} className="placeholder:text-foreground-muted flex h-11 w-full rounded-none bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50" placeholder={ page ? `Search in ${CORPUS_LABELS[page as Corpus] ?? page}โฆ` : "Type to searchโฆ" } value={search} onValueChange={setSearch} /> </div> <CommandList ref={listRef} className="max-h-[50vh] lg:max-h-[60vh] [&_[cmdk-item]]:rounded-none" > {(loading || fetching) && scope && !items.length ? ( <CommandLoading>Searching...</CommandLoading> ) : null} {!(loading || fetching) ? ( <CommandEmpty>No results found.</CommandEmpty> ) : null} {showCommands ? ( <Home search={search} setPages={setPages} resetSearch={resetSearch} setOpen={setOpen} /> ) : null} {scope && items.length > 0 ? ( <SearchResults items={items} search={search} setOpen={setOpen} scope={scope} /> ) : null} </CommandList> </Command> </DialogContent> </Dialog> </> );}
function Home({ search, setPages, resetSearch, setOpen,}: { search: string; setPages: React.Dispatch<React.SetStateAction<string[]>>; resetSearch: () => void; setOpen: React.Dispatch<React.SetStateAction<boolean>>;}) { 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) => ( <React.Fragment key={section.heading}> {index > 0 && <CommandSeparator />} <CommandGroup heading={section.heading}> {section.heading === "Settings" ? ( <CommandItem onSelect={() => setTheme(resolvedTheme === "dark" ? "light" : "dark") } > <span>{themeLabel}</span> </CommandItem> ) : ( section.items.map((item) => { if (item.type === "item") { return ( <CommandItem key={item.label} value={item.label} onSelect={() => { router.push(item.href); setOpen(false); }} > <span>{item.label}</span> {item.shortcut && ( <CommandShortcut>{item.shortcut}</CommandShortcut> )} </CommandItem> ); } return ( <CommandItem key={item.page} value={item.label} onSelect={() => { setPages((pages) => [...pages, item.page]); resetSearch(); }} > <span>{item.label}</span> </CommandItem> ); }) )} </CommandGroup> </React.Fragment> ))} </> );}
// Bucket, preserving the server's rank order โ first key seen is the best-scoring one.function bucket<K>(list: SearchResult[], keyOf: (item: SearchResult) => K) { const groups: { key: K; items: SearchResult[] }[] = []; const map = new Map<K, SearchResult[]>(); 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<React.SetStateAction<boolean>>; scope: Corpus | "all";}) { const router = useRouter();
const highlightRe = React.useMemo( () => buildHighlightRegex(search), [search], ); const highlight = (text: string) => highlightRe ? text.replace(highlightRe, (match) => `<mark>${match}</mark>`) : text;
const renderRow = (item: SearchResult) => ( <CommandItem key={item.href} value={item.href} onSelect={() => { router.push(item.href); setOpen(false); }} > <div className="grid min-w-0"> <span className="block truncate" // oxlint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ __html: highlight(item.metadata.title) }} /> {item.content && search ? ( <span className="text-muted-foreground block truncate text-xs" // oxlint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ __html: highlight(item.content) }} /> ) : null} </div> </CommandItem> );
// 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) => ( <React.Fragment key={`${prefix}-${group.key}`}> {index > 0 && <CommandSeparator />} <CommandGroup heading={group.key}> {group.items.map(renderRow)} </CommandGroup> </React.Fragment> ));
return ( <> {renderGroups(full, "full")} {partial.length > 0 ? renderGroups(partial, "partial") : null} </> );}