Skip to content
File

Blob: src/client/components/sidebar/search-dialog.tsx

typescript144 lines
1import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2import { useNavigate } from "@tanstack/react-router";
3import { useQuery } from "@tanstack/react-query";
4import { Search, FileText, Loader2 } from "lucide-react";
5import { searchQueryOptions } from "@/client/lib/queries/search";
6import { useCurrentWorkspace } from "@/client/components/workspace/use-workspace-view";
7import type { SearchResult } from "@/shared/types";
8import { DEFAULT_PAGE_TITLE } from "@/shared/constants";
9 
10export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
11 if (!open) return null;
12 return <SearchDialogContent onClose={onClose} />;
13}
14 
15function SearchDialogContent({ onClose }: { onClose: () => void }) {
16 const navigate = useNavigate();
17 const workspace = useCurrentWorkspace();
18 const [query, setQuery] = useState("");
19 const [debouncedQuery, setDebouncedQuery] = useState("");
20 const [selectedIndex, setSelectedIndex] = useState(0);
21 const inputRef = useRef<HTMLInputElement>(null);
22 const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
23 
24 const searchQuery = useQuery(searchQueryOptions(workspace?.id ?? null, debouncedQuery));
25 const results: SearchResult[] = useMemo(() => searchQuery.data ?? [], [searchQuery.data]);
26 const isSearching = searchQuery.isFetching;
27 const hasError = searchQuery.isError;
28 
29 useEffect(() => {
30 requestAnimationFrame(() => inputRef.current?.focus());
31 return () => {
32 if (!debounceRef.current) return;
33 clearTimeout(debounceRef.current);
34 };
35 }, []);
36 
37 const handleInput = useCallback((value: string) => {
38 setQuery(value);
39 setSelectedIndex(0);
40 if (debounceRef.current) clearTimeout(debounceRef.current);
41 debounceRef.current = setTimeout(() => setDebouncedQuery(value), 200);
42 }, []);
43 
44 const selectResult = useCallback(
45 (result: SearchResult) => {
46 if (!workspace) return;
47 onClose();
48 navigate({
49 to: "/$workspaceSlug/$pageId",
50 params: { workspaceSlug: workspace.slug, pageId: result.page_id },
51 });
52 },
53 [workspace, navigate, onClose],
54 );
55 
56 const handleKeyDown = useCallback(
57 (e: React.KeyboardEvent) => {
58 if (e.key === "ArrowDown") {
59 e.preventDefault();
60 setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
61 } else if (e.key === "ArrowUp") {
62 e.preventDefault();
63 setSelectedIndex((i) => Math.max(i - 1, 0));
64 } else if (e.key === "Enter" && results[selectedIndex]) {
65 e.preventDefault();
66 selectResult(results[selectedIndex]);
67 } else if (e.key === "Escape") {
68 onClose();
69 }
70 },
71 [results, selectedIndex, selectResult, onClose],
72 );
73 
74 return (
75 <div
76 className="fixed inset-0 z-[90] flex items-start justify-center pt-[15vh]"
77 role="presentation"
78 onClick={onClose}
79 >
80 <div className="fixed inset-0 bg-zinc-950/60" aria-hidden="true" />
81 <div
82 className="animate-scale-fade relative w-full max-w-lg rounded-xl border border-zinc-700 bg-zinc-800 shadow-2xl"
83 onClick={(e) => e.stopPropagation()}
84 >
85 <div className="flex items-center gap-3 border-b border-zinc-800 px-4 py-3">
86 <Search className="h-4 w-4 shrink-0 text-zinc-500" />
87 <input
88 ref={inputRef}
89 value={query}
90 onChange={(e) => handleInput(e.target.value)}
91 onKeyDown={handleKeyDown}
92 placeholder="Search pages..."
93 aria-label="Search pages"
94 className="flex-1 bg-transparent text-sm text-zinc-200 placeholder:text-zinc-500 outline-none"
95 />
96 {isSearching && <Loader2 className="h-4 w-4 animate-spin text-zinc-500" />}
97 <kbd className="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-[10px] text-zinc-400">
98 Esc
99 </kbd>
100 </div>
101
102 <div className="max-h-80 overflow-y-auto p-1">
103 {results.length === 0 && query.trim().length >= 3 && !isSearching && hasError && (
104 <div className="px-4 py-8 text-center text-sm text-zinc-400">Search is down. Not your fault.</div>
105 )}
106 {results.length === 0 && query.trim().length >= 3 && !isSearching && !hasError && (
107 <div className="px-4 py-8 text-center text-sm text-zinc-400">Nothing. Try different words.</div>
108 )}
109 {results.length === 0 && query.trim().length > 0 && query.trim().length < 3 && !isSearching && (
110 <div className="px-4 py-8 text-center text-sm text-zinc-400">Type at least 3 characters</div>
111 )}
112 {results.length === 0 && query.trim().length === 0 && (
113 <div className="px-4 py-8 text-center text-sm text-zinc-400">Search across all your pages</div>
114 )}
115 {results.map((result, i) => (
116 <button
117 key={result.page_id}
118 onClick={() => selectResult(result)}
119 onMouseEnter={() => setSelectedIndex(i)}
120 className={`flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left opacity-0 animate-slide-up transition-colors ${
121 i === selectedIndex ? "bg-zinc-800" : "hover:bg-zinc-800/50"
122 }`}
123 style={{ animationDelay: `${Math.min(i, 7) * 60}ms` }}
124 >
125 <span className="mt-0.5 shrink-0 text-base">
126 {result.icon ?? <FileText className="h-4 w-4 text-zinc-500" />}
127 </span>
128 <div className="min-w-0 flex-1">
129 <div className="truncate text-sm font-medium text-zinc-200">{result.title || DEFAULT_PAGE_TITLE}</div>
130 {result.snippet && (
131 <div
132 className="mt-0.5 truncate text-xs text-zinc-400 [&>mark]:bg-accent-500/30 [&>mark]:text-zinc-200"
133 dangerouslySetInnerHTML={{ __html: result.snippet }}
134 />
135 )}
136 </div>
137 </button>
138 ))}
139 </div>
140 </div>
141 </div>
142 );
143}