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