DocsSearchPage.tsx371 lines · main
1'use client'
2
3import {
4 DocsSearchResultType as PageType,
5 useDocsSearch,
6 type DocsSearchResult as Page,
7 type DocsSearchResultSection as PageSection,
8} from 'common'
9import { Book, ChevronRight, Github, Hash, Loader2, MessageSquare, Search } from 'lucide-react'
10import { useCallback, useEffect, useRef } from 'react'
11import { Button, cn, CommandGroup, CommandItem, CommandList } from 'ui'
12import { StatusIcon } from 'ui/src/components/StatusIcon'
13
14import {
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 '../..'
28import { BASE_PATH } from '../shared/constants'
29
30const 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
39const 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
52const 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
68const 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&apos;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
327export 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
343export 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
357export 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
371export { DocsSearchPage }