Skip to content

Commit

Permalink
Implement SearchBar using Algolia (#21)
Browse files Browse the repository at this point in the history
* Implement SearchBar using Algolia

* Prettier.

* Add missing @algolia/autocomplete-shared

* Removing incorrect dependency.

* Update.

* Add react-instantsearch types.

* Add pagination.

* Fix unfocus issue.

* Fix dependencies.

* Fix ts issues.

---------

Co-authored-by: James Kwon <[email protected]>
Co-authored-by: Robin Huang <[email protected]>
  • Loading branch information
3 people authored Sep 5, 2024
1 parent e78f057 commit 2d6e1cc
Show file tree
Hide file tree
Showing 12 changed files with 2,014 additions and 1,078 deletions.
4 changes: 3 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ NEXT_PUBLIC_FIREBASE_API_KEY="AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE"
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="dreamboothy-dev.firebaseapp.com"
NEXT_PUBLIC_FIREBASE_PROJECT_ID="dreamboothy-dev"
NEXT_PUBLIC_BACKEND_URL="http://localhost:8080"
NEXT_PUBLIC_MIXPANEL_KEY=""
NEXT_PUBLIC_MIXPANEL_KEY=""
NEXT_PUBLIC_ALGOLIA_APP_ID="4E0RO38HS8"
NEXT_PUBLIC_ALGOLIA_SEARCH_KEY="684d998c36b67a9a9fce8fc2d8860579"
162 changes: 162 additions & 0 deletions components/Search/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { SearchClient } from 'algoliasearch/lite'
import type { BaseItem } from '@algolia/autocomplete-core'
import type { AutocompleteOptions } from '@algolia/autocomplete-js'

import {
createElement,
Fragment,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createRoot, Root } from 'react-dom/client'

import { usePagination, useSearchBox } from 'react-instantsearch'
import { autocomplete } from '@algolia/autocomplete-js'
import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches'
import { createQuerySuggestionsPlugin } from '@algolia/autocomplete-plugin-query-suggestions'
// @ts-ignore
import { debounce } from '@algolia/autocomplete-shared'

import { INSTANT_SEARCH_QUERY_SUGGESTIONS } from 'src/constants'

import '@algolia/autocomplete-theme-classic'

type AutocompleteProps = Partial<AutocompleteOptions<BaseItem>> & {
searchClient: SearchClient
className?: string
}

type SetInstantSearchUiStateOptions = {
query: string
}

export default function Autocomplete({
searchClient,
className,
...autocompleteProps
}: AutocompleteProps) {
const autocompleteContainer = useRef<HTMLDivElement>(null)
const panelRootRef = useRef<Root | null>(null)
const rootRef = useRef<HTMLElement | null>(null)

const { query, refine: setQuery } = useSearchBox()

const { refine: setPage } = usePagination()

const [instantSearchUiState, setInstantSearchUiState] =
useState<SetInstantSearchUiStateOptions>({ query })
const debouncedSetInstantSearchUiState = debounce(
setInstantSearchUiState,
500
)

useEffect(() => {
setQuery(instantSearchUiState.query)
setPage(0)
}, [instantSearchUiState, setQuery])

const plugins = useMemo(() => {
const recentSearches = createLocalStorageRecentSearchesPlugin({
key: 'instantsearch',
limit: 3,
transformSource({ source }) {
return {
...source,
onSelect({ item }) {
setInstantSearchUiState({ query: item.label })
},
}
},
})

const querySuggestions = createQuerySuggestionsPlugin({
searchClient,
indexName: INSTANT_SEARCH_QUERY_SUGGESTIONS,
getSearchParams() {
return recentSearches.data!.getAlgoliaSearchParams({
hitsPerPage: 6,
})
},
transformSource({ source }) {
return {
...source,
sourceId: 'querySuggestionsPlugin',
onSelect({ item }) {
setInstantSearchUiState({
query: item.query,
})
},
getItems(params) {
if (!params.state.query) {
return []
}

return source.getItems(params)
},
templates: {
...source.templates,
header({ items }) {
if (items.length === 0) {
return <Fragment />
}

return (
<Fragment>
<span className="aa-SourceHeaderTitle">
In other categories
</span>
<span className="aa-SourceHeaderLine" />
</Fragment>
)
},
},
}
},
})

return [recentSearches, querySuggestions]
}, [])

useEffect(() => {
if (!autocompleteContainer.current) {
return
}

const autocompleteInstance = autocomplete({
...autocompleteProps,
container: autocompleteContainer.current,
initialState: { query },
insights: true,
plugins,
onReset() {
setInstantSearchUiState({
query: '',
})
},
onSubmit({ state }) {
setInstantSearchUiState({ query: state.query })
},
onStateChange({ prevState, state }) {
if (prevState.query !== state.query) {
debouncedSetInstantSearchUiState({ query: state.query })
}
},
renderer: { createElement, Fragment, render: () => {} },
render({ children }, root) {
if (!panelRootRef.current || rootRef.current !== root) {
rootRef.current = root
panelRootRef.current?.unmount()
panelRootRef.current = createRoot(root)
}

panelRootRef.current.render(children)
},
})

return () => autocompleteInstance.destroy()
}, [plugins])

return <div className={className} ref={autocompleteContainer} />
}
29 changes: 29 additions & 0 deletions components/Search/EmptyQueryBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react'
import { useInstantSearch } from 'react-instantsearch'

type EmptyQueryBoundaryProps = {
children: React.ReactNode
fallback: React.ReactNode
}

const EmptyQueryBoundary: React.FC<EmptyQueryBoundaryProps> = ({
children,
fallback,
}) => {
const { indexUiState } = useInstantSearch()

// Render the fallback if the query is empty or too short
if (!indexUiState.query || indexUiState.query.length <= 1) {
return (
<>
{fallback}
<div hidden>{children}</div>
</>
)
}

// Render children if the query is valid
return <>{children}</>
}

export default EmptyQueryBoundary
77 changes: 77 additions & 0 deletions components/Search/SearchHit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React from 'react'
import { Snippet } from 'react-instantsearch'

import { useRouter } from 'next/router'

interface NodeHit {
id: string
name: string
publisher_id: string
total_install: number
version: string
}

type HitProps = {
hit: NodeHit
}

const Hit: React.FC<HitProps> = ({ hit }) => {
const router = useRouter()

const handleClick = () => {
router.push(`/nodes/${hit.id}`)
}

return (
<div
className="flex flex-col bg-gray-800 rounded-lg shadow cursor-pointer h-full dark:border-gray-700 lg:p-4"
onClick={handleClick}
>
<div className="flex flex-col px-4">
<h6 className="mb-2 text-base font-bold tracking-tight text-white break-words">
{/* @ts-ignore */}
<Snippet hit={hit} attribute="name" />
</h6>

{hit.version && (
<p className="mb-1 text-xs tracking-tight text-white">
<span>v{hit.version}</span>
</p>
)}

<p className="mb-1 text-xs font-light text-white text-nowrap mt-2">
{hit.publisher_id}
</p>

<div className="flex items-center flex-start align-center gap-1 mt-2">
{hit.total_install != 0 && (
<p className="flex justify-center text-center align-center">
<svg
className="w-4 h-4 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5-4 5-4-5m9 8h.01"
/>
</svg>
<p className="ml-1 text-xs font-bold text-white">
{hit.total_install}
</p>
</p>
)}
</div>
</div>
</div>
)
}

export default Hit
2 changes: 2 additions & 0 deletions components/Search/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './Autocomplete'
export * from './EmptyQueryBoundary'
46 changes: 46 additions & 0 deletions components/common/CustomSearchPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react'
import { Pagination as FlowbitePagination } from 'flowbite-react'
import { usePagination, UsePaginationProps } from 'react-instantsearch'
import { CustomThemePagination } from 'utils/comfyTheme'

export default function CustomSearchPagination(props: UsePaginationProps) {
const {
pages,
currentRefinement,
nbPages,
isFirstPage,
isLastPage,
refine,
createURL,
} = usePagination(props)

const handlePageChange = (page: number) => {
refine(page - 1) // Flowbite uses 1-based indexing, InstantSearch uses 0-based
}

return (
<div className="flex mt-2 sm:justify-center">
<FlowbitePagination
theme={CustomThemePagination as any} // Add 'as any' to bypass type checking
currentPage={currentRefinement + 1}
totalPages={nbPages}
onPageChange={handlePageChange}
showIcons={true}
previousLabel="Previous"
nextLabel="Next"
layout="pagination"
/>
</div>
)
}

function isModifierClick(event: React.MouseEvent) {
const isMiddleClick = event.button === 1
return Boolean(
isMiddleClick ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
)
}
Loading

0 comments on commit 2d6e1cc

Please sign in to comment.