From 0fcd98595ec79d393112bcd14512f55a08ab1fb1 Mon Sep 17 00:00:00 2001 From: Ashish Padhy <100484401+Shurtu-gal@users.noreply.github.com> Date: Sat, 9 Mar 2024 23:32:54 +0530 Subject: [PATCH] feat: migrate algolia search (#2752) Co-authored-by: Akshat Nema <76521428+akshatnema@users.noreply.github.com>%0ACo-authored-by: akshatnema --- components/AlgoliaSearch.tsx | 325 +++++++++++++++++++++++++++++++++++ package-lock.json | 6 +- pages/_app.tsx | 22 ++- pages/index.tsx | 43 ++++- 4 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 components/AlgoliaSearch.tsx diff --git a/components/AlgoliaSearch.tsx b/components/AlgoliaSearch.tsx new file mode 100644 index 00000000000..5e5a9dbddd0 --- /dev/null +++ b/components/AlgoliaSearch.tsx @@ -0,0 +1,325 @@ +/* eslint-disable no-underscore-dangle */ +import { DocSearchModal } from '@docsearch/react'; +import type { DocSearchHit, InternalDocSearchHit, StoredDocSearchHit } from '@docsearch/react/dist/esm/types'; +import clsx from 'clsx'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +export const INDEX_NAME = 'asyncapi'; +export const DOCS_INDEX_NAME = 'asyncapi-docs'; +const APP_ID = 'Z621OGRI9Y'; +const API_KEY = '5a4122ae46ce865146d23d3530595d38'; + +interface ISearchContext { + isOpen: boolean; + onOpen: (indexName?: string) => void; + onClose: () => void; + onInput?: (e: React.KeyboardEvent) => void; +} + +const SearchContext = createContext({} as ISearchContext); + +interface IHitProps { + hit: (StoredDocSearchHit | InternalDocSearchHit) & { + __is_result?: () => boolean; + __is_parent?: () => boolean; + __is_child?: () => boolean; + __is_first?: () => boolean; + __is_last?: () => boolean; + } + children: React.ReactNode; +} + +interface AlgoliaModalProps { + onClose: (event?: React.MouseEvent) => void; + initialQuery: string; + indexName: string; +} + +interface IUseDocSearchKeyboardEvents { + isOpen: boolean; + onOpen: (indexName?: string) => void; + onClose: () => void; + onInput?: (e: React.KeyboardEvent) => void; +} + +type ISearchButtonProps = Omit, 'children'> & { + children?: React.ReactNode | (({ actionKey }: { actionKey: { shortKey: string; key: string } }) => React.ReactNode); + indexName?: string; +}; + +/** + * + * @description The function used to transform the items + * @param {StoredDocSearchHit[]} items - The items to be transformed + */ +function transformItems(items: DocSearchHit[]) { + return items.map((item, index) => { + // We transform the absolute URL into a relative URL to + // leverage Next's preloading. + const a = document.createElement('a'); + + a.href = item.url; + + const hash = a.hash === '#content-wrapper' || a.hash === '#header' ? '' : a.hash; + + if (item.hierarchy?.lvl0) { + // eslint-disable-next-line no-param-reassign + item.hierarchy.lvl0 = item.hierarchy.lvl0.replace(/&/g, '&'); + } + + return { + ...item, + url: `${a.pathname}${hash}`, + __is_result: () => true, + __is_parent: () => item.type === 'lvl1' && items.length > 1 && index === 0, + __is_child: () => item.type !== 'lvl1' && + items.length > 1 && + items[0].type === 'lvl1' && + index !== 0, + __is_first: () => index === 1, + __is_last: () => index === items.length - 1 && index !== 0 + }; + }); +} + +/** + * + * @description The hit component used for the Algolia search + * @param {IHitProps} props - The props of the hit + */ +function Hit({ hit, children }: IHitProps) { + return ( + + {children} + + ); +} + +/** + * + * @description The Algolia modal used for searching the website + * @param {IAlgoliaModalProps} props - The props of the Algolia modal + */ +function AlgoliaModal({ onClose, initialQuery, indexName }: AlgoliaModalProps) { + const router = useRouter(); + + return createPortal( { + return `https://github.com/asyncapi/website/issues/new?title=Cannot%20search%20given%20query:%20${query}`; + }} + />, + document.body); +} + +/** + * @description The function used to check if the content is being edited + * @param {KeyboardEvent} event - The keyboard event + * @returns {boolean} - Whether the content is being edited + */ +function isEditingContent(event: KeyboardEvent) { + const element = event.target; + const { tagName } = element as HTMLElement; + + return ( + (element as HTMLElement).isContentEditable || + tagName === 'INPUT' || + tagName === 'SELECT' || + tagName === 'TEXTAREA' + ); +} + +/** + * @description The function used to get the action key + * @returns {Object} - The action key + */ +function getActionKey() { + if (typeof navigator !== 'undefined') { + if (/(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent || navigator.platform)) { + return { + shortKey: '⌘', + key: 'Command' + }; + } + + return { + shortKey: 'Ctrl', + key: 'Control' + }; + } + + return { + shortKey: 'Ctrl', + key: 'Control' + }; +} + +/** + * + * @description The hook used for the Algolia search keyboard events + * @param {IUseDocSearchKeyboardEvents} props - The props of the useDocSearchKeyboardEvents hook + */ +function useDocSearchKeyboardEvents({ isOpen, onOpen, onClose }: IUseDocSearchKeyboardEvents) { + useEffect(() => { + /** + * @description The function used to handle the keyboard event. + * @description It opens the search modal when the '/' key is pressed + * @description It closes the search modal when the 'Escape' key is pressed + * @description It opens the search modal when the 'k' key is pressed with the 'Command' or 'Control' key + * @param {KeyboardEvent} event - The keyboard event + * @returns {void} + */ + function onKeyDown(event: KeyboardEvent): void { + if ( + (event.key === 'Escape' && isOpen) || + (event.key === 'k' && (event.metaKey || event.ctrlKey)) || + (!isEditingContent(event) && event.key === '/' && !isOpen) + ) { + event.preventDefault(); + + if (isOpen) { + onClose(); + } else if (!document.body.classList.contains('DocSearch--active')) { + let indexName = INDEX_NAME; + + if (typeof document !== 'undefined') { + const loc = document.location; + + indexName = loc.pathname.startsWith('/docs') ? DOCS_INDEX_NAME : INDEX_NAME; + } + onOpen(indexName); + } + } + } + + window.addEventListener('keydown', onKeyDown); + + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, [isOpen, onOpen, onClose]); +} + +/** + * + * @description The Algolia search component used for searching the website + * @param {React.ReactNode} children - The content of the page + */ +export default function AlgoliaSearch({ children } : { children: React.ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const [indexName, setIndexName] = useState(INDEX_NAME); + const [initialQuery, setInitialQuery] = useState(); + + const onOpen = useCallback((_indexName?: string) => { + if (_indexName) { + setIndexName(_indexName); + } + setIsOpen(true); + }, [setIsOpen, setIndexName]); + + const onClose = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + const onInput = useCallback((e: React.KeyboardEvent) => { + setIsOpen(true); + setInitialQuery(e.key); + }, + [setIsOpen, setInitialQuery]); + + useDocSearchKeyboardEvents({ + isOpen, + onOpen, + onClose, + onInput + }); + + return ( + <> + + + + + {children} + + {isOpen && } + + ); +} + +/** + * + * @description The search button component used for opening the Algolia search + * @param {ISearchButtonProps} props - The props of the search button + */ +export function SearchButton({ children, indexName = INDEX_NAME, ...props }: ISearchButtonProps) { + const { onOpen, onInput } = useContext(SearchContext); + const searchButtonRef = useRef(null); + const actionKey = getActionKey(); + + useEffect(() => { + /** + * @description It triggers the onInput event when a key is pressed and the search button is focused + * @description It starts search with the key pressed + * @param {KeyboardEvent} event - The keyboard event + * @returns {void} + */ + function onKeyDown(event: KeyboardEvent) { + if (searchButtonRef && searchButtonRef.current === document.activeElement && onInput) { + if (/[a-zA-Z0-9]/.test(event.key)) { + onInput(event as unknown as React.KeyboardEvent); + } + } + } + + window.addEventListener('keydown', onKeyDown); + + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, [onInput, searchButtonRef]); + + return ( + + ); +} diff --git a/package-lock.json b/package-lock.json index 5c3ce98c37f..3b3a63c8f70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2227,9 +2227,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.2.19", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.19.tgz", - "integrity": "sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==", + "version": "18.2.21", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.21.tgz", + "integrity": "sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw==", "dev": true, "dependencies": { "@types/react": "*" diff --git a/pages/_app.tsx b/pages/_app.tsx index 265695462c8..a2615b36fe8 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -3,14 +3,32 @@ import '../styles/globals.css'; import type { AppProps } from 'next/app'; +import { + defaultLanguage, + defaultNamespace, + I18nProvider, + languages, + namespaces +} from '@/utils/i18n'; + +import loadLocales from '../utils/locales'; + /** * @description The MyApp component is the root component for the application. */ function MyApp({ Component, pageProps }: AppProps) { + const i18n = { + languages, + defaultLanguage, + namespaces, + defaultNamespace, + locales: loadLocales() + }; + return ( -
+ -
+ ); } diff --git a/pages/index.tsx b/pages/index.tsx index a4f7cb89848..177eeaa7eca 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,16 +1,57 @@ import { Inter } from 'next/font/google'; +import AlgoliaSearch, { SearchButton } from '@/components/AlgoliaSearch'; +import Button from '@/components/buttons/Button'; +import IconArrowRight from '@/components/icons/ArrowRight'; +import IconLoupe from '@/components/icons/Loupe'; + +import { useTranslation } from '../utils/i18n'; + const inter = Inter({ subsets: ['latin'] }); /** * @description The Home component is the main page of the application. */ export default function Home() { + const { t } = useTranslation('landing-page'); + return (
- Hello World +
+
); }