Skip to content

Commit

Permalink
Implement SearchBar using Algolia
Browse files Browse the repository at this point in the history
  • Loading branch information
james03160927 committed Aug 26, 2024
1 parent 2a052af commit 8c4d3ef
Show file tree
Hide file tree
Showing 11 changed files with 4,778 additions and 4,894 deletions.
5 changes: 4 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@ 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_SENTRY_DSN=""
NEXT_PUBLIC_ALGOLIA_APP_ID=RJTNV0RBWH
NEXT_PUBLIC_ALGOLIA_SEARCH_KEY=11e730dd1d8ea5975e6fdbb630e7164e
187 changes: 187 additions & 0 deletions components/Search/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import '@algolia/autocomplete-theme-classic';
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 {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';
import {debounce} from '@algolia/autocomplete-shared';

import {
INSTANT_SEARCH_INDEX_NAME,
INSTANT_SEARCH_QUERY_SUGGESTIONS,
} from 'src/constants'; // Only import necessary constants

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

type SetInstantSearchUiStateOptions = {
query: string;
};

export function Autocomplete({
searchClient,
className,
...autocompleteProps
}: AutocompleteProps) {
// Refs for DOM elements and the root for rendering the panel
const autocompleteContainer = useRef<HTMLDivElement>(null);
const panelRootRef = useRef<Root | null>(null);
const rootRef = useRef<HTMLElement | null>(null);

// Hooks for managing state from Algolia's useSearchBox and usePagination
const {query, refine: setQuery} = useSearchBox();

// Local state to manage instant search UI state with debounced updates
const [instantSearchUiState, setInstantSearchUiState] =
useState<SetInstantSearchUiStateOptions>({query});
const debouncedSetInstantSearchUiState = debounce(
setInstantSearchUiState,
500
);

// Update the query in Algolia's useSearchBox when the instant search UI state changes
useEffect(() => {
setQuery(instantSearchUiState.query);
}, [instantSearchUiState, setQuery]);

// Memoize the creation of plugins for recent searches and query suggestions
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}) {
// Update the instant search state with the selected suggestion
setInstantSearchUiState({query: item.name});

// Update the input field with the selected suggestion
const inputElement = document.querySelector('.aa-Input');
if (inputElement) {
inputElement.value = item.name;
}

// Perform a search on the main index and add the query to recent searches
searchClient.search([{
indexName: INSTANT_SEARCH_INDEX_NAME,
query: item.name,
params: {hitsPerPage: 10},
}]).then(() => {
recentSearches.data!.addItem({id: item.name, label: item.name});
}).catch(err => {
console.error('Search failed:', err);
});

debouncedSetInstantSearchUiState({query: item.name});
},
templates: {
item({item}) {
return (
<div className="aa-ItemWrapper">
<div className="aa-ItemContent">
<div className="aa-ItemTitle">
{item.name}
</div>
</div>
</div>
);
},
},
};
},
});

return [recentSearches, querySuggestions];
}, [searchClient, debouncedSetInstantSearchUiState]);

// Initialize the autocomplete instance with the specified plugins and options
useEffect(() => {
if (!autocompleteContainer.current) {
return;
}
const autocompleteInstance = autocomplete({
...autocompleteProps,
container: autocompleteContainer.current,
initialState: {query},
insights: true,
plugins,
onSubmit({state}) {
// Perform a search on form submission
setInstantSearchUiState({query: state.query});

searchClient.search([{
indexName: INSTANT_SEARCH_INDEX_NAME,
query: state.query,
params: {hitsPerPage: 10},
}]).catch(err => {
console.error('Search failed:', err);
});
},
onStateChange({prevState, state}) {
// Update the instant search UI state on query change
if (prevState.query !== state.query) {
debouncedSetInstantSearchUiState({query: state.query});
}
},
renderer: {
createElement, Fragment, render: () => {
}
},
render({children}, root) {
// Ensure the root is mounted correctly for rendering
if (!panelRootRef.current || rootRef.current !== root) {
rootRef.current = root;
panelRootRef.current?.unmount();
panelRootRef.current = createRoot(root);
}

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

return () => autocompleteInstance.destroy();
}, [plugins, searchClient, autocompleteProps, query, debouncedSetInstantSearchUiState]);

return (
<div className={className}>
<div ref={autocompleteContainer}/>
</div>
);
}

export default Autocomplete;
26 changes: 26 additions & 0 deletions components/Search/EmptyQueryBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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;
73 changes: 73 additions & 0 deletions components/Search/SearchHit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';
import { Snippet } from 'react-instantsearch';
import { useRouter } from 'next/router';

type HitProps = {
hit: {
id: string;
name: string;
publisher_id: string;
total_install: number;
version: string;
};
};

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">
<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'
Loading

0 comments on commit 8c4d3ef

Please sign in to comment.