add article page search

This commit is contained in:
amirhouieh 2023-05-12 16:55:53 +02:00
parent a9db723aca
commit c1f44b6f08
21 changed files with 456 additions and 56 deletions

View File

@ -1,15 +1,20 @@
import { getBodyBlocks } from '@/utils/data.utils'
import { RenderArticleBlock } from './Article.Block'
import { ArticlePostData } from '@/types/data.types'
import {
GoogleDocEnhanced,
UnbodyImageBlock,
UnbodyTextBlock,
} from '@/lib/unbody/unbody.types'
type Props = {
data: ArticlePostData
data: GoogleDocEnhanced
}
const ArticleBlocks = ({ data }: Props) => {
return data?.article.blocks.length ? (
return data.blocks.length ? (
<>
{getBodyBlocks(data.article).map((block, idx) => (
{getBodyBlocks(data).map((block, idx) => (
<RenderArticleBlock key={'block-' + idx} block={block} />
))}
</>

View File

@ -5,18 +5,45 @@ import ArticleHeader from './Header/Article.Header'
import ArticleFooter from './Footer/Article.Footer'
import { MobileToc } from './Article.MobileToc'
import ArticleBlocks from './Article.Blocks'
import { useArticleContext } from '@/context/article.context'
import { useSearchBarContext } from '@/context/searchbar.context'
import { useEffect } from 'react'
import { TextBlockEnhanced, UnbodyImageBlock } from '@/lib/unbody/unbody.types'
import { Typography } from '@acid-info/lsd-react'
interface Props {
data: ArticlePostData
}
export default function ArticleBody({ data }: Props) {
const { resultsNumber, setResultsHelperText } = useSearchBarContext()
const { data: searchResultBlocks = [] } = useArticleContext()
useEffect(() => {
if (resultsNumber !== null) {
setResultsHelperText(data.article.title)
}
}, [resultsNumber])
const ids = searchResultBlocks?.map((block) => block.doc._additional.id)
const blocks =
resultsNumber !== null
? data.article.blocks.filter((block) =>
ids?.includes(block._additional.id),
)
: data.article.blocks
return (
<ArticleContainer>
<ArticleHeader {...data.article} />
<MobileToc toc={data.article.toc} />
{resultsNumber === null && <ArticleHeader {...data.article} />}
{resultsNumber === null && <MobileToc toc={data.article.toc} />}
<TextContainer>
<ArticleBlocks data={data} />
{/*@ts-ignore*/}
<ArticleBlocks data={{ ...data.article, blocks }} />
{resultsNumber === 0 && (
<Typography variant="body1">No results found</Typography>
)}
</TextContainer>
<ArticleFooter data={data} />
</ArticleContainer>

View File

@ -7,7 +7,12 @@ const Main = ({ children }: PropsWithChildren) => {
}
const Container = styled.main`
display: flex;
justify-content: center;
max-width: 1440px;
margin-block: ${uiConfigs.postSectionMargin}px;
margin-left: auto;
margin-right: auto;
`
export default Main

View File

@ -12,4 +12,5 @@
.globalSearchTrigger{
text-decoration: underline;
transition: font-size 150ms ease-in-out;
}

View File

@ -23,13 +23,19 @@ import {
extractTopicsFromQuery,
} from '@/utils/search.utils'
import Link from 'next/link'
import { useSearchBarContext } from '@/context/searchbar.context'
export type SearchbarProps = {
searchScope?: ESearchScope
className?: string
onSearch?: (query: string, filterTags: string[]) => void
onReset?: () => void
}
export default function Searchbar(props: SearchbarProps) {
const { onSearch, onReset } = props
const { resultsNumber, resultsHelperText } = useSearchBarContext()
const [searchScope, setSearchScope] = useState<ESearchScope>(
props.searchScope || ESearchScope.GLOBAL,
)
@ -46,10 +52,20 @@ export default function Searchbar(props: SearchbarProps) {
const isValidSearchInput = (_filterTags: string[] = []) =>
(query && query.length > 0) || _filterTags.length > 0
const isArticlePage = router.pathname === '/article/[slug]'
const performSearch = async (
q: string = query,
_filterTags: string[] = filterTags,
) => {
//if it is article page, just call onSearch
if (isArticlePage) {
if (onSearch) {
onSearch(q, _filterTags)
}
return
}
await router.push(
{
pathname: '/search',
@ -74,7 +90,15 @@ export default function Searchbar(props: SearchbarProps) {
}, [router.query.query, router.query.topics])
const performClear = useCallback(() => {
performSearch('', [])
if (!isArticlePage) {
performSearch('', [])
return
}
setQuery('')
setFilterTags([])
setActive(false)
onReset && onReset()
}, [setQuery, setFilterTags])
const handleTagClick = (tag: string) => {
@ -112,6 +136,16 @@ export default function Searchbar(props: SearchbarProps) {
onChange={(e) => {
setQuery(e.target.value)
}}
style={{
transition: 'height 150ms ease-in-out',
height: active ? '56px' : 'auto',
}}
inputProps={{
style: {
fontSize: active ? '28px' : '14px',
transition: 'font-size 150ms ease-in-out',
},
}}
/>
{searchScope === ESearchScope.ARTICLE && (
<GlobalSearchTrigger
@ -141,6 +175,17 @@ export default function Searchbar(props: SearchbarProps) {
selectedTags={filterTags}
/>
</TagsWrapper>
{resultsNumber !== null && (
<ResultsStatus>
<Typography variant={'subtitle2'}>{resultsNumber} matches</Typography>
{resultsHelperText && (
<Typography variant={'subtitle2'}>.</Typography>
)}
{resultsHelperText && (
<Typography variant={'subtitle2'}>{resultsHelperText}</Typography>
)}
</ResultsStatus>
)}
<Collapsed
className={isCollapsed ? 'enabled' : ''}
onClick={() => setActive(true)}
@ -153,15 +198,26 @@ export default function Searchbar(props: SearchbarProps) {
}
const TagsWrapper = styled.div`
transition: height 250ms ease-in-out;
transition: height 150ms ease-in-out;
overflow: hidden;
height: 0;
&.active {
height: 45px;
}
`
const ResultsStatus = styled.div`
padding: 8px 14px;
display: flex;
grid-column-gap: 8px;
align-items: center;
> :nth-child(2) {
font-size: 18px;
transform: translateY(-3px);
}
`
const Collapsed = styled.div`
display: flex;
align-items: baseline;
@ -174,9 +230,9 @@ const Collapsed = styled.div`
top: -100%;
left: 0;
font-size: 14px;
font-size: 28px;
transition: top 250ms ease-in-out;
transition: top 150ms ease-in-out;
&.enabled {
top: 0;
@ -206,7 +262,8 @@ const GlobalSearchTrigger = styled(Link)`
left: 256px;
top: 7px;
transition: opacity 250ms ease-in-out;
transition: opacity 50ms;
transition-delay: 50ms;
&.hide {
opacity: 0;

View File

@ -4,6 +4,8 @@ import { useSticky } from '@/utils/ui.utils'
import { Typography } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import { UnbodyGoogleDoc } from '@/lib/unbody/unbody.types'
import { useArticleContext } from '@/context/article.context'
import { useSearchBarContext } from '@/context/searchbar.context'
export type TableOfContentsProps = Pick<UnbodyGoogleDoc, 'toc'>
@ -15,6 +17,7 @@ export default function TableOfContents({ contents, ...props }: Props) {
const articleContainer = useArticleContainerContext()
const { tocIndex, setTocIndex } = articleContainer
const dy = uiConfigs.navbarRenderedHeight + uiConfigs.postSectionMargin
const { resultsNumber } = useSearchBarContext()
const { sticky, stickyRef, height } = useSticky<HTMLDivElement>(dy)
@ -28,7 +31,6 @@ export default function TableOfContents({ contents, ...props }: Props) {
top: Number(position?.top) + window.scrollY - 100,
behavior: 'smooth',
})
setTocIndex(index)
}
@ -38,7 +40,9 @@ export default function TableOfContents({ contents, ...props }: Props) {
height={height}
ref={stickyRef}
{...props}
className={sticky ? 'sticky' : ''}
className={`${resultsNumber !== null ? 'hidden' : ''} ${
sticky ? 'sticky' : ''
}`}
>
<Title variant="body3">Contents</Title>
<Contents height={height}>
@ -71,6 +75,11 @@ const Container = styled.aside<{ dy: number; height: number }>`
margin-left: 16px;
padding-bottom: 72px;
transition: opacity 0.3s ease-in-out;
&.hidden {
opacity: 0;
}
// temporary breakpoint
@media (max-width: 1024px) {
display: none;

View File

@ -4,6 +4,8 @@ import { useState } from 'react'
import { ArticleContainerContext } from '@/containers/ArticleContainer.Context'
import ArticleBody from '@/components/Article/Article.Body'
import { ArticlePostData } from '@/types/data.types'
import { useArticleContext } from '@/context/article.context'
import { Grid, GridItem } from '@/components/Grid/Grid'
interface Props {
data: ArticlePostData
@ -14,27 +16,29 @@ const ArticleContainer = (props: Props) => {
const [tocIndex, setTocIndex] = useState(0)
return (
<Container>
<ArticleContainerContext.Provider
value={{ tocIndex: tocIndex, setTocIndex: setTocIndex }}
<ArticleContainerContext.Provider
value={{ tocIndex: tocIndex, setTocIndex: setTocIndex }}
>
<Grid
style={{
width: '100%',
}}
>
<TableOfContents contents={data.article.toc ?? []} />
<ArticleBody data={data} />
<Right />
</ArticleContainerContext.Provider>
</Container>
<Gap className={'w-1'} />
<GridItem className={'w-2'}>
<TableOfContents contents={data.article.toc ?? []} />
</GridItem>
<Gap className={'w-1'} />
<GridItem className={'w-8'}>
<ArticleBody data={data} />
</GridItem>
</Grid>
</ArticleContainerContext.Provider>
)
}
const Container = styled.div`
display: flex;
justify-content: center;
`
const Right = styled.aside`
width: 162px;
// temporary breakpoint
@media (max-width: 1024px) {
const Gap = styled(GridItem)`
@media (max-width: 550px) {
display: none;
}
`

View File

@ -0,0 +1,61 @@
import { useArticleSearch, useSearchGeneric } from '@/hooks/useSearch'
import { UnbodyImageBlock, UnbodyTextBlock } from '@/lib/unbody/unbody.types'
import { PostTypes, SearchHook } from '@/types/data.types'
import { useRouter } from 'next/router'
import { createContext, useContext, useState } from 'react'
import { useSearchBarContext } from './searchbar.context'
type ArticleContext = SearchHook<UnbodyTextBlock | UnbodyImageBlock> & {
onSearch: (query: string, filters: string[], title: string) => void
onReset: () => void
}
const ArticleContext = createContext<ArticleContext>({
onSearch: () => {},
data: [],
loading: false,
error: null,
search: () => Promise.resolve([]),
reset: () => {},
onReset: () => {},
})
export const ArticleProvider = ({ children }: any) => {
const {
query: { slug },
} = useRouter()
const blocks = useArticleSearch([])
const { setResultsNumber, setResultsHelperText } = useSearchBarContext()
const onSearch = async (query: string = '', filters: string[] = []) => {
if (query.trim().length == 0) return
setResultsHelperText('Searching...')
const res = await blocks.search(query, filters, slug)
setResultsHelperText(null)
setResultsNumber(res.length)
}
const onReset = async () => {
setResultsNumber(null)
setResultsHelperText(null)
blocks.reset([])
}
return (
<ArticleContext.Provider
value={{
...blocks,
onSearch,
onReset,
}}
>
{children}
</ArticleContext.Provider>
)
}
// Export context
export default ArticleContext
export const useArticleContext = () => useContext(ArticleContext)

View File

@ -0,0 +1,43 @@
// context for searchbar
import { SearchbarProps } from '@/components/Searchbar/Searchbar'
import { createContext, useContext, useState } from 'react'
type SearchBarContext = SearchbarProps & {
resultsNumber: number | null
setResultsNumber: (resultsNumber: number | null) => void
resultsHelperText: string | null
setResultsHelperText: (resultsHelperText: string | null) => void
}
const SearchBarContext = createContext<SearchBarContext>({
resultsNumber: null,
setResultsNumber: () => {},
resultsHelperText: null,
setResultsHelperText: () => {},
})
export const SearchBarProvider = ({ children }: any) => {
const [resultsNumber, setResultsNumber] = useState<number | null>(null)
const [resultsHelperText, setResultsHelperText] = useState<string | null>(
null,
)
return (
<SearchBarContext.Provider
value={{
resultsNumber,
setResultsNumber,
resultsHelperText,
setResultsHelperText,
}}
>
{children}
</SearchBarContext.Provider>
)
}
// Export context
export default SearchBarContext
export const useSearchBarContext = () =>
useContext<SearchBarContext>(SearchBarContext)

View File

@ -38,3 +38,32 @@ export const useSearchGeneric = <T>(
}
return { data, loading, error, search, reset }
}
export const useArticleSearch = (
initialData: SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>[],
): SearchHook<UnbodyImageBlock | UnbodyTextBlock> => {
const [data, setData] =
useState<SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>[]>(
initialData,
)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const search = async (query: string, tags: string[], slug: string) => {
if (loading) return Promise.resolve([])
setLoading(true)
const result = await searchApi.searchArticle(query, tags, slug)
setData(result.data)
setLoading(false)
return result.data
}
const reset = (
_initialData: SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>[],
) => {
setData(_initialData)
setLoading(false)
setError(null)
}
return { data, loading, error, search, reset }
}

View File

@ -7,17 +7,27 @@ import { ESearchScope } from '@/types/ui.types'
import styles from './Article.layout.module.css'
import { Footer } from '@/components/Footer'
import { Main } from '@/components/Main'
import { useArticleContext } from '@/context/article.context'
export default function ArticleLayout(props: PropsWithChildren<any>) {
type Props = PropsWithChildren<{
// onSearch: (query: string, filters: string[]) => void
}>
export default function ArticleLayout({ children }: Props) {
const isDarkState = useIsDarkState()
const { onSearch, onReset } = useArticleContext()
return (
<>
<header className={styles.header}>
<Navbar isDark={isDarkState.get()} toggle={isDarkState.toggle} />
<NavbarFiller />
<Searchbar searchScope={ESearchScope.ARTICLE} />
<Searchbar
searchScope={ESearchScope.ARTICLE}
onSearch={onSearch}
onReset={onReset}
/>
</header>
<Main>{props.children}</Main>
<Main>{children}</Main>
<Footer />
</>
)

View File

@ -7,6 +7,7 @@ import { uiConfigs } from '@/configs/ui.configs'
import { DefaultLayout } from '@/layouts/DefaultLayout'
import { ReactNode } from 'react'
import { NextComponentType, NextPageContext } from 'next'
import { SearchBarProvider } from '@/context/searchbar.context'
type NextLayoutComponentType<P = {}> = NextComponentType<
NextPageContext,
@ -52,7 +53,9 @@ export default function App({ Component, pageProps }: AppLayoutProps) {
}
`}
/>
{getLayout(<Component {...pageProps} />)}
<SearchBarProvider>
{getLayout(<Component {...pageProps} />)}
</SearchBarProvider>
</ThemeProvider>
)
}

View File

@ -0,0 +1,31 @@
import api from '@/services/unbody.service'
import { PostTypes } from '@/types/data.types'
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<any>,
) {
const {
query: { q = '', tags: tagsString = '', slug },
} = req
if (!slug) {
return res.status(400).json({ error: 'Invalid request' })
}
const tags =
typeof tagsString === 'string'
? tagsString
.split(',')
.map((tag: string) => tag.trim())
.filter((t) => t.length > 0)
: undefined
const response = await api.searchBlockInArticle(
q as string,
tags,
true,
slug as string,
)
res.status(200).json(response)
}

View File

@ -5,6 +5,7 @@ import ArticleContainer from '@/containers/ArticleContainer'
import api from '@/services/unbody.service'
import { ArticlePostData } from '@/types/data.types'
import { SEO } from '@/components/SEO'
import { ArticleProvider } from '@/context/article.context'
type ArticleProps = {
data: ArticlePostData
@ -66,7 +67,11 @@ export const getStaticProps = async ({ params }: GetStaticPropsContext) => {
}
ArticlePage.getLayout = function getLayout(page: ReactNode) {
return <ArticleLayout>{page}</ArticleLayout>
return (
<ArticleProvider>
<ArticleLayout>{page}</ArticleLayout>
</ArticleProvider>
)
}
export default ArticlePage

View File

@ -23,6 +23,9 @@ export const getArticlePostQuery = (args: UnbodyGetFilters = defaultArgs) =>
alt
order
__typename
_additional{
id
}
}
... on TextBlock {
footnotes
@ -32,6 +35,9 @@ export const getArticlePostQuery = (args: UnbodyGetFilters = defaultArgs) =>
tagName
__typename
classNames
_additional{
id
}
}
}
_additional{

View File

@ -27,6 +27,7 @@ export const getSearchBlocksQuery = (args: UnbodyGetFilters = defaultArgs) =>
}
_additional{
certainty
id
}
`),
GetImageBlockQuery(args)(`
@ -46,6 +47,7 @@ export const getSearchBlocksQuery = (args: UnbodyGetFilters = defaultArgs) =>
}
_additional{
certainty
id
}
`),
].join(' '),

View File

@ -2,8 +2,22 @@ import { PostTypes } from '@/types/data.types'
class SearchService {
constructor() {}
serach = (query: string, tags: string[], postType: PostTypes) => {
return fetch(`/api/search/${postType}?q=${query}&tags=${tags.join(',')}`)
return fetch(
`/api/search/general/${postType}?q=${query}&tags=${tags.join(',')}`,
)
.then((res) => res.json())
.catch((e) => {
console.error(e)
return { data: null, errors: JSON.stringify(e) }
})
}
searchArticle = (query: string, tags: string[], slug: string) => {
return fetch(
`/api/search/article/${slug}?q=${query}&tags=${tags.join(',')}`,
)
.then((res) => res.json())
.catch((e) => {
console.error(e)

View File

@ -40,6 +40,7 @@ type HomepagePost = Pick<
| 'blocks'
| 'mentions'
| 'slug'
| '_additional'
>
type HomePageData = {
@ -66,13 +67,14 @@ const OperandFactory = (
})
export const Operands: Record<string, (...a: any) => WhereOperandsInpObj> = {
WHERE_PUBLISHED: (subPath: string[] = ['pathString']) =>
OperandFactory(
WHERE_PUBLISHED: (subPath: string[] = ['pathString']) => {
return OperandFactory(
UnbodyGraphQl.Filters.WhereOperatorEnum.Like,
[...subPath],
'*/published/*',
'valueString',
),
)
},
WHERE_ID_IS: (id) =>
OperandFactory(
UnbodyGraphQl.Filters.WhereOperatorEnum.Equal,
@ -101,6 +103,13 @@ export const Operands: Record<string, (...a: any) => WhereOperandsInpObj> = {
author,
'valueText',
),
WHERE_IS_IN_SLUG: (slug: string) =>
OperandFactory(
UnbodyGraphQl.Filters.WhereOperatorEnum.Equal,
['document', UnbodyGraphQl.UnbodyDocumentTypeNames.GoogleDoc, 'slug'],
slug,
'valueString',
),
}
const mapSearchResultItem = <T extends UnbodyDocTypes>(
@ -160,7 +169,7 @@ class UnbodyService extends UnbodyClient {
if (errors || !data) {
console.log(errors)
return {
data: null as any,
data: data as any,
errors: JSON.stringify(errors),
}
}
@ -280,29 +289,36 @@ class UnbodyService extends UnbodyClient {
.catch((e) => this.handleResponse([], e))
}
serachBlocks = async (
searchBlockInArticle = async (
q: string = '',
tags: string[] = [],
published: boolean = true,
articleSlug: string,
): Promise<
ApiResponse<SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>[]>
ApiResponse<SearchResultItem<UnbodyImageBlock | TextBlockEnhanced>[]>
> => {
const query = getSearchBlocksQuery({
...(q.trim().length > 0
? {
nearText: {
concepts: [q, ...tags],
certainty: 0.85,
certainty: 0.8,
},
where: {
operator: UnbodyGraphQl.Filters.WhereOperatorEnum.And,
operands: [
...(published
? [
Operands.WHERE_PUBLISHED([
'document',
UnbodyGraphQl.UnbodyDocumentTypeNames.GoogleDoc,
'pathString',
]),
]
: []),
Operands.WHERE_IS_IN_SLUG(articleSlug),
],
},
...(published
? {
where: Operands.WHERE_PUBLISHED([
'document',
'GoogleDoc',
'pathString',
]),
}
: {}),
}
: {}),
})
@ -311,7 +327,65 @@ class UnbodyService extends UnbodyClient {
.then(({ data }) => {
if (!data || !(data.Get.ImageBlock || data.Get.TextBlock))
return this.handleResponse([], 'No data')
const blocks = [...data.Get.ImageBlock, ...data.Get.TextBlock]
const blocks = [
...(data.Get.ImageBlock || []),
...(data.Get.TextBlock || []),
]
return this.handleResponse(
blocks
.map((block) => {
if (
block.__typename ===
UnbodyGraphQl.UnbodyDocumentTypeNames.TextBlock
) {
return mapSearchResultItem(q, tags, enhanceTextBlock(block))
}
return mapSearchResultItem(q, tags, block)
})
.sort((a, b) => b.score - a.score),
)
})
.catch((e) => this.handleResponse([], e))
}
serachBlocks = async (
q: string = '',
tags: string[] = [],
published: boolean = true,
): Promise<
ApiResponse<SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>[]>
> => {
let whereFilters: any = []
const query = getSearchBlocksQuery({
...(q.trim().length > 0
? {
nearText: {
concepts: [q, ...tags],
certainty: 0.85,
},
where: Operands.WHERE_PUBLISHED([
'document',
UnbodyGraphQl.UnbodyDocumentTypeNames.GoogleDoc,
'pathString',
]),
limit: 20,
}
: { limit: 20 }),
})
return this.request<UnbodyGraphQlResponseBlocks>(query)
.then(({ data }) => {
if (!data || !(data.Get.ImageBlock || data.Get.TextBlock))
return this.handleResponse([], 'No data')
const blocks = [
...(data.Get.ImageBlock || []),
...(data.Get.TextBlock || []),
]
return this.handleResponse(
blocks
.map((block) => mapSearchResultItem(q, tags, block))

View File

@ -1,5 +1,6 @@
import {
GoogleDocEnhanced,
TextBlockEnhanced,
UnbodyGoogleDoc,
UnbodyImageBlock,
UnbodyTextBlock,
@ -14,6 +15,7 @@ export enum PostTypes {
export interface ArticlePostData {
article: GoogleDocEnhanced & {
toc: Array<UnbodyGraphQl.Fragments.TocItem>
blocks: Array<UnbodyImageBlock | TextBlockEnhanced>
}
relatedArticles: Array<GoogleDocEnhanced>
articlesFromSameAuthors: Array<GoogleDocEnhanced>
@ -58,6 +60,10 @@ export type SearchHook<T> = {
data: SearchResultItem<T>[]
loading: boolean
error: string | null
search: (query: string, tags: string[]) => Promise<SearchResultItem<T>[]>
search: (
query: string,
tags: string[],
...args: any
) => Promise<SearchResultItem<T>[]>
reset: (initialData: SearchResultItem<T>[]) => void
}

View File

@ -1,5 +1,6 @@
import {
GoogleDocEnhanced,
TextBlockEnhanced,
UnbodyGoogleDoc,
UnbodyImageBlock,
UnbodyTextBlock,
@ -13,12 +14,19 @@ function hasClassName(inputString: string, className: string) {
return regex.test(inputString)
}
type Props = {
blocks: Array<UnbodyImageBlock | TextBlockEnhanced>
summary: string
tags: string[]
mentions: any[]
}
export const getBodyBlocks = ({
blocks,
summary,
tags = [],
mentions = [],
}: GoogleDocEnhanced) => {
}: Props) => {
return (blocks || []).filter((b) => {
const classNames = b.classNames || []