DocsSearchPage.tsx371 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { |
| 4 | DocsSearchResultType as PageType, |
| 5 | useDocsSearch, |
| 6 | type DocsSearchResult as Page, |
| 7 | type DocsSearchResultSection as PageSection, |
| 8 | } from 'common' |
| 9 | import { Book, ChevronRight, Github, Hash, Loader2, MessageSquare, Search } from 'lucide-react' |
| 10 | import { useCallback, useEffect, useRef } from 'react' |
| 11 | import { Button, cn, CommandGroup, CommandItem, CommandList } from 'ui' |
| 12 | import { StatusIcon } from 'ui/src/components/StatusIcon' |
| 13 | |
| 14 | import { |
| 15 | Breadcrumb, |
| 16 | CommandHeader, |
| 17 | CommandMenuInput, |
| 18 | CommandWrapper, |
| 19 | escapeAttributeSelector, |
| 20 | generateCommandClassNames, |
| 21 | TextHighlighter, |
| 22 | useCommandMenuTelemetryContext, |
| 23 | useCrossCompatRouter, |
| 24 | useQuery, |
| 25 | useSetCommandMenuOpen, |
| 26 | useSetQuery, |
| 27 | } from '../..' |
| 28 | import { BASE_PATH } from '../shared/constants' |
| 29 | |
| 30 | const questions = [ |
| 31 | 'How do I get started with Briven?', |
| 32 | 'How do I run Briven locally?', |
| 33 | 'How do I connect to my database?', |
| 34 | 'How do I run migrations? ', |
| 35 | 'How do I listen to changes in a table?', |
| 36 | 'How do I set up authentication?', |
| 37 | ] |
| 38 | |
| 39 | const ChevronArrow = () => ( |
| 40 | <ChevronRight |
| 41 | strokeWidth={1.5} |
| 42 | className={cn( |
| 43 | '-left-4', |
| 44 | 'opacity-0', |
| 45 | 'text-foreground-muted', |
| 46 | 'group-aria-selected:scale-[101%] group-aria-selected:opacity-100 group-aria-selected:left-0', |
| 47 | 'transition' |
| 48 | )} |
| 49 | /> |
| 50 | ) |
| 51 | |
| 52 | const IconContainer = ( |
| 53 | props: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> |
| 54 | ) => ( |
| 55 | <div |
| 56 | className={cn( |
| 57 | 'w-6 h-6', |
| 58 | 'bg-surface-100 border rounded-sm', |
| 59 | 'flex items-center justify-center', |
| 60 | 'text-foreground-muted', |
| 61 | 'group-aria-selected:bg-surface-200 group-aria-selected:text-foreground-lighter group-aria-selected:[&_svg]:scale-[103%]', |
| 62 | 'transition' |
| 63 | )} |
| 64 | {...props} |
| 65 | /> |
| 66 | ) |
| 67 | |
| 68 | const DocsSearchPage = () => { |
| 69 | const { |
| 70 | searchState: state, |
| 71 | handleDocsSearch: handleSearch, |
| 72 | handleDocsSearchDebounced: debouncedSearch, |
| 73 | resetSearch, |
| 74 | } = useDocsSearch() |
| 75 | const setIsOpen = useSetCommandMenuOpen() |
| 76 | const setQuery = useSetQuery() |
| 77 | const query = useQuery() |
| 78 | |
| 79 | const telemetryContext = useCommandMenuTelemetryContext() |
| 80 | |
| 81 | const initialLoad = useRef(true) |
| 82 | const inputRef = useRef<HTMLInputElement>(null) |
| 83 | const router = useCrossCompatRouter() |
| 84 | |
| 85 | const trackResultClicked = useCallback( |
| 86 | function trackResultClicked(name: string, path: string) { |
| 87 | telemetryContext?.onTelemetry?.({ |
| 88 | action: 'command_menu_command_clicked', |
| 89 | properties: { |
| 90 | command_name: name, |
| 91 | command_type: 'route', |
| 92 | search_query: query || undefined, |
| 93 | result_path: path, |
| 94 | app: telemetryContext.app, |
| 95 | }, |
| 96 | groups: {}, |
| 97 | }) |
| 98 | }, |
| 99 | [query] |
| 100 | ) |
| 101 | |
| 102 | async function openLink(pageType: PageType, link: string) { |
| 103 | // A simple way to achieve opening links in new tab but room for improvement including support for middle clicks |
| 104 | let openInNewTab = |
| 105 | (window.event as KeyboardEvent)?.metaKey || (window.event as KeyboardEvent)?.ctrlKey |
| 106 | let finalLink: string = link |
| 107 | switch (pageType) { |
| 108 | case PageType.Markdown: |
| 109 | case PageType.Reference: |
| 110 | case PageType.Troubleshooting: |
| 111 | if (BASE_PATH === '/docs') { |
| 112 | if (openInNewTab) { |
| 113 | finalLink = `/docs${link}` |
| 114 | } |
| 115 | } else if (!BASE_PATH) { |
| 116 | finalLink = `/docs${link}` |
| 117 | } else { |
| 118 | finalLink = `https://supabase.com/docs${link}` |
| 119 | openInNewTab = true |
| 120 | } |
| 121 | break |
| 122 | case PageType.Integration: |
| 123 | if (BASE_PATH) { |
| 124 | openInNewTab = true |
| 125 | finalLink = `https://supabase.com${link}` |
| 126 | } |
| 127 | break |
| 128 | case PageType.GithubDiscussion: |
| 129 | openInNewTab = true |
| 130 | break |
| 131 | default: |
| 132 | throw new Error(`Unknown page type '${pageType}'`) |
| 133 | } |
| 134 | if (openInNewTab) { |
| 135 | window.open(finalLink, '_blank', 'noreferrer,noopener') |
| 136 | } else { |
| 137 | router.push(finalLink) |
| 138 | setIsOpen(false) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | const hasResults = |
| 143 | state.status === 'fullResults' || |
| 144 | state.status === 'partialResults' || |
| 145 | (state.status === 'loading' && state.staleResults.length > 0) |
| 146 | |
| 147 | function handleResetPrompt() { |
| 148 | setQuery('') |
| 149 | resetSearch() |
| 150 | } |
| 151 | |
| 152 | useEffect(() => { |
| 153 | if (initialLoad.current) { |
| 154 | // On first navigation into 'docs search' page, search immediately |
| 155 | if (query) { |
| 156 | handleSearch(query) |
| 157 | } |
| 158 | initialLoad.current = false |
| 159 | } else if (query) { |
| 160 | // Else if user is typing, debounce search |
| 161 | debouncedSearch(query) |
| 162 | } else { |
| 163 | // If user clears search, reset results |
| 164 | resetSearch() |
| 165 | } |
| 166 | }, [query, handleSearch, debouncedSearch]) |
| 167 | |
| 168 | // Immediately run search if user presses enter |
| 169 | // and abort any debounced searches that are waiting |
| 170 | useEffect(() => { |
| 171 | const handleEnter = (event: KeyboardEvent) => { |
| 172 | if ( |
| 173 | event.key === 'Enter' && |
| 174 | document.activeElement === inputRef.current && |
| 175 | query && |
| 176 | // If there are results, cmdk menu will trigger navigation to the highlighted |
| 177 | // result on Enter, even though the active element is the input |
| 178 | !hasResults |
| 179 | ) { |
| 180 | event.preventDefault() |
| 181 | debouncedSearch.cancel() |
| 182 | handleSearch(query) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | inputRef.current?.addEventListener('keydown', handleEnter) |
| 187 | return () => inputRef.current?.removeEventListener('keydown', handleEnter) |
| 188 | }, [query, hasResults]) |
| 189 | |
| 190 | return ( |
| 191 | <CommandWrapper> |
| 192 | <CommandHeader> |
| 193 | <Breadcrumb /> |
| 194 | <CommandMenuInput placeholder="Search..." ref={inputRef} /> |
| 195 | </CommandHeader> |
| 196 | <CommandList className="max-h-[initial]"> |
| 197 | {hasResults && |
| 198 | ('results' in state ? state.results : state.staleResults).map((page, i) => { |
| 199 | return ( |
| 200 | <CommandGroup |
| 201 | heading="" |
| 202 | key={`${page.path}-group`} |
| 203 | value={`${escapeAttributeSelector(page.title)}-group-index-${i}`} |
| 204 | forceMount={true} |
| 205 | className="overflow-hidden py-3 px-2 text-border-strong **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:pb-1.5 **:[[cmdk-group-heading]]:text-sm **:[[cmdk-group-heading]]:font-normal [&_[cmdk-group-heading]]:text-foreground-muted" |
| 206 | > |
| 207 | <CommandItem |
| 208 | key={`${page.path}-item`} |
| 209 | value={`${escapeAttributeSelector(page.title)}-item-index-${i}`} |
| 210 | onSelect={() => { |
| 211 | trackResultClicked(page.title, page.path) |
| 212 | openLink(page.type, page.path) |
| 213 | }} |
| 214 | forceMount={true} |
| 215 | className={cn(generateCommandClassNames(true), 'border border-overlay/90')} |
| 216 | > |
| 217 | <div className="grow flex gap-3 items-center"> |
| 218 | <IconContainer>{getPageIcon(page)}</IconContainer> |
| 219 | <div className="flex flex-col gap-0"> |
| 220 | <TextHighlighter>{page.title}</TextHighlighter> |
| 221 | {(page.description || page.subtitle) && ( |
| 222 | <div className="text-xs text-foreground-muted"> |
| 223 | {page.description || page.subtitle} |
| 224 | </div> |
| 225 | )} |
| 226 | </div> |
| 227 | </div> |
| 228 | |
| 229 | <ChevronArrow /> |
| 230 | </CommandItem> |
| 231 | {page.sections.length > 0 && ( |
| 232 | <div className="border-l border-muted ml-3 pt-3"> |
| 233 | {page.sections.map((section, i) => ( |
| 234 | <CommandItem |
| 235 | className={cn( |
| 236 | generateCommandClassNames(true), |
| 237 | 'border border-overlay/90', |
| 238 | 'ml-3 mb-3' |
| 239 | )} |
| 240 | onSelect={() => { |
| 241 | trackResultClicked( |
| 242 | section.heading ?? page.title, |
| 243 | formatSectionUrl(page, section) |
| 244 | ) |
| 245 | openLink(page.type, formatSectionUrl(page, section)) |
| 246 | }} |
| 247 | key={`${page.path}__${section.heading}-item`} |
| 248 | value={`${escapeAttributeSelector(page.title)}__${escapeAttributeSelector(section.heading)}-item-index-${i}`} |
| 249 | forceMount={true} |
| 250 | > |
| 251 | <div className="grow flex gap-3 items-center"> |
| 252 | <IconContainer>{getPageSectionIcon(page)}</IconContainer> |
| 253 | <div className="flex flex-col gap-0"> |
| 254 | <cite> |
| 255 | <TextHighlighter className="not-italic text-[10px] rounded-full px-2 py-1 bg-surface-300 text-foreground-muted"> |
| 256 | {page.title} |
| 257 | </TextHighlighter> |
| 258 | </cite> |
| 259 | {section.heading && ( |
| 260 | <TextHighlighter>{section.heading}</TextHighlighter> |
| 261 | )} |
| 262 | </div> |
| 263 | </div> |
| 264 | <ChevronArrow /> |
| 265 | </CommandItem> |
| 266 | ))} |
| 267 | </div> |
| 268 | )} |
| 269 | </CommandGroup> |
| 270 | ) |
| 271 | })} |
| 272 | {state.status === 'initial' && ( |
| 273 | <CommandGroup className="overflow-hidden py-3 px-2 text-border-strong **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:pb-1.5 **:[[cmdk-group-heading]]:text-sm **:[[cmdk-group-heading]]:font-normal [&_[cmdk-group-heading]]:text-foreground-muted"> |
| 274 | {questions.map((question) => { |
| 275 | const key = question.replace(/\s+/g, '_') |
| 276 | return ( |
| 277 | <CommandItem |
| 278 | className={generateCommandClassNames(false)} |
| 279 | disabled={hasResults} |
| 280 | onSelect={() => { |
| 281 | if (!query) { |
| 282 | handleSearch(question) |
| 283 | setQuery(question) |
| 284 | } |
| 285 | }} |
| 286 | key={key} |
| 287 | > |
| 288 | <Search /> |
| 289 | {question} |
| 290 | </CommandItem> |
| 291 | ) |
| 292 | })} |
| 293 | </CommandGroup> |
| 294 | )} |
| 295 | {state.status === 'loading' && state.staleResults.length === 0 && ( |
| 296 | <div className="flex items-center gap-3 my-4 justify-center"> |
| 297 | <Loader2 className="animate animate-spin text-foreground-muted" size={14} /> |
| 298 | <p className="text-sm text-foreground-muted text-center">Searching for results</p> |
| 299 | </div> |
| 300 | )} |
| 301 | {state.status === 'noResults' && ( |
| 302 | <div className="p-6 flex flex-col items-center gap-6 mt-4 text-foreground-light"> |
| 303 | <StatusIcon variant="default" /> |
| 304 | <p className="text-sm text-foreground-light text-center">No results found.</p> |
| 305 | <Button size="tiny" type="default" onClick={handleResetPrompt}> |
| 306 | Try again? |
| 307 | </Button> |
| 308 | </div> |
| 309 | )} |
| 310 | {state.status === 'error' && ( |
| 311 | <div className="p-6 flex flex-col items-center gap-6 mt-4"> |
| 312 | <StatusIcon variant="warning" /> |
| 313 | <p className="text-lg text-foreground-light"> |
| 314 | Sorry, looks like we're having some issues with search! |
| 315 | </p> |
| 316 | <p className="text-sm text-foreground-lighter">Please try again in a bit.</p> |
| 317 | <Button size="tiny" type="default" onClick={handleResetPrompt}> |
| 318 | Try again? |
| 319 | </Button> |
| 320 | </div> |
| 321 | )} |
| 322 | </CommandList> |
| 323 | </CommandWrapper> |
| 324 | ) |
| 325 | } |
| 326 | |
| 327 | export function formatSectionUrl(page: Page, section: PageSection) { |
| 328 | switch (page.type) { |
| 329 | case PageType.Markdown: |
| 330 | case PageType.GithubDiscussion: |
| 331 | return `${page.path}#${section.slug ?? ''}` |
| 332 | case PageType.Reference: |
| 333 | return `${page.path}/${section.slug ?? ''}` |
| 334 | case PageType.Troubleshooting: |
| 335 | // [Charis] Markdown headings on integrations pages don't have slugs yet |
| 336 | case PageType.Integration: |
| 337 | return page.path |
| 338 | default: |
| 339 | throw new Error(`Unknown page type '${page.type}'`) |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | export function getPageIcon(page: Page) { |
| 344 | switch (page.type) { |
| 345 | case PageType.Markdown: |
| 346 | case PageType.Reference: |
| 347 | case PageType.Integration: |
| 348 | case PageType.Troubleshooting: |
| 349 | return <Book strokeWidth={1.5} className="mr-0! w-4! h-4!" /> |
| 350 | case PageType.GithubDiscussion: |
| 351 | return <Github strokeWidth={1.5} className="mr-0! w-4! h-4!" /> |
| 352 | default: |
| 353 | throw new Error(`Unknown page type '${page.type}'`) |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | export function getPageSectionIcon(page: Page) { |
| 358 | switch (page.type) { |
| 359 | case PageType.Markdown: |
| 360 | case PageType.Reference: |
| 361 | case PageType.Integration: |
| 362 | case PageType.Troubleshooting: |
| 363 | return <Hash strokeWidth={1.5} className="mr-0! w-4! h-4!" /> |
| 364 | case PageType.GithubDiscussion: |
| 365 | return <MessageSquare strokeWidth={1.5} className="mr-0! w-4! h-4!" /> |
| 366 | default: |
| 367 | throw new Error(`Unknown page type '${page.type}'`) |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | export { DocsSearchPage } |