Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate Orama for search #6257

Merged
merged 53 commits into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
6377ab1
feat: adds basic orama structure
micheleriva Jan 16, 2024
dfcea9f
Merge branch 'main' into feat/integrate-orama
micheleriva Jan 16, 2024
fdb40e9
feat: adds searchbox
micheleriva Jan 17, 2024
3bfc095
Merge branch 'main' into feat/integrate-orama
micheleriva Jan 17, 2024
f037bdb
Merge branch 'main' into feat/integrate-orama
micheleriva Jan 18, 2024
2dc00c1
feat: integrates searchbox
micheleriva Jan 19, 2024
5e2325c
Merge branch 'main' into feat/integrate-orama
micheleriva Jan 19, 2024
052a635
style: moves components to separate files
micheleriva Jan 19, 2024
5a8b189
feat: wip on searchbox
micheleriva Jan 19, 2024
cfacfca
feat: adds basic mobile styles
micheleriva Jan 19, 2024
9de0148
tmp: work in progress
micheleriva Jan 21, 2024
7c01328
Merge branch 'main' into feat/integrate-orama
micheleriva Jan 21, 2024
8a860b7
work in progress
micheleriva Jan 22, 2024
69bc5ca
feat: improves search page
micheleriva Jan 22, 2024
a24870d
style: addresses feedbacks on code style
micheleriva Jan 22, 2024
7ce813e
style: addresses feedbacks on code style
micheleriva Jan 22, 2024
56ce384
feat: adds texts management via i18n
micheleriva Jan 22, 2024
9c85831
fix: encodes URL components
micheleriva Jan 22, 2024
5e3cef2
style: addresses feedback
micheleriva Jan 22, 2024
a7aab20
style: addresses feedback
micheleriva Jan 22, 2024
ebc3742
docs: adds comments to Orama sync script
micheleriva Jan 22, 2024
bbc98da
style: addresses feedback
micheleriva Jan 22, 2024
5aab3cc
style: addresses feedback
micheleriva Jan 22, 2024
e1fde64
style: addresses feedback
micheleriva Jan 22, 2024
ff2086f
refactor: moves components and hooks into the correct folder structure
micheleriva Jan 22, 2024
2a7052d
refactor: moves components and hooks into the correct folder structure
micheleriva Jan 22, 2024
97f5de4
refactor: moves components and hooks into the correct folder structure
micheleriva Jan 22, 2024
5b773b8
refactor: moves components and hooks into the correct folder structure
micheleriva Jan 22, 2024
765033c
refactor: moves components and hooks into the correct folder structure
micheleriva Jan 22, 2024
f22681d
refactor: moves components and hooks into the correct folder structure
micheleriva Jan 22, 2024
cfd39e8
Merge branch 'main' into feat/integrate-orama
micheleriva Jan 22, 2024
da148e2
style: addresses feedback
micheleriva Jan 22, 2024
f876fa6
style: addresses feedback
micheleriva Jan 22, 2024
3be81f7
style: addresses feedback
micheleriva Jan 22, 2024
b1a012a
style: addresses feedback
micheleriva Jan 22, 2024
eb0915b
style: addresses feedback
micheleriva Jan 22, 2024
531cf0c
ci: adds Orama sync script to gh workflows
micheleriva Jan 23, 2024
f4e25b4
chore: removes useless log
micheleriva Jan 23, 2024
18c764e
Merge branch 'nodejs:main' into feat/integrate-orama
micheleriva Jan 24, 2024
c0c5c9f
style: addresses feedback and adds tests
micheleriva Jan 24, 2024
c86e7aa
feat: adds footer
micheleriva Jan 24, 2024
2aaf9e5
fix: fixes logo in light mode
micheleriva Jan 24, 2024
602856b
updates orama dependencies
micheleriva Jan 24, 2024
3b9d5d9
Merge branch 'main' into feat/integrate-orama
micheleriva Feb 2, 2024
ff65300
chore: updates orama dependencies to latest version
micheleriva Feb 2, 2024
38ccff5
Merge branch 'main' into feat/integrate-orama
micheleriva Feb 21, 2024
05ceb46
chore: updates Orama client
micheleriva Feb 22, 2024
e01a445
fix: fixes unexpected close of modal on click
micheleriva Feb 22, 2024
28a7bce
fix: fixes Orama logo
micheleriva Feb 22, 2024
9f57a16
chore: removes unused test attribute
micheleriva Feb 22, 2024
75230d0
fix: code-reviews
ovflowd Feb 23, 2024
f0e09ba
chore: minor copy changes
ovflowd Feb 23, 2024
0c62be5
fix: aggregate results and make them unique
ovflowd Feb 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,8 @@ jobs:
# this should be a last resort in case by any chances the build memory gets too high
# but in general this should never happen
NODE_OPTIONS: '--max_old_space_size=4096'

- name: Sync Orama Cloud
if: github.ref == 'refs/heads/main'
run: |
npm run sync-orama
ovflowd marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
node_modules
npm-debug.log
.npm
.env.local
ovflowd marked this conversation as resolved.
Show resolved Hide resolved

# Next.js Build Output
.next
Expand Down
48 changes: 28 additions & 20 deletions app/[locale]/next-data/page-data/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,38 @@ export const GET = async () => {
defaultLocale.code
);

const availablePagesMetadata = allAvailbleRoutes.map(async pathname => {
const { source, filename } = await dynamicRouter.getMarkdownFile(
defaultLocale.code,
pathname
);
const availablePagesMetadata = allAvailbleRoutes
.filter(route => !route.startsWith('blog'))
.map(async pathname => {
const { source, filename } = await dynamicRouter.getMarkdownFile(
defaultLocale.code,
pathname
);

// Gets the title and the Description from the Page Metadata
const { title, description } = await dynamicRouter.getPageMetadata(
defaultLocale.code,
pathname
);
// Gets the title and the Description from the Page Metadata
const { title, description } = await dynamicRouter.getPageMetadata(
defaultLocale.code,
pathname
);

// Parser the Markdown source with `gray-matter` and then only
// grabs the markdown content and cleanses it by removing HTML/JSX tags
// removing empty/blank lines or lines just with spaces and trims each line
// from leading and trailing paddings/spaces
const cleanedContent = parseRichTextIntoPlainText(matter(source).content);
// Parser the Markdown source with `gray-matter` and then only
// grabs the markdown content and cleanses it by removing HTML/JSX tags
// removing empty/blank lines or lines just with spaces and trims each line
// from leading and trailing paddings/spaces
const cleanedContent = parseRichTextIntoPlainText(matter(source).content);

// Deflates a String into a base64 string-encoded (zlib compressed)
const deflatedSource = deflateSync(cleanedContent).toString('base64');
// Deflates a String into a base64 string-encoded (zlib compressed)
const deflatedSource = deflateSync(cleanedContent).toString('base64');

// Returns metadata of each page available on the Website
return { filename, pathname, title, description, content: deflatedSource };
});
// Returns metadata of each page available on the Website
return {
filename,
pathname,
title,
description,
content: deflatedSource,
};
});

return Response.json(await Promise.all(availablePagesMetadata));
};
Expand Down
40 changes: 40 additions & 0 deletions components/Common/Search/States/WithAllResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Results } from '@orama/orama';
import NextLink from 'next/link';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import type { FC } from 'react';

import type { SearchDoc } from '@/types';

import styles from './index.module.css';

type SearchResults = Results<SearchDoc>;

type SeeAllProps = {
searchResults: SearchResults;
searchTerm: string;
selectedFacetName: string;
onSeeAllClick: () => void;
};

export const WithAllResults: FC<SeeAllProps> = props => {
const t = useTranslations();
const params = useParams();

const locale = params?.locale ?? 'en';
const resultsCount = props.searchResults?.count?.toLocaleString('en') ?? 0;
const searchParams = new URLSearchParams();

searchParams.set('q', props.searchTerm);
searchParams.set('section', props.selectedFacetName);

const allResultsURL = `/${locale}/search?${searchParams.toString()}`;

return (
<div className={styles.seeAllFulltextSearchResults}>
<NextLink href={allResultsURL} onClick={props.onSeeAllClick}>
{t('components.search.seeAll.text', { count: resultsCount })}
</NextLink>
</div>
);
};
14 changes: 14 additions & 0 deletions components/Common/Search/States/WithEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useTranslations } from 'next-intl';
import type { FC } from 'react';

import styles from './index.module.css';

export const WithEmptyState: FC = () => {
const t = useTranslations();

return (
<div className={styles.emptyStateContainer}>
{t('components.search.emptyState.text')}
</div>
);
};
14 changes: 14 additions & 0 deletions components/Common/Search/States/WithError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useTranslations } from 'next-intl';
import type { FC } from 'react';

import styles from './index.module.css';

export const WithError: FC = () => {
const t = useTranslations();

return (
<div className={styles.searchErrorContainer}>
{t('components.search.searchError.text')}
</div>
);
};
16 changes: 16 additions & 0 deletions components/Common/Search/States/WithNoResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useTranslations } from 'next-intl';
import type { FC } from 'react';

import styles from './index.module.css';

type NoResultsProps = { searchTerm: string };

export const WithNoResults: FC<NoResultsProps> = props => {
const t = useTranslations();

return (
<div className={styles.noResultsContainer}>
{t('components.search.noResults.text', { query: props.searchTerm })}
</div>
);
};
41 changes: 41 additions & 0 deletions components/Common/Search/States/WithPoweredBy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client';

import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

import styles from './index.module.css';

const getLogoURL = (theme: string = 'dark') =>
`https://website-assets.oramasearch.com/orama-when-${theme}.svg`;

export const WithPoweredBy = () => {
const t = useTranslations();
const { resolvedTheme } = useTheme();
const [logoURL, setLogoURL] = useState<string>();

useEffect(() => setLogoURL(getLogoURL(resolvedTheme)), [resolvedTheme]);

return (
<div className={styles.poweredBy}>
{t('components.search.poweredBy.text')}

<a
href="https://oramasearch.com?utm_source=nodejs.org"
target="_blank"
rel="noreferer"
>
{logoURL && (
<Image
src={logoURL}
alt="Powered by OramaSearch"
className={styles.poweredByLogo}
width={80}
height={20}
/>
)}
</a>
</div>
);
};
193 changes: 193 additions & 0 deletions components/Common/Search/States/WithSearchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
'use client';

import {
micheleriva marked this conversation as resolved.
Show resolved Hide resolved
MagnifyingGlassIcon,
ChevronLeftIcon,
} from '@heroicons/react/24/outline';
import type { Results, Nullable } from '@orama/orama';
import classNames from 'classnames';
import { useState, useRef, useEffect } from 'react';
import type { FC } from 'react';

import styles from '@/components/Common/Search/States/index.module.css';
import { WithAllResults } from '@/components/Common/Search/States/WithAllResults';
import { WithEmptyState } from '@/components/Common/Search/States/WithEmptyState';
import { WithError } from '@/components/Common/Search/States/WithError';
import { WithNoResults } from '@/components/Common/Search/States/WithNoResults';
import { WithPoweredBy } from '@/components/Common/Search/States/WithPoweredBy';
import { WithSearchResult } from '@/components/Common/Search/States/WithSearchResult';
import { useClickOutside } from '@/hooks/react-client';
import { useRouter } from '@/navigation.mjs';
import { DEFAULT_ORAMA_QUERY_PARAMS } from '@/next.constants.mjs';
import { search as oramaSearch, getInitialFacets } from '@/next.orama.mjs';
import type { SearchDoc } from '@/types';
import { debounce } from '@/util/debounce';

type Facets = { [key: string]: number };

type SearchResults = Nullable<Results<SearchDoc>>;

type SearchBoxProps = { onClose: () => void };

export const WithSearchBox: FC<SearchBoxProps> = ({ onClose }) => {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<SearchResults>(null);
const [selectedFacet, setSelectedFacet] = useState<number>(0);
const [searchError, setSearchError] = useState<Nullable<Error>>(null);

const router = useRouter();
const searchInputRef = useRef<HTMLInputElement>(null);
const searchBoxRef = useRef<HTMLDivElement>(null);

const search = (term: string) => {
oramaSearch({
term,
...DEFAULT_ORAMA_QUERY_PARAMS,
mode: 'fulltext',
returning: [
'path',
'pageSectionTitle',
'pageTitle',
'path',
'siteSection',
],
...filterBySection(),
})
.then(setSearchResults)
.catch(setSearchError);
};

useClickOutside(searchBoxRef, () => {
reset();
onClose();
});

useEffect(() => {
searchInputRef.current?.focus();

getInitialFacets().then(setSearchResults).catch(setSearchError);

return reset;
}, []);

useEffect(
() => debounce(() => search(searchTerm), 1000),
// we don't need to care about memoization of search function
// eslint-disable-next-line react-hooks/exhaustive-deps
[searchTerm, selectedFacet]
);

const reset = () => {
setSearchTerm('');
setSearchResults(null);
setSelectedFacet(0);
};

const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
router.push(`/search?q=${searchTerm}&section=${selectedFacetName}`);
onClose();
};

const changeFacet = (idx: number) => setSelectedFacet(idx);

const filterBySection = () => {
if (selectedFacet === 0) {
return {};
}

return { where: { siteSection: { eq: selectedFacetName } } };
};

const facets: Facets = {
all: searchResults?.count ?? 0,
...(searchResults?.facets?.siteSection?.values ?? {}),
};

const selectedFacetName = Object.keys(facets)[selectedFacet];

return (
<div className={styles.searchBoxModalContainer}>
<div className={styles.searchBoxModalPanel} ref={searchBoxRef}>
<div className={styles.searchBoxInnerPanel}>
<div className={styles.searchBoxInputContainer}>
<button
onClick={onClose}
className={styles.searchBoxBackIconContainer}
>
<ChevronLeftIcon className={styles.searchBoxBackIcon} />
</button>

<MagnifyingGlassIcon
className={styles.searchBoxMagnifyingGlassIcon}
/>

<form onSubmit={onSubmit}>
<input
ref={searchInputRef}
type="search"
className={styles.searchBoxInput}
onChange={event => setSearchTerm(event.target.value)}
value={searchTerm}
/>
</form>
</div>

<div className={styles.fulltextSearchSections}>
{Object.keys(facets).map((facetName, idx) => (
<button
key={facetName}
className={classNames(styles.fulltextSearchSection, {
[styles.fulltextSearchSectionSelected]: selectedFacet === idx,
})}
onClick={() => changeFacet(idx)}
>
{facetName}
<span className={styles.fulltextSearchSectionCount}>
({facets[facetName].toLocaleString('en')})
</span>
</button>
))}
</div>

<div className={styles.fulltextResultsContainer}>
{searchError && <WithError />}

{!searchError && !searchTerm && <WithEmptyState />}

{!searchError && searchTerm && (
<>
{searchResults &&
searchResults.count > 0 &&
searchResults.hits.map(hit => (
<WithSearchResult
key={hit.id}
hit={hit}
searchTerm={searchTerm}
/>
))}

{searchResults && searchResults.count === 0 && (
<WithNoResults searchTerm={searchTerm} />
)}

{searchResults && searchResults.count > 8 && (
<WithAllResults
searchResults={searchResults}
searchTerm={searchTerm}
selectedFacetName={selectedFacetName}
onSeeAllClick={onClose}
/>
)}
</>
)}
</div>

<div className={styles.fulltextSearchFooter}>
<WithPoweredBy />
</div>
</div>
</div>
</div>
);
};
Loading
Loading