File
Blob: src/client/components/sidebar/search-dialog.tsx
| 1 | import { useState, useEffect, useRef, useCallback, useMemo } from "react"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | import { useQuery } from "@tanstack/react-query"; |
| 4 | import { Search, FileText, Loader2 } from "lucide-react"; |
| 5 | import { searchQueryOptions } from "@/client/lib/queries/search"; |
| 6 | import { useCurrentWorkspace } from "@/client/components/workspace/use-workspace-view"; |
| 7 | import type { SearchResult } from "@/shared/types"; |
| 8 | import { DEFAULT_PAGE_TITLE } from "@/shared/constants"; |
| 9 | |
| 10 | export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => void }) { |
| 11 | if (!open) return null; |
| 12 | return <SearchDialogContent onClose={onClose} />; |
| 13 | } |
| 14 | |
| 15 | function 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 | } |