diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBarRef.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBarRef.tsx index 7501f116c2a..0a2bdb7a696 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBarRef.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBarRef.tsx @@ -3,11 +3,13 @@ import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' import { FC, useEffect, useState } from 'react' -import { Button, IconCommand, IconGitHub, IconMoon, IconSearch, IconSun, SearchButton } from 'ui' +import { Button, IconCommand, IconGitHub, IconMoon, IconSearch, IconSun } from 'ui' import { REFERENCES } from '~/components/Navigation/Navigation.constants' import { getPageType } from '~/lib/helpers' +import SearchButton from '~/components/Search/SearchButton' + const TopNavBarRef: FC = () => { const { isDarkMode, toggleTheme } = useTheme() const [mounted, setMounted] = useState(false) diff --git a/apps/docs/components/Search/SearchButton.tsx b/apps/docs/components/Search/SearchButton.tsx new file mode 100644 index 00000000000..94da639a526 --- /dev/null +++ b/apps/docs/components/Search/SearchButton.tsx @@ -0,0 +1,40 @@ +import { + ButtonHTMLAttributes, + DetailedHTMLProps, + FC, + PropsWithChildren, + useEffect, + useRef, +} from 'react' +import { useActionKey } from '~/hooks/useActionKey' +import { useSearch } from './SearchProvider' + +const SearchButton: FC< + PropsWithChildren, HTMLButtonElement>> +> = ({ children, ...props }) => { + const searchButtonRef = useRef() + const { open, setQuery } = useSearch() + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (searchButtonRef.current === document.activeElement) { + if (/[a-zA-Z0-9]/.test(String.fromCharCode(event.keyCode))) { + open() + setQuery?.(event.key) + } + } + } + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + } + }, [open, setQuery]) + + return ( + + ) +} + +export default SearchButton diff --git a/apps/docs/components/Search/SearchModal.tsx b/apps/docs/components/Search/SearchModal.tsx new file mode 100644 index 00000000000..213035540b1 --- /dev/null +++ b/apps/docs/components/Search/SearchModal.tsx @@ -0,0 +1,423 @@ +import type { CreateCompletionResponse } from 'openai' +import { FC, useCallback, useRef, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { SSE } from 'sse.js' +import clippyImageDark from '../../public/img/clippy-dark.png' +import clippyImage from '../../public/img/clippy.png' + +import { useSupabaseClient } from '@supabase/auth-helpers-react' +import { useTheme } from 'common/Providers' +import Image from 'next/image' +import { + Button, + IconAlertCircle, + IconAlertTriangle, + IconLoader, + IconSearch, + Input, + Loading, + Modal, + Tabs, +} from 'ui' +import components from '~/components' +import { IS_PLATFORM } from '~/lib/constants' +import { useSearch } from './SearchProvider' +import SearchResult, { SearchResultType } from './SearchResult' + +const questions = [ + 'How do I get started with Supabase?', + 'How do I run Supabase locally?', + 'How do I connect to my database?', + 'How do I run migrations? ', + 'How do I listen to changes in a table?', + 'How do I set up authentication?', +] + +function getEdgeFunctionUrl() { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, '') + + if (IS_PLATFORM) { + const [schemeAndProjectId, domain, tld] = supabaseUrl.split('.') + return `${schemeAndProjectId}.functions.${domain}.${tld}` + } else { + return `${supabaseUrl}/functions/v1` + } +} + +const edgeFunctionUrl = getEdgeFunctionUrl() + +const SearchModal: FC = () => { + const { isDarkMode } = useTheme() + const { close, query, setQuery } = useSearch() + const [answer, setAnswer] = useState('') + const [results, setResults] = useState() + const [isLoading, setIsLoading] = useState(false) + const [isResponding, setIsResponding] = useState(false) + const [hasClippyError, setHasClippyError] = useState(false) + const [hasSearchError, setHasSearchError] = useState(false) + const [selectedTab, setSelectedTab] = useState('search-panel') + const eventSourceRef = useRef() + const supabaseClient = useSupabaseClient() + + const cantHelp = answer?.trim() === "Sorry, I don't know how to help with that." + const status = isLoading + ? 'Clippy is searching...' + : isResponding + ? 'Clippy is responding...' + : cantHelp || hasClippyError + ? 'Clippy has failed you' + : undefined + + const handleSearchConfirm = useCallback( + async (query: string) => { + setResults(undefined) + setAnswer(undefined) + setIsResponding(false) + setHasClippyError(false) + setHasSearchError(false) + setIsLoading(true) + + const { error, data: pageSections } = await supabaseClient.functions.invoke('search', { + body: { query }, + }) + + setIsLoading(false) + + if (error) { + setIsLoading(false) + setIsResponding(false) + setHasSearchError(true) + console.error(error) + return + } + + if (!Array.isArray(pageSections)) { + setIsLoading(false) + setIsResponding(false) + setHasSearchError(true) + console.error('Malformed response') + return + } + + setResults(pageSections) + }, + [supabaseClient] + ) + + const handleClippyConfirm = useCallback(async (query: string) => { + setResults(undefined) + setAnswer(undefined) + setIsResponding(false) + setHasClippyError(false) + setHasSearchError(false) + setIsLoading(true) + + const eventSource = new SSE(`${edgeFunctionUrl}/clippy-search`, { + headers: { + apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`, + 'Content-Type': 'application/json', + }, + payload: JSON.stringify({ query }), + }) + + function handleError(err: T) { + setIsLoading(false) + setIsResponding(false) + setHasClippyError(true) + console.error(err) + } + + eventSource.addEventListener('error', handleError) + eventSource.addEventListener('message', (e) => { + try { + setIsLoading(false) + + if (e.data === '[DONE]') { + setIsResponding(false) + return + } + + setIsResponding(true) + + const completionResponse: CreateCompletionResponse = JSON.parse(e.data) + const [{ text }] = completionResponse.choices + + setAnswer((answer) => { + return (answer ?? '') + text + }) + } catch (err) { + handleError(err) + } + }) + + eventSource.stream() + + eventSourceRef.current = eventSource + + setIsLoading(true) + }, []) + + const handleConfirm = useCallback( + (selectedTab: string, query: string) => { + switch (selectedTab) { + case 'search-panel': + return handleSearchConfirm(query) + case 'clippy-panel': + return handleClippyConfirm(query) + } + }, + [handleSearchConfirm, handleClippyConfirm] + ) + + function handleResetPrompt() { + eventSourceRef.current?.close() + eventSourceRef.current = undefined + setQuery('') + setResults(undefined) + setAnswer(undefined) + setIsResponding(false) + setHasClippyError(false) + setHasSearchError(false) + } + return ( + +
e.stopPropagation()} + > +
+ setQuery(e.target.value)} + icon={} + onKeyDown={(e) => { + switch (e.key) { + case 'Enter': + if (!query) { + return + } + handleConfirm(selectedTab, query) + return + default: + return + } + }} + /> +
+ +
+ {!isLoading && answer && ( +
+ +
+ )} +
+ { + setSelectedTab(tabId) + if (!query) { + handleResetPrompt() + return + } + handleConfirm(tabId, query) + }} + > + +
+ {!isLoading && !hasSearchError && !results && ( +
+

+ Search Supabase guides & reference docs +

+
+ )} + {results && results.length > 0 && ( +
+ {results.map((page) => { + const pageSections = page.sections.filter((section) => !!section.heading) + return ( +
+ + {pageSections.length > 0 && ( +
+
+
+ {pageSections.map((section) => ( + + ))} +
+
+ )} +
+ ) + })} +
+ )} + {isLoading && ( +
+ {} +

Searching for results

+
+ )} + {results && results.length === 0 && ( +
+ +

No results found.

+ +
+ )} + {hasSearchError && ( +
+ +

+ Sorry, looks like we're having some issues with search! +

+

Please try again in a bit.

+ +
+ )} +
+
+ + {!isLoading && !answer && !hasClippyError && ( +
+
+

Not sure where to start?

+ +
    + {questions.map((question) => { + const key = question.replace(/\s+/g, '_') + return ( +
  • + +
  • + ) + })} +
+
+
+ )} + {answer && ( +
+ {cantHelp ? ( +

+

+ +

Sorry, I don't know how to help with that.

+
+ +

+ ) : ( +
+ { + const supabaseUrl = new URL('https://supabase.com') + const linkUrl = new URL(href, 'https://supabase.com') + + if (linkUrl.origin === supabaseUrl.origin) { + return linkUrl.toString() + } + + return href + }} + components={components} + > + {answer} + +
+ )} +
+ )} + {isLoading && ( +
+ {} +

Searching for results

+
+ )} + {hasClippyError && ( +
+ +

+ Sorry, looks like Clippy is having a hard time! +

+

Please try again in a bit.

+ +
+ )} +
+
+
+ Powered by OpenAI. + + Read the blog post + +
+
+ {status ? ( + + {(isLoading || isResponding) && ( + + )} + {status} + + ) : ( + <> + )} + Clippy +
+
+
+
+
+
+
+ ) +} + +export default SearchModal diff --git a/apps/docs/components/Search/SearchProvider.tsx b/apps/docs/components/Search/SearchProvider.tsx new file mode 100644 index 00000000000..4175c159c29 --- /dev/null +++ b/apps/docs/components/Search/SearchProvider.tsx @@ -0,0 +1,80 @@ +import { + createContext, + FC, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import SearchModal from './SearchModal' + +export type SearchContextValue = { + isOpen: boolean + open: () => void + close: () => void + query: string + setQuery: (query: string) => void +} + +export const SearchContext = createContext(null) + +export const useSearch = () => { + const { isOpen, open, close, query, setQuery } = useContext(SearchContext) + + return { isOpen, open, close, query, setQuery } +} + +const SearchProvider: FC> = ({ children }) => { + const [isOpen, setIsOpen] = useState(false) + const [query, setQuery] = useState('') + + const open = useCallback(() => { + setIsOpen(true) + document.body.classList.add('DocSearch--active') + }, []) + + const close = useCallback(() => { + setIsOpen(false) + document.body.classList.remove('DocSearch--active') + }, []) + + useSearchKeyboardEvents({ + open, + close, + }) + + return ( + + {children} + {isOpen && createPortal(, document.body)} + + ) +} + +function useSearchKeyboardEvents({ open, close }) { + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + switch (event.key) { + case 'Escape': + close() + return + case 'k': + case '/': + if (event.metaKey || event.ctrlKey) { + open() + } + return + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => { + window.removeEventListener('keydown', onKeyDown) + } + }, [open, close]) +} + +export default SearchProvider diff --git a/apps/docs/components/Search/SearchResult.tsx b/apps/docs/components/Search/SearchResult.tsx new file mode 100644 index 00000000000..d3689024ec5 --- /dev/null +++ b/apps/docs/components/Search/SearchResult.tsx @@ -0,0 +1,54 @@ +import Link from 'next/link' +import { FC } from 'react' +import { IconBookOpen, IconHash } from '~/../../packages/ui' +import { useSearch } from './SearchProvider' + +export enum SearchResultType { + Document = 'document', + Section = 'section', +} + +type Props = { + href: string + type: SearchResultType + title: string + chip?: string +} + +const SearchResult: FC = ({ href, type, title, chip }) => { + const { close } = useSearch() + + return ( + + +
+ {getIconByType(type)} +
+
+ {chip && ( +
+ {chip} +
+ )} +
{title}
+
+
+ + ) +} + +function getIconByType(type: SearchResultType) { + switch (type) { + case SearchResultType.Document: + return + case SearchResultType.Section: + return + default: + throw new Error(`Unknown search result type '${type}'`) + } +} + +export default SearchResult diff --git a/apps/docs/pages/_app.tsx b/apps/docs/pages/_app.tsx index f26400c6502..1557f8ec9d7 100644 --- a/apps/docs/pages/_app.tsx +++ b/apps/docs/pages/_app.tsx @@ -4,8 +4,9 @@ import { ThemeProvider } from 'common/Providers' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { AppPropsWithLayout } from 'types' -import { CommandMenuProvider } from 'ui' +// import { CommandMenuProvider } from 'ui' import Favicons from '~/components/Favicons' +import SearchProvider from '~/components/Search/SearchProvider' import SiteLayout from '~/layouts/SiteLayout' import { IS_PLATFORM, LOCAL_SUPABASE } from '~/lib/constants' import { post } from '~/lib/fetchWrappers' @@ -66,20 +67,24 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { {IS_PLATFORM || LOCAL_SUPABASE ? ( - + + {/* */} - + {/* */} + ) : ( - + + {/* */} - + {/* */} + )} diff --git a/studio/pages/_app.tsx b/studio/pages/_app.tsx index f9c80b2081a..054c4fe8cfa 100644 --- a/studio/pages/_app.tsx +++ b/studio/pages/_app.tsx @@ -40,7 +40,7 @@ import { PortalToast, RouteValidationWrapper, AppBannerWrapper } from 'component import PageTelemetry from 'components/ui/PageTelemetry' import FlagProvider from 'components/ui/Flag/FlagProvider' import useAutoAuthRedirect from 'hooks/misc/useAutoAuthRedirect' -import CommandMenuProvider from 'ui/src/components/Command/CommandMenuProvider' +// import CommandMenuProvider from 'ui/src/components/Command/CommandMenuProvider' import { TooltipProvider } from '@radix-ui/react-tooltip' dayjs.extend(customParseFormat) @@ -118,9 +118,9 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - - {getLayout()} - + {/* */} + {getLayout()} + {/* */}