diff --git a/.gitignore b/.gitignore index 6dbefc3ed5566..3e5db9342a1f8 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,7 @@ test-results playwright-report ## MacOS Ignored Files -.DS_Store \ No newline at end of file +.DS_Store + +## Other Files +.env diff --git a/apps/site/components/Common/Search/index.tsx b/apps/site/components/Common/Search/index.tsx deleted file mode 100644 index 0c12664b61826..0000000000000 --- a/apps/site/components/Common/Search/index.tsx +++ /dev/null @@ -1,145 +0,0 @@ -'use client'; - -import { OramaSearchBox, OramaSearchButton } from '@orama/react-components'; -import { useTranslations, useLocale } from 'next-intl'; -import { useTheme } from 'next-themes'; -import type { FC } from 'react'; - -import { useRouter } from '#site/navigation.mjs'; -import { - ORAMA_CLOUD_ENDPOINT, - ORAMA_CLOUD_API_KEY, - DEFAULT_ORAMA_QUERY_PARAMS, - DEFAULT_ORAMA_SUGGESTIONS, - BASE_URL, -} from '#site/next.constants.mjs'; - -type ResultMapDescription = { - path: string; - pageSectionTitle: string; -}; - -type ResultMapPath = { path: string; siteSection: string }; - -import { themeConfig, translationKeys } from './utils'; - -const uppercaseFirst = (word: string) => - word.charAt(0).toUpperCase() + word.slice(1); - -const getFormattedPath = (path: string, title: string) => - `${path - .replace(/#.+$/, '') - .split('/') - .map(element => element.replaceAll('-', ' ')) - .map(element => uppercaseFirst(element)) - .filter(Boolean) - .join(' > ')} — ${title}`; - -const SearchButton: FC = () => { - const { resolvedTheme } = useTheme(); - const t = useTranslations(); - const locale = useLocale(); - const colorScheme = resolvedTheme as 'light' | 'dark'; - const router = useRouter(); - - const sourceMap = { - title: 'pageSectionTitle', - description: 'formattedPath', - path: 'path', - }; - - const resultMap = { - ...sourceMap, - description: ({ path, pageSectionTitle }: ResultMapDescription) => - getFormattedPath(path, pageSectionTitle), - path: ({ path, siteSection }: ResultMapPath) => - siteSection.toLowerCase() === 'docs' ? `/${path}` : `/${locale}/${path}`, - section: 'siteSection', - }; - - return ( - <> - - {t('components.search.searchPlaceholder')} - - - [key, t(`components.search.${key}`)]) - )} - searchParams={DEFAULT_ORAMA_QUERY_PARAMS} - suggestions={DEFAULT_ORAMA_SUGGESTIONS} - chatMarkdownLinkHref={({ href }) => { - if (!href) { - return href; - } - - const baseURLObject = new URL(BASE_URL); - const baseURLHostName = baseURLObject.hostname; - - const searchBoxURLObject = new URL(href); - const searchBoxURLHostName = searchBoxURLObject.hostname; - const serachBoxURLPathName = searchBoxURLObject.pathname; - - // We do not want to add the locale to the url for external links and docs links - if ( - baseURLHostName !== searchBoxURLHostName || - serachBoxURLPathName.startsWith('/docs/') - ) { - return href; - } - - const URLWithLocale = new URL( - `${locale}${searchBoxURLObject.pathname}`, - searchBoxURLObject.origin - ); - - return URLWithLocale.href; - }} - onAnswerSourceClick={event => { - event.preventDefault(); - - const baseURLObject = new URL(BASE_URL); - - const { path } = event.detail.source; - - const finalPath = path.startsWith('docs/') - ? path - : `${locale}/${path}`; - - const finalURL = new URL(finalPath, baseURLObject); - - window.open(finalURL, '_blank'); - }} - onSearchResultClick={event => { - event.preventDefault(); - - const fullURLObject = new URL(event.detail.result.path, BASE_URL); - - // result.path already contains LOCALE. Locale is set to undefined here so router does not add it once again. - router.push(fullURLObject.href, { locale: undefined }); - }} - /> - - ); -}; - -export default SearchButton; diff --git a/apps/site/components/Common/Search/utils.ts b/apps/site/components/Common/Search/utils.ts deleted file mode 100644 index dca1281ad7154..0000000000000 --- a/apps/site/components/Common/Search/utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -export const themeConfig = { - typography: { - '--font-primary': 'var(--font-open-sans)', - }, - colors: { - light: { - '--text-color-primary': 'var(--color-neutral-900)', - '--text-color-accent': 'var(--color-green-600)', - '--background-color-secondary': 'var(--color-neutral-100)', - '--background-color-tertiary': 'var(--color-neutral-300)', - '--border-color-accent': 'var(--color-green-600)', - '--border-color-primary': 'var(--color-neutral-200)', - '--border-color-tertiary': 'var(--color-green-700)', - '--button-background-color-primary': 'var(--color-green-600)', - '--button-background-color-secondary': 'var(--color-white)', - '--button-background-color-secondary-hover': 'var(--color-neutral-100)', - '--button-border-color-secondary': 'var(--color-neutral-300)', - '--button-text-color-secondary': 'var(--color-neutral-900)', - '--chat-button-border-color-gradientThree': 'var(--color-green-400)', - '--chat-button-border-color-gradientFour': 'var(--color-green-700)', - '--chat-button-background-color-gradientOne': 'var(--color-green-600)', - '--chat-button-background-color-gradientTwo': 'var(--color-green-300)', - }, - dark: { - '--text-color-primary': 'var(--color-neutral-100)', - '--text-color-accent': 'var(--color-green-400)', - '--background-color-secondary': 'var(--color-neutral-950)', - '--background-color-tertiary': 'var(--color-neutral-900)', - '--border-color-accent': 'var(--color-green-400)', - '--border-color-primary': 'var(--color-neutral-900)', - '--border-color-tertiary': 'var(--color-green-300)', - '--button-background-color-primary': 'var(--color-green-400)', - '--button-background-color-secondary': 'var(--color-neutral-950)', - '--button-background-color-secondary-hover': 'var(--color-neutral-900)', - '--button-border-color-secondary': 'var(--color-neutral-900)', - '--button-text-color-secondary': 'var(--color-neutral-200)', - '--chat-button-border-color-gradientThree': 'var(--color-green-400)', - '--chat-button-border-color-gradientFour': 'var(--color-green-700)', - '--chat-button-background-color-gradientOne': 'var(--color-green-400)', - '--chat-button-background-color-gradientTwo': 'var(--color-green-800)', - }, - }, -}; - -export const translationKeys = [ - 'searchPlaceholder', - 'chatPlaceholder', - 'noResultsFoundFor', - 'suggestions', - 'seeAll', - 'addMore', - 'clearChat', - 'errorMessage', - 'disclaimer', - 'startYourSearch', - 'initErrorSearch', - 'initErrorChat', - 'chatButtonLabel', - 'searchButtonLabel', -] as const; diff --git a/apps/site/components/Common/Searchbox/ChatActions/index.module.css b/apps/site/components/Common/Searchbox/ChatActions/index.module.css new file mode 100644 index 0000000000000..a6081326ce8f9 --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatActions/index.module.css @@ -0,0 +1,44 @@ +@reference "../../../../styles/index.css"; + +.chatActionsContainer { + @apply flex + items-center + justify-end; +} + +.chatActionsList { + @apply flex + list-none + items-center + gap-2 + p-0; +} + +.chatAction { + @apply cursor-pointer + rounded-full + p-2 + text-neutral-800 + duration-300 + hover:bg-neutral-300 + focus:bg-neutral-300 + focus:outline-none + motion-safe:transition-colors + dark:text-neutral-400 + dark:hover:bg-neutral-900 + dark:focus:bg-neutral-900; + + svg { + @apply size-4; + } +} + +.chatActionIconSelected { + @apply text-green-600 + dark:text-green-400; +} + +.chatActionDisaliked { + @apply text-neutral-900 + dark:text-neutral-800; +} diff --git a/apps/site/components/Common/Searchbox/ChatActions/index.tsx b/apps/site/components/Common/Searchbox/ChatActions/index.tsx new file mode 100644 index 0000000000000..79f25bfd0e840 --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatActions/index.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { + DocumentCheckIcon, + ClipboardIcon, + ArrowPathIcon, + HandThumbDownIcon, +} from '@heroicons/react/24/solid'; +import type { Interaction } from '@orama/core'; +import { ChatInteractions } from '@orama/ui/components'; +import classNames from 'classnames'; +import type { FC } from 'react'; +import { useState } from 'react'; + +import styles from './index.module.css'; + +type ChatActionsProps = { + interaction: Interaction; +}; + +export const ChatActions: FC = ({ interaction }) => { + const [isDisliked, setIsDisliked] = useState(false); + + const dislikeMessage = () => setIsDisliked(!isDisliked); + + if (!interaction.response) { + return null; + } + + return ( +
+
    +
  • + + + +
  • +
  • + + {(copied: boolean) => + copied ? ( + + ) : ( + + ) + } + +
  • + {!interaction.loading && ( +
  • + +
  • + )} +
+
+ ); +}; diff --git a/apps/site/components/Common/Searchbox/ChatInput/index.module.css b/apps/site/components/Common/Searchbox/ChatInput/index.module.css new file mode 100644 index 0000000000000..3cb7979d6f233 --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatInput/index.module.css @@ -0,0 +1,99 @@ +@reference "../../../../styles/index.css"; + +.textareaContainer { + @apply px-1; +} + +.textareaWrapper { + @apply flex + items-center + rounded-2xl + border + border-neutral-300 + bg-neutral-100 + py-2 + pl-3 + pr-1 + dark:border-neutral-900 + dark:bg-neutral-950; +} + +.textareaField { + @apply flex-1 + border-0 + bg-transparent + text-neutral-900 + focus:outline-none + dark:text-neutral-200; +} + +.textareaButton { + @apply cursor-pointer + rounded-xl + bg-green-600 + p-2 + text-white + duration-300 + focus:bg-green-600/75 + focus:outline-none + disabled:cursor-not-allowed + disabled:bg-neutral-200/60 + disabled:text-neutral-800 + motion-safe:transition-colors + dark:bg-green-400 + dark:text-neutral-400 + focus:dark:bg-green-400/75 + disabled:dark:bg-neutral-900/60; + + svg { + @apply size-4; + } +} + +.textareaFooter { + @apply pt-1 + text-center + text-xs + text-neutral-800 + sm:text-sm + dark:text-neutral-500; +} + +.suggestionsWrapper { + @apply mb-4 + flex + items-center + gap-2 + overflow-x-auto + px-1 + text-sm + lg:justify-center; + + &::-webkit-scrollbar { + @apply hidden; + } +} + +.suggestionsItem { + @apply flex + size-max + cursor-pointer + whitespace-nowrap + rounded-full + border + border-neutral-300 + bg-neutral-200 + px-3 + py-1 + text-neutral-900 + duration-300 + hover:bg-neutral-300 + focus:bg-neutral-300 + focus:outline-none + motion-safe:transition-colors + dark:border-neutral-900 + dark:bg-neutral-950 + dark:text-neutral-200 + dark:hover:bg-neutral-900 + dark:focus:bg-neutral-900; +} diff --git a/apps/site/components/Common/Searchbox/ChatInput/index.tsx b/apps/site/components/Common/Searchbox/ChatInput/index.tsx new file mode 100644 index 0000000000000..2b78591179131 --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatInput/index.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { PaperAirplaneIcon } from '@heroicons/react/20/solid'; +import { PauseCircleIcon } from '@heroicons/react/24/solid'; +import { PromptTextArea, Suggestions } from '@orama/ui/components'; +import { useChat } from '@orama/ui/hooks'; +import { useTranslations } from 'next-intl'; +import type { FC } from 'react'; +import { useEffect, useRef } from 'react'; + +import styles from './index.module.css'; + +export const ChatInput: FC = () => { + const t = useTranslations(); + const { + context: { interactions }, + } = useChat(); + const textareaRef = useRef(null); + + const suggestions = [ + t('components.search.suggestionOne'), + t('components.search.suggestionTwo'), + t('components.search.suggestionThree'), + ]; + + const hasInteractions = !!interactions?.length; + + useEffect(() => { + setTimeout(() => { + textareaRef.current?.focus(); + }, 100); + }, []); + + return ( + <> + {!hasInteractions && ( + + {suggestions.map(suggestion => ( + + {suggestion} + + ))} + + )} +
+ + + } + className={styles.textareaButton} + > + + + +
+ {t('components.search.disclaimer')} +
+
+ + ); +}; diff --git a/apps/site/components/Common/Searchbox/ChatInteractions/index.module.css b/apps/site/components/Common/Searchbox/ChatInteractions/index.module.css new file mode 100644 index 0000000000000..2bb4384ee51f5 --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatInteractions/index.module.css @@ -0,0 +1,64 @@ +@reference "../../../../styles/index.css"; + +.chatInteractionsContainer { + @apply relative + mb-6 + flex + h-full + flex-1 + flex-col + items-start + overflow-auto + px-1; + + &::-webkit-scrollbar { + @apply size-1.5; + } + + &::-webkit-scrollbar-track { + @apply rounded-md + bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply rounded-md + bg-neutral-900; + } +} + +.chatInteractionsWrapper { + @apply flex + w-full + flex-wrap + gap-6; + + > div { + @apply w-full; + } +} + +.scrollDownButton { + @apply absolute + bottom-36 + left-1/2 + inline-flex + -translate-x-1/2 + items-center + justify-center + rounded-xl + bg-neutral-200 + p-2 + text-neutral-900 + duration-300 + focus:bg-neutral-300 + focus:outline-none + motion-safe:transition-colors + lg:bottom-28 + dark:bg-neutral-900 + dark:text-neutral-200 + focus:dark:bg-neutral-800; + + svg { + @apply size-4; + } +} diff --git a/apps/site/components/Common/Searchbox/ChatInteractions/index.tsx b/apps/site/components/Common/Searchbox/ChatInteractions/index.tsx new file mode 100644 index 0000000000000..2fd2a1c77f026 --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatInteractions/index.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { ArrowDownIcon } from '@heroicons/react/24/solid'; +import type { Interaction } from '@orama/core'; +import { ChatInteractions } from '@orama/ui/components'; +import { useScrollableContainer } from '@orama/ui/hooks/useScrollableContainer'; +import { useTranslations } from 'next-intl'; + +import { ChatMessage } from '../ChatMessage'; +import styles from './index.module.css'; + +export const ChatInteractionsContainer = () => { + const t = useTranslations(); + const { + containerRef, + scrollToBottom, + recalculateGoToBottomButton, + showGoToBottomButton, + } = useScrollableContainer(); + + return ( + <> +
+ scrollToBottom({ animated: true })} + className={styles.chatInteractionsWrapper} + > + {(interaction: Interaction) => ( + + )} + +
+ {showGoToBottomButton && ( + + )} + + ); +}; diff --git a/apps/site/components/Common/Searchbox/ChatMessage/index.module.css b/apps/site/components/Common/Searchbox/ChatMessage/index.module.css new file mode 100644 index 0000000000000..792c1857f9b8c --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatMessage/index.module.css @@ -0,0 +1,50 @@ +@reference "../../../../styles/index.css"; + +.chatUserPrompt { + @apply py-3; + + p { + @apply max-w-2xl + rounded-xl + text-neutral-900 + dark:text-neutral-200; + } +} + +.chatAssistantMessageWrapper { + @apply my-2 + rounded-xl + bg-neutral-100 + px-4 + py-1 + text-neutral-900 + empty:hidden + dark:bg-neutral-950 + dark:text-neutral-200; +} + +.typingIndicator { + @apply flex + items-center + gap-1 + rounded-xl + bg-neutral-200 + p-4 + dark:bg-neutral-950; +} + +.typingDot { + @apply animate-dot-move + size-1 + rounded-full + bg-neutral-500 + dark:bg-neutral-400; + + &:nth-child(2) { + @apply animate-dot-move-delay-200; + } + + &:nth-child(3) { + @apply animate-dot-move-delay-400; + } +} diff --git a/apps/site/components/Common/Searchbox/ChatMessage/index.tsx b/apps/site/components/Common/Searchbox/ChatMessage/index.tsx new file mode 100644 index 0000000000000..133d83a5a5bd4 --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatMessage/index.tsx @@ -0,0 +1,71 @@ +import type { Interaction } from '@orama/core'; +import { ChatInteractions } from '@orama/ui/components'; +import type { FC } from 'react'; + +import styles from './index.module.css'; +import { ChatActions } from '../ChatActions'; +import ChatSources from '../ChatSources'; + +type ChatMessageProps = { + interaction: Interaction; +}; + +const TypingIndicator: FC = () => ( +
+ + + +
+); + +export const ChatMessage: FC = ({ interaction }) => { + if (!interaction) { + return null; + } + + return ( + <> + +

{interaction?.query}

+
+ + + + +
+ +
+
+ + {interaction.response && ( +
+ + {interaction.response || ''} + + +
+ )} + + ); +}; diff --git a/apps/site/components/Common/Searchbox/ChatSources/index.module.css b/apps/site/components/Common/Searchbox/ChatSources/index.module.css new file mode 100644 index 0000000000000..0f6f09a15e77b --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatSources/index.module.css @@ -0,0 +1,53 @@ +@reference "../../../../styles/index.css"; + +.chatSources { + @apply mb-4 + flex + flex-nowrap + items-center + gap-3 + overflow-x-scroll + scroll-smooth + [-ms-overflow-style:none] + [scrollbar-width:none]; + + &::-webkit-scrollbar { + @apply hidden; + } +} + +.chatSource { + @apply flex + max-w-full + items-center + gap-2 + text-base; +} + +.chatSourceLink { + @apply w-3xs + rounded-xl + bg-neutral-100 + px-4 + py-2 + text-neutral-900 + duration-300 + hover:bg-neutral-200 + focus:bg-neutral-200 + focus:outline-none + motion-safe:transition-colors + dark:bg-neutral-950 + dark:text-neutral-200 + hover:dark:bg-neutral-900 + focus:dark:bg-neutral-900; +} + +.chatSourceTitle { + @apply max-w-full + overflow-hidden + truncate + text-ellipsis + whitespace-nowrap + text-sm + font-semibold; +} diff --git a/apps/site/components/Common/Searchbox/ChatSources/index.tsx b/apps/site/components/Common/Searchbox/ChatSources/index.tsx new file mode 100644 index 0000000000000..76ceca6c159fe --- /dev/null +++ b/apps/site/components/Common/Searchbox/ChatSources/index.tsx @@ -0,0 +1,46 @@ +import type { Interaction, AnyObject } from '@orama/core'; +import { ChatInteractions } from '@orama/ui/components'; +import type { FC } from 'react'; + +import styles from './index.module.css'; +import type { Document } from '../DocumentLink'; +import { DocumentLink } from '../DocumentLink'; + +type ChatSourcesProps = { + interaction: Interaction; +}; + +const ChatSources: FC = ({ interaction }) => { + if (!interaction?.sources) { + return null; + } + + return ( + + {(document: AnyObject, index: number) => ( +
+ {typeof document.pageSectionTitle === 'string' && ( + + + {document.pageSectionTitle && + document.pageSectionTitle.length > 25 + ? `${document.pageSectionTitle.substring(0, 25)}...` + : document.pageSectionTitle} + + + )} +
+ )} +
+ ); +}; + +export default ChatSources; diff --git a/apps/site/components/Common/Searchbox/DocumentLink/index.module.css b/apps/site/components/Common/Searchbox/DocumentLink/index.module.css new file mode 100644 index 0000000000000..31b802682b825 --- /dev/null +++ b/apps/site/components/Common/Searchbox/DocumentLink/index.module.css @@ -0,0 +1,32 @@ +@reference "../../../../styles/index.css"; + +.documentLink { + @apply rounded-xl + bg-white + px-4 + py-2 + text-neutral-900 + duration-300 + hover:bg-neutral-200 + focus:bg-neutral-200 + motion-safe:transition-colors + lg:bg-neutral-100 + dark:bg-neutral-950 + dark:text-neutral-200 + hover:dark:bg-neutral-900 + focus:dark:bg-neutral-900; + + svg { + @apply size-5; + } +} + +.documentTitle { + @apply max-w-full + overflow-hidden + truncate + text-ellipsis + whitespace-nowrap + text-sm + font-semibold; +} diff --git a/apps/site/components/Common/Searchbox/DocumentLink/index.tsx b/apps/site/components/Common/Searchbox/DocumentLink/index.tsx new file mode 100644 index 0000000000000..4c2164207ba02 --- /dev/null +++ b/apps/site/components/Common/Searchbox/DocumentLink/index.tsx @@ -0,0 +1,50 @@ +'use client'; + +import Link from 'next/link'; +import { useLocale } from 'next-intl'; +import type { FC } from 'react'; + +import styles from './index.module.css'; + +export type Document = { + path: string; + siteSection: string; + pageSectionTitle?: string; +}; + +type DocumentLinkProps = { + document: Document; + className?: string; + children?: React.ReactNode; + 'data-focus-on-arrow-nav'?: boolean; +}; + +export const DocumentLink: FC = ({ + document, + className = styles.documentLink, + children, + 'data-focus-on-arrow-nav': dataFocusOnArrowNav, + ...props +}) => { + const locale = useLocale(); + + const href = + document.siteSection?.toLowerCase() === 'docs' + ? `/${document.path}` + : `/${locale}/${document.path}`; + + return ( + + {children || ( + + {document.pageSectionTitle} + + )} + + ); +}; diff --git a/apps/site/components/Common/Searchbox/Footer/index.module.css b/apps/site/components/Common/Searchbox/Footer/index.module.css new file mode 100644 index 0000000000000..95ec47b281a27 --- /dev/null +++ b/apps/site/components/Common/Searchbox/Footer/index.module.css @@ -0,0 +1,53 @@ +@reference "../../../../styles/index.css"; + +.footer { + @apply flex + justify-center + border-t + border-neutral-200 + bg-neutral-100 + p-4 + align-baseline + lg:justify-between + lg:rounded-b-xl + dark:border-neutral-900 + dark:bg-neutral-950; +} + +.poweredByLink { + @apply flex + items-center + gap-2 + text-sm + text-neutral-800 + dark:text-neutral-600; +} + +.shortcutWrapper { + @apply hidden + items-center + gap-2 + lg:flex; +} + +.shortcutItem { + @apply flex + items-center + gap-2 + text-xs + text-neutral-800 + dark:text-neutral-600; +} + +.shortcutKey { + @apply font-ibm-plex-mono + rounded-md + bg-neutral-200 + p-1 + text-xs + dark:bg-neutral-900; + + svg { + @apply size-4; + } +} diff --git a/apps/site/components/Common/Searchbox/Footer/index.tsx b/apps/site/components/Common/Searchbox/Footer/index.tsx new file mode 100644 index 0000000000000..383ede4211022 --- /dev/null +++ b/apps/site/components/Common/Searchbox/Footer/index.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { + ArrowTurnDownLeftIcon, + ArrowDownIcon, + ArrowUpIcon, +} from '@heroicons/react/24/solid'; +import { useSearchDispatch } from '@orama/ui/contexts'; +import Image from 'next/image'; +import { useTranslations } from 'next-intl'; +import { useTheme } from 'next-themes'; +import { useEffect, useCallback } from 'react'; + +import styles from './index.module.css'; + +export const Footer = () => { + const t = useTranslations(); + const { resolvedTheme } = useTheme(); + const dispatch = useSearchDispatch(); + + const oramaLogo = `https://website-assets.oramasearch.com/orama-when-${resolvedTheme}.svg`; + + const clearAll = useCallback(() => { + dispatch({ type: 'SET_SEARCH_TERM', payload: { searchTerm: '' } }); + dispatch({ type: 'SET_SELECTED_FACET', payload: { selectedFacet: 'All' } }); + dispatch({ type: 'SET_RESULTS', payload: { results: [] } }); + }, [dispatch]); + + useEffect(() => { + clearAll(); + return () => { + clearAll(); + }; + }, [clearAll]); + + return ( +
+
+
+ + + + + {t('components.search.keyboardShortcuts.select')} + +
+
+ + + + + + + + {t('components.search.keyboardShortcuts.navigate')} + +
+
+ esc + + {t('components.search.keyboardShortcuts.close')} + +
+
+ +
+ ); +}; diff --git a/apps/site/components/Common/Searchbox/Search/index.module.css b/apps/site/components/Common/Searchbox/Search/index.module.css new file mode 100644 index 0000000000000..e82cf050151f3 --- /dev/null +++ b/apps/site/components/Common/Searchbox/Search/index.module.css @@ -0,0 +1,318 @@ +@reference "../../../../styles/index.css"; + +.searchContainer { + @apply flex + grow + flex-col + overflow-hidden; +} + +.searchResultsContainer { + @apply flex + grow + flex-col + overflow-y-auto; +} + +.searchInputWrapper { + @apply relative; + + svg { + @apply absolute + left-3 + top-1/2 + size-4 + -translate-y-1/2 + text-neutral-500 + dark:text-neutral-600; + } +} + +.searchInput { + @apply w-full + border-b + border-neutral-200 + bg-transparent + py-4 + pl-9 + pr-4 + text-sm + text-neutral-900 + placeholder:text-neutral-500 + focus:outline-none + dark:border-neutral-900 + dark:text-neutral-200 + dark:placeholder:text-neutral-600; +} + +.chatButtonWrapper { + @apply hidden + border-b + border-neutral-200 + p-2 + lg:block + dark:border-neutral-900; + + svg { + @apply size-4; + } +} + +.chatButton { + @apply flex + w-full + cursor-pointer + items-center + gap-2 + rounded-lg + border + border-transparent + bg-transparent + p-3 + text-sm + duration-300 + hover:bg-neutral-300 + focus-visible:border-green-600 + focus-visible:outline-none + motion-safe:transition-colors + dark:hover:bg-neutral-900 + dark:focus-visible:border-green-400; +} + +.chatButtonWithSearch { + @apply bg-neutral-300 + dark:bg-neutral-900; +} + +.suggestionsWrapper { + @apply flex + min-h-0 + flex-1 + flex-col + overflow-y-auto + pb-4 + text-neutral-900 + dark:text-neutral-200; +} + +.suggestionsList { + @apply mt-1 + space-y-1; +} + +.suggestionsTitle { + @apply my-3 + text-xs + font-semibold + uppercase + text-neutral-800 + dark:text-neutral-500; +} + +.suggestionItem { + @apply flex + cursor-pointer + items-center + gap-2 + rounded-lg + border + border-transparent + py-2 + text-sm + text-green-600 + focus-visible:border-green-600 + focus-visible:outline-none + dark:text-green-400 + dark:focus-visible:border-green-400; + + svg { + @apply size-5; + } +} + +.searchResultsWrapper { + @apply grow + overflow-y-auto + px-5 + pt-3 + text-neutral-900 + lg:grow-0 + dark:text-neutral-200; + + &::-webkit-scrollbar { + @apply size-1.5; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply rounded-md + bg-neutral-900; + } +} + +.noResultsWrapper { + @apply pb-31 + flex + h-full + items-center + justify-center + pt-10 + text-sm + text-neutral-800 + dark:text-neutral-500; +} + +.facetTabsWrapper { + @apply mb-2 + overflow-x-auto; + + &::-webkit-scrollbar { + @apply hidden; + } +} + +.facetTabItem { + @apply flex + cursor-pointer + items-center + gap-2 + rounded-3xl + border + border-neutral-200 + px-3 + py-1 + text-sm + duration-300 + focus:outline-none + focus-visible:bg-neutral-300 + motion-safe:transition-colors + dark:border-neutral-900 + dark:focus-visible:bg-neutral-900; +} + +.facetTabItemSelected { + @apply border-2 + border-green-600 + dark:border-green-400; +} + +.facetTabsList { + @apply flex + items-center + gap-2 + overflow-x-auto; + + &::-webkit-scrollbar { + @apply hidden; + } +} + +.facetTabItemCount { + @apply text-neutral-700; +} + +.searchResultsGroupWrapper { + @apply relative + items-start + overflow-y-auto; +} + +.searchResultsGroup { + @apply mb-3 + border-t + border-neutral-200 + dark:border-neutral-900; +} + +.searchResultsGroup:first-of-type { + @apply border-0; +} + +.searchResultsGroupTitle { + @apply mb-3 + mt-4 + pl-2 + text-sm + font-semibold + text-neutral-600 + dark:text-neutral-600; +} + +.searchResultsItem { + > a { + @apply flex + items-center + gap-4 + rounded-lg + border + border-transparent + px-2 + py-3 + text-sm + outline-none + duration-300 + hover:bg-neutral-300 + focus-visible:border-green-600 + focus-visible:bg-transparent + motion-safe:transition-colors + dark:bg-zinc-950 + dark:hover:bg-neutral-900 + lg:dark:bg-neutral-950; + } + + svg { + @apply size-5 + shrink-0; + } +} + +.searchResultsItemDescription { + @apply text-sm + text-neutral-600 + dark:text-neutral-700; +} + +.skeletonWrapper { + @apply flex + flex-col + gap-5 + py-6; +} + +.skeletonItem { + @apply flex + items-center + gap-4; +} + +.skeletonAnim { + @apply dark:animate-pulse-dark + animate-pulse + rounded-md; +} + +.skeletonAvatar { + @apply h-6 + w-5 + shrink-0; +} + +.skeletonText { + @apply flex + flex-1 + flex-col + gap-2; +} + +.skeletonLineShort { + @apply h-3 + w-1/3; +} + +.skeletonLineLong { + @apply h-3 + w-2/3; +} diff --git a/apps/site/components/Common/Searchbox/Search/index.tsx b/apps/site/components/Common/Searchbox/Search/index.tsx new file mode 100644 index 0000000000000..b85c3b2fd9532 --- /dev/null +++ b/apps/site/components/Common/Searchbox/Search/index.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { SparklesIcon, DocumentTextIcon } from '@heroicons/react/24/outline'; +import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; +import { + SearchInput, + FacetTabs, + SearchResults, + Suggestions, + SlidingPanel, +} from '@orama/ui/components'; +import { useSearch } from '@orama/ui/hooks/useSearch'; +import classNames from 'classnames'; +import { useTranslations } from 'next-intl'; +import type { FC, PropsWithChildren } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; + +import { DEFAULT_ORAMA_QUERY_PARAMS } from '#site/next.constants.mjs'; + +import styles from './index.module.css'; +import { getFormattedPath } from './utils'; +import { DocumentLink } from '../DocumentLink'; +import type { Document } from '../DocumentLink'; +import { Footer } from '../Footer'; + +type SearchProps = PropsWithChildren<{ + onChatTrigger: () => void; + mode?: 'search' | 'chat'; +}>; + +export const Search: FC = ({ onChatTrigger, mode = 'search' }) => { + const t = useTranslations(); + const { + dispatch, + context: { searchTerm, selectedFacet }, + } = useSearch(); + const containerRef = useRef(null); + + const clearAll = useCallback(() => { + dispatch({ type: 'SET_SEARCH_TERM', payload: { searchTerm: '' } }); + dispatch({ type: 'SET_SELECTED_FACET', payload: { selectedFacet: 'All' } }); + dispatch({ type: 'SET_RESULTS', payload: { results: [] } }); + }, [dispatch]); + + useEffect(() => { + clearAll(); + return () => { + clearAll(); + }; + }, [clearAll]); + + useEffect(() => { + const interactiveElements = containerRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (mode === 'search') { + interactiveElements?.forEach(el => { + el.removeAttribute('tabindex'); + }); + } else { + interactiveElements?.forEach(el => { + el.setAttribute('tabindex', '-1'); + }); + } + }, [mode]); + + return ( +
+ + + + + +
+
+ + + + {searchTerm ? `${searchTerm} - ` : ''} + {t('components.search.chatButtonLabel')} + + +
+ +
+ + + + {(group, isSelected) => ( + <> + + {group.name} + + ({group.count}) + + + + )} + + + + +
+ {[...Array(3)].map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ + + + {term => ( + <> + {term ? ( +
+

+ {t('components.search.noResultsFoundFor')} "{term}" +

+
+ ) : ( + +

+ {t('components.search.suggestions')} +

+ + + {t('components.search.suggestionOne')} + + + + {t('components.search.suggestionTwo')} + + + + {t('components.search.suggestionThree')} + +
+ )} + + )} +
+ + + {group => ( +
+

+ {group.name} +

+ + {hit => ( + + + +
+ {typeof hit.document?.pageSectionTitle === + 'string' && ( +

+ {hit.document?.pageSectionTitle} +

+ )} + {typeof hit.document?.pageSectionTitle === + 'string' && + typeof hit.document?.path === 'string' && ( +

+ {getFormattedPath( + hit.document?.path, + hit.document?.pageSectionTitle + )} +

+ )} +
+
+
+ )} +
+
+ )} +
+ +
+
+
+
+ ); +}; diff --git a/apps/site/components/Common/Searchbox/Search/utils.ts b/apps/site/components/Common/Searchbox/Search/utils.ts new file mode 100644 index 0000000000000..5fc41ceac70f1 --- /dev/null +++ b/apps/site/components/Common/Searchbox/Search/utils.ts @@ -0,0 +1,11 @@ +export const uppercaseFirst = (word: string) => + word.charAt(0).toUpperCase() + word.slice(1); + +export const getFormattedPath = (path: string, title: string) => + `${path + .replace(/#.+$/, '') + .split('/') + .map(element => element.replaceAll('-', ' ')) + .map(element => uppercaseFirst(element)) + .filter(Boolean) + .join(' > ')} — ${title}`; diff --git a/apps/site/components/Common/Searchbox/SlidingChatPanel/index.module.css b/apps/site/components/Common/Searchbox/SlidingChatPanel/index.module.css new file mode 100644 index 0000000000000..e13ca6df1a662 --- /dev/null +++ b/apps/site/components/Common/Searchbox/SlidingChatPanel/index.module.css @@ -0,0 +1,57 @@ +@reference "../../../../styles/index.css"; + +.slidingPanelCloseButton { + @apply absolute + right-6 + top-2 + z-20 + cursor-pointer + rounded-full + p-2 + text-neutral-700 + duration-300 + hover:bg-white/20 + focus:bg-white/20 + focus:outline-none + motion-safe:transition-colors + dark:text-white; + + svg { + @apply size-5; + } +} + +.slidingPanelContentWrapper { + @apply fixed + bottom-0 + left-0 + box-border + h-[95vh] + w-full + overflow-hidden + rounded-lg + border + border-neutral-300 + bg-white + p-0 + text-white + duration-300 + motion-safe:ease-in-out + dark:border-neutral-900 + dark:bg-zinc-950; +} + +.slidingPanelInner { + @apply relative + mx-auto + flex + h-full + max-w-4xl + flex-col + justify-between + py-6; +} + +.slidingPanelBottom { + @apply relative; +} diff --git a/apps/site/components/Common/Searchbox/SlidingChatPanel/index.tsx b/apps/site/components/Common/Searchbox/SlidingChatPanel/index.tsx new file mode 100644 index 0000000000000..ad9dc134b7d3a --- /dev/null +++ b/apps/site/components/Common/Searchbox/SlidingChatPanel/index.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { XMarkIcon } from '@heroicons/react/24/solid'; +import { SlidingPanel } from '@orama/ui/components'; +import { useTranslations } from 'next-intl'; +import type { FC, PropsWithChildren } from 'react'; + +import { ChatInput } from '../ChatInput'; +import styles from './index.module.css'; +import { ChatInteractionsContainer } from '../ChatInteractions'; + +type SlidingChatPanelProps = PropsWithChildren<{ + open: boolean; + onClose: () => void; + autoTriggerQuery?: string | null; + onAutoTriggerComplete?: () => void; +}>; + +export const SlidingChatPanel: FC = ({ + open, + onClose, +}) => { + const t = useTranslations(); + + return ( + <> + + + + + + +
+ +
+ +
+
+
+
+ + ); +}; diff --git a/apps/site/components/Common/Searchbox/index.module.css b/apps/site/components/Common/Searchbox/index.module.css new file mode 100644 index 0000000000000..e8db2814494cb --- /dev/null +++ b/apps/site/components/Common/Searchbox/index.module.css @@ -0,0 +1,189 @@ +@reference "../../../styles/index.css"; + +.searchboxContainer { + @apply grow; + + * { + @apply antialiased; + } +} + +.searchButton { + @apply flex + w-full + grow + cursor-pointer + items-center + justify-between + gap-1 + rounded-xl + border + border-neutral-300 + bg-white + p-1.5 + text-neutral-900 + duration-300 + hover:bg-neutral-100 + motion-safe:transition-colors + dark:border-neutral-900 + dark:bg-neutral-950 + dark:text-neutral-200 + hover:dark:bg-neutral-900; +} + +.searchButtonContent { + @apply relative + flex + flex-nowrap + items-center + gap-1 + text-sm; + + svg { + @apply size-4; + } +} + +.searchButtonShortcut { + @apply hidden + rounded-md + bg-neutral-300 + px-2 + py-1 + text-sm + text-neutral-800 + lg:inline + dark:bg-neutral-900 + dark:text-neutral-400; +} + +.modalWrapper { + @apply fixed + left-0 + top-0 + z-50 + mx-auto + my-0 + flex + size-full + items-start + justify-center + bg-white/70 + lg:pt-[5vh] + dark:bg-zinc-950/70; +} + +.modalInner { + @apply fixed + bottom-0 + top-0 + mx-auto + my-0 + flex + h-full + max-w-none + bg-white + lg:bottom-auto + lg:top-auto + lg:h-auto + lg:max-w-3xl + lg:bg-neutral-100 + dark:bg-zinc-950 + lg:dark:bg-neutral-950; +} + +.modalContent { + @apply flex + h-full + flex-col + border-neutral-200 + lg:h-auto + lg:max-h-[70vh] + lg:rounded-xl + lg:border + dark:border-neutral-900; +} + +.topBar { + @apply relative + flex + justify-center + p-4 + lg:hidden; +} + +.topBarArrow { + @apply absolute + left-4 + top-1/2 + -translate-y-1/2 + text-neutral-900 + dark:text-neutral-200; + + svg { + @apply size-4; + } +} + +.topBarTabs { + @apply rounded-4xl + flex + bg-neutral-200 + p-1 + text-sm + text-neutral-900 + dark:bg-neutral-900 + dark:text-neutral-200; +} + +.topBarTab { + @apply flex + items-center + gap-1 + px-4 + py-1; + + svg { + @apply size-4; + } +} + +.topBarTabActive { + @apply before:rounded-4xl + relative + z-10 + text-white + before:absolute + before:inset-0 + before:z-[-1] + before:bg-black + motion-safe:transition-colors + dark:text-neutral-900 + dark:before:bg-white; + + &.topBarTabAnimated:first-of-type { + @apply before:animate-slide-to-left; + } + + &.topBarTabAnimated:last-of-type { + @apply before:animate-slide-to-right; + } +} + +.mobileChatContainer { + @apply flex + grow + flex-col + overflow-hidden + px-4 + pb-4; +} + +.mobileChatTop { + @apply grow + overflow-hidden; +} + +.mobileChatBottom { + @apply mt-4; +} diff --git a/apps/site/components/Common/Searchbox/index.tsx b/apps/site/components/Common/Searchbox/index.tsx new file mode 100644 index 0000000000000..1482ea330d184 --- /dev/null +++ b/apps/site/components/Common/Searchbox/index.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { + MagnifyingGlassIcon, + ArrowLeftIcon, + SparklesIcon, +} from '@heroicons/react/24/solid'; +import { OramaCloud } from '@orama/core'; +import { SearchRoot, ChatRoot, Modal } from '@orama/ui/components'; +import { useSearchContext, useChatDispatch } from '@orama/ui/contexts'; +import classNames from 'classnames'; +import { useTranslations } from 'next-intl'; +import type { FC, PropsWithChildren } from 'react'; +import { useEffect, useState } from 'react'; + +import { + ORAMA_CLOUD_PROJECT_ID, + ORAMA_CLOUD_READ_API_KEY, +} from '#site/next.constants.mjs'; + +import '@orama/ui/styles.css'; + +import { ChatInput } from './ChatInput'; +import { ChatInteractionsContainer } from './ChatInteractions'; +import { Footer } from './Footer'; +import styles from './index.module.css'; +import { Search } from './Search'; +import { SlidingChatPanel } from './SlidingChatPanel'; + +const orama = + ORAMA_CLOUD_PROJECT_ID && ORAMA_CLOUD_READ_API_KEY + ? new OramaCloud({ + projectId: ORAMA_CLOUD_PROJECT_ID, + apiKey: ORAMA_CLOUD_READ_API_KEY, + }) + : null; + +const MobileTopBar: FC<{ + isChatOpen: boolean; + onClose: () => void; + onSelect: (mode: 'search' | 'chat') => void; +}> = ({ isChatOpen, onClose, onSelect }) => { + const [animated, setAnimated] = useState(false); + + function selectMode(mode: 'search' | 'chat') { + onSelect(mode); + + if (!animated) { + setAnimated(true); + } + } + + return ( +
+ +
+ + +
+
+ ); +}; + +const InnerSearchBox: FC void }>> = ({ + onClose, +}) => { + const [isChatOpen, setIsChatOpen] = useState(false); + const dispatch = useChatDispatch(); + const [mode, setMode] = useState<'search' | 'chat'>('search'); + const [shouldAutoTrigger, setShouldAutoTrigger] = useState(false); + const [autoTriggerValue, setAutoTriggerValue] = useState(null); + const { searchTerm } = useSearchContext(); + const [isMobileScreen, setIsMobileScreen] = useState(false); + + const displaySearch = + !isMobileScreen || (isMobileScreen && mode === 'search'); + + useEffect(() => { + const checkScreenSize = () => { + setIsMobileScreen(window.innerWidth < 1024); + }; + checkScreenSize(); + window.addEventListener('resize', checkScreenSize); + return () => { + window.removeEventListener('resize', checkScreenSize); + }; + }, []); + + const handleSelectMode = (newMode: 'search' | 'chat') => { + setMode(newMode); + if (newMode === 'chat') { + setIsChatOpen(true); + } + if (newMode === 'search') { + setIsChatOpen(false); + } + }; + + const handleChatOpened = (): void => { + setTimeout(() => { + setShouldAutoTrigger(false); + setAutoTriggerValue(null); + }, 1000); + }; + + return ( + <> + {isMobileScreen && ( + + )} + {displaySearch && ( + <> + { + setAutoTriggerValue(searchTerm ?? null); + handleSelectMode('chat'); + }} + /> + + )} + {isMobileScreen && mode === 'chat' && ( + <> +
+
+ +
+
+ +
+
+