Openstatus
www.openstatus.dev
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}