feat: implement post search; refs #137

This commit is contained in:
Hossein Mehrabi 2023-09-28 16:28:05 +03:30
parent ef5b2671fe
commit 5ed6fd7dc2
No known key found for this signature in database
GPG Key ID: 45C04964191AFAA1
19 changed files with 1063 additions and 409 deletions

View File

@ -1,6 +1,7 @@
import { useArticleContainerContext } from '@/containers/ArticleContainer.Context'
import { useIntersectionObserver } from '@/utils/ui.utils'
import { useMemo } from 'react'
import { usePostSearch } from '../../containers/PostSearchContainer/PostSearch.context'
import { LPE } from '../../types/lpe.types'
import { RenderArticleBlock } from './Article.Block'
@ -12,7 +13,9 @@ const ArticleBlocks = ({ data }: Props) => {
const { setTocId, tocId } = useArticleContainerContext()
const headingElementsRef = useIntersectionObserver(setTocId)
const blocks = useMemo(
const search = usePostSearch()
const filteredBlocks = useMemo(
() =>
data.content.filter(
(b) =>
@ -24,6 +27,10 @@ const ArticleBlocks = ({ data }: Props) => {
[data.content],
)
const blocks = search.active
? search.results.map((i) => i.data)
: filteredBlocks
return blocks.length ? (
<>
{blocks.map((block, idx) => (

View File

@ -1,5 +1,4 @@
import styled from '@emotion/styled'
import { LPE } from '../../types/lpe.types'
import ArticleBlocks from './Article.Blocks'
import ArticleFooter from './Footer/Article.Footer'
@ -7,14 +6,20 @@ import ArticleHeader from './Header/Article.Header'
interface Props {
data: LPE.Article.Document
header?: boolean
footer?: boolean
}
export default function ArticleBody({ data }: Props) {
export default function ArticleBody({
data,
header = true,
footer = true,
}: Props) {
return (
<ArticleContainer>
<ArticleHeader {...data.data} />
{header && <ArticleHeader {...data.data} />}
<ArticleBlocks data={data.data} />
<ArticleFooter data={data} />
{footer && <ArticleFooter data={data} />}
</ArticleContainer>
)
}

View File

@ -46,6 +46,20 @@ export default function NavBar({ defaultState }: NavBarProps) {
defaultState && state.state.set((value) => ({ ...value, ...defaultState }))
}, [defaultState])
const searchButton =
state.showSearchButton.get() &&
(!!state.onSearch ? (
<IconButton size={'small'} onClick={() => state.onSearch!()}>
<SearchIcon color={'primary'} />
</IconButton>
) : (
<Link href={'/search'}>
<IconButton size={'small'}>
<SearchIcon color={'primary'} />
</IconButton>
</Link>
))
const buttons = (
<>
<Buttons>
@ -53,18 +67,10 @@ export default function NavBar({ defaultState }: NavBarProps) {
toggle={themeState.toggleMode}
mode={themeState.get().mode}
/>
<Link href={'/search'}>
<IconButton size={'small'}>
<SearchIcon color={'primary'} />
</IconButton>
</Link>
{searchButton}
</Buttons>
<Buttons mobile>
<Link href={'/search'}>
<IconButton size={'small'}>
<SearchIcon color={'primary'} />
</IconButton>
</Link>
{searchButton}
<IconButton size={'small'} onClick={toggleMobileMenu}>
{showMobileMenu ? (

View File

@ -1,291 +1,169 @@
import { copyConfigs } from '@/configs/copy.configs'
import { uiConfigs } from '@/configs/ui.configs'
import { LPE } from '@/types/lpe.types'
import { nope } from '@/utils/general.utils'
import { lsdUtils } from '@/utils/lsd.utils'
import { formatTagText } from '@/utils/string.utils'
import { useHydrated } from '@/utils/useHydrated.util'
import { Dropdown, TabItem, Tabs, Typography } from '@acid-info/lsd-react'
import { CloseIcon, IconButton, TabItem, Tabs } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react'
import { useDeepCompareEffect } from 'react-use'
import {
ArrayParam,
StringParam,
useQueryParam,
withDefault,
} from 'use-query-params'
import { DotIcon } from '../Icons/DotIcon'
import PostTypes = LPE.PostTypes
import ContentBlockTypes = LPE.Post.ContentBlockTypes
interface SearchBoxProps {
onSearch?: (query: string, tags: string[], types: LPE.ContentType[]) => void
tags?: string[]
onViewChange?: (view: string) => void
resultsNumber: number | null
busy?: boolean
showModeSwitch?: boolean
}
import { SearchBoxFilters, SearchBoxFiltersProps } from './SearchBoxFilters'
import { SearchBoxInput } from './SearchBoxInput'
import { SearchBoxResults } from './SearchBoxResults'
const ContentTypesCategories = {
Post: 'post',
Block: 'block',
} as const
export type SearchBoxProps = Partial<React.ComponentProps<typeof Container>> &
Pick<SearchBoxFiltersProps, 'filters'> & {
view?: string
views?: { key: string; label: string }[]
query?: string
title?: string
fetching?: boolean
numberOfResults?: number
showCloseButton?: boolean
showClearQueryButton?: boolean
const contentTypes = [
{
label: 'Articles',
value: PostTypes.Article,
category: ContentTypesCategories.Post,
},
{
label: 'Podcasts',
value: PostTypes.Podcast,
category: ContentTypesCategories.Post,
},
{
label: 'Paragraphs',
value: ContentBlockTypes.Text,
category: ContentTypesCategories.Block,
},
{
label: 'Images',
value: ContentBlockTypes.Image,
category: ContentTypesCategories.Block,
},
]
globalMode?: boolean
const allContentTypes = contentTypes.map((c) => c.value)
const SearchBox = (props: SearchBoxProps) => {
const {
onSearch = nope,
onViewChange = nope,
tags = [],
resultsNumber,
busy = false,
showModeSwitch = true,
} = props
onViewChange?: (view: string) => void
onQueryChange?: (query: string) => void
onFilterChange?: SearchBoxFiltersProps['onChange']
onSearch?: () => void
onClose?: () => void
onClearQuery?: () => void
}
export const SearchBox: React.FC<SearchBoxProps> = ({
view,
views,
title,
query = '',
filters = [],
fetching = false,
numberOfResults = 0,
showCloseButton = false,
showClearQueryButton = false,
globalMode = true,
onQueryChange = nope,
onViewChange = nope,
onFilterChange = nope,
onSearch = nope,
onClose = nope,
onClearQuery = nope,
...props
}) => {
const hydrated = useHydrated()
const [filterTags, setFilterTags] = useQueryParam(
'topic',
withDefault(ArrayParam, []),
{
skipUpdateWhenNoChange: false,
},
)
const [filterContentTypes, setFilterContentTypes] = useQueryParam(
'type',
withDefault(ArrayParam, allContentTypes),
{
skipUpdateWhenNoChange: true,
},
)
const [query, setQuery] = useQueryParam('q', withDefault(StringParam, ''), {
skipUpdateWhenNoChange: true,
})
const rootRef = useRef<HTMLDivElement>(null)
const resultsRef = useRef<HTMLDivElement>(null)
const [queryInput, setQueryInput] = useState<string>(query)
const [view, setView] = useState<string>('list')
const [enlargeQuery, setEnlargeQuery] = useState(false)
const [placeholder, setPlaceholder] = useState<string>(
copyConfigs.search.searchbarPlaceholders.global(),
)
const filtersRef = useRef<HTMLDivElement>(null)
const [whereResultsStick, setWhereResultsStick] = useState(0)
const [showDetails, setShowDetails] = useState(false)
const [detailsTop, setDetailsTop] = useState(0)
const [top, setTop] = useState(0)
const [focused, setFocused] = useState(false)
const [showClear, setShowClear] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const [activeInput, setActiveInput] = useState(false)
const stickyComponent = globalMode
? 'results'
: focused || !activeInput
? 'root'
: 'results'
const handleViewChange = async (n: string) => {
setView(n)
onViewChange(n)
onSearch(
query,
filterTags as string[],
filterContentTypes as LPE.ContentType[],
)
onSearch()
}
useEffect(() => {
setEnlargeQuery(!(queryInput.length === 0 && !focused))
if (focused) {
setPlaceholder('')
} else {
setTimeout(() => {
setPlaceholder(copyConfigs.search.searchbarPlaceholders.global())
}, 200)
}
}, [focused, queryInput])
if (!hydrated) return
useEffect(() => {
if (filtersRef.current && hydrated) {
const filtersB = filtersRef.current.getBoundingClientRect().bottom
const rootElement = rootRef.current
let stickyElement =
stickyComponent === 'results' ? resultsRef.current : rootElement
const parentT =
filtersRef.current.parentElement?.getBoundingClientRect().top || 0
const navbarRect = document
.querySelector('header > nav')
?.getBoundingClientRect()
const whereResultsStick =
-1 * (filtersB - parentT - uiConfigs.navbarRenderedHeight + 2)
if (!stickyElement || !rootElement || !navbarRect) return
setWhereResultsStick(whereResultsStick)
setDetailsTop(filtersB + whereResultsStick)
}
}, [filtersRef, hydrated, queryInput])
const rootRect = rootElement.getBoundingClientRect()
const rect = stickyElement.getBoundingClientRect()
let top = navbarRect.height
top -= rect.top - rootRect.top
top += rootRect.bottom - rect.bottom
setTop(top)
setCollapsed(window.scrollY > rect.bottom + top)
useEffect(() => {
const onScroll = () => {
setShowDetails(window.scrollY >= detailsTop)
setCollapsed(window.scrollY > rect.bottom + top)
}
window.addEventListener('scroll', onScroll)
return () => window.removeEventListener('scroll', onScroll)
}, [detailsTop])
useEffect(() => {
if (
filterContentTypes.length < allContentTypes.length ||
filterTags.length > 0
) {
setShowClear(true)
} else {
setShowClear(false)
window.addEventListener('scroll', onScroll, { passive: true })
return () => {
window.removeEventListener('scroll', onScroll)
}
}, [filterTags, filterContentTypes])
const clear = async () => {
setQuery('')
setFilterTags([])
setFilterContentTypes(allContentTypes)
}
const handleKeyUp = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
setQuery(queryInput)
}
}
useEffect(() => {
if (query?.length > 0) setQueryInput(query)
}, [query])
}, [resultsRef, hydrated, globalMode, stickyComponent])
useDeepCompareEffect(() => {
onSearch(
query,
filterTags as string[],
filterContentTypes as LPE.ContentType[],
)
}, [query, filterTags, filterContentTypes])
// useEffect(() => {
// if (focused) return
// if (query !== queryInput) setQuery(queryInput)
// }, [focused, query, queryInput])
onSearch()
}, [query, filters])
return (
<Container
className={enlargeQuery ? 'active' : ''}
style={{ top: `${whereResultsStick}px` }}
ref={rootRef}
{...props}
className={clsx(
props.className,
'search-box',
activeInput && 'search-box--active',
collapsed && 'search-box--collapsed',
globalMode && 'search-box--global',
`search-box--sticky-${stickyComponent}`,
)}
style={{ top: `${top}px`, ...(props.style || {}) }}
>
<FirstRow>
<input
className={`search-input`}
placeholder={placeholder}
value={queryInput as string}
onFocus={() => {
setFocused(true)
}}
onKeyUp={handleKeyUp}
onChange={(e) => setQueryInput(e.target.value)}
onBlurCapture={() => {
setFocused(false)
}}
<div className="search-box__controls">
<SearchBoxInput
value={query}
globalMode={globalMode}
keepEnlarged={!globalMode}
triggerOnBlur={!globalMode}
showClearButton={showClearQueryButton}
onChange={(value) => onQueryChange(value)}
onFocusChange={(value) => setFocused(value)}
onActive={(value) => setActiveInput(value)}
/>
{showModeSwitch && (
{views && views.length > 0 && (
<ViewButtons>
<Tabs size={'small'} activeTab={view} onChange={handleViewChange}>
<TabItem name={'list'}>
{copyConfigs.search.views.default}
</TabItem>
<TabItem name={'explore'}>
{copyConfigs.search.views.explore}
</TabItem>
{views.map((view) => (
<TabItem key={view.key} name={view.key}>
{view.label}
</TabItem>
))}
</Tabs>
</ViewButtons>
)}
</FirstRow>
<Filters ref={filtersRef}>
<Dropdown
size={'small'}
placeholder={'Content Type'}
options={contentTypes.map((c) => ({ name: c.label, value: c.value }))}
value={hydrated ? (filterContentTypes as string[]) : []}
onChange={(n) => setFilterContentTypes(n as string[])}
multi={true}
/>
<Dropdown
size={'small'}
placeholder={'Topics'}
options={tags.map((t) => ({ name: formatTagText(t), value: t }))}
value={hydrated ? (filterTags as string[]) : []}
onChange={(n) => setFilterTags(n as string[])}
multi={true}
triggerLabel={'Topics'}
/>
<Clear
variant={'label2'}
className={`${showClear ? 'show' : ''}`}
onClick={clear}
>
<span>clear</span>
<span> </span>
<span>filters</span>
</Clear>
</Filters>
{busy ? (
<Typography variant={'subtitle2'}>Searching...</Typography>
) : (
<Results>
<Typography variant={'subtitle2'}>
{resultsNumber === 0
? copyConfigs.search.results.noResults
: `${resultsNumber} ${copyConfigs.search.results.results}`}
</Typography>
<>
<Details
variant={'subtitle2'}
className={`search-details ${showDetails ? 'show' : ''}`}
>
{query && query.length > 0 && (
<span>
<DotIcon className="dot" color="primary" />
<span>{query}</span>
</span>
)}
{filterTags && filterTags.length > 0 && (
<span>
<DotIcon className="dot" color="primary" />
{filterTags.map((filterTag, index) => (
<span key={index}>[{filterTag}]</span>
))}
</span>
)}
{filterContentTypes && filterContentTypes.length > 0 && (
<span>
<DotIcon className="dot" color="primary" />
{filterContentTypes.map((contentType, index) => (
<span key={index}>[{contentType}]</span>
))}
</span>
)}
</Details>
</>
</Results>
)}
{showCloseButton && (
<IconButton
className="search-box__close-button"
size="small"
onClick={() => onClose()}
>
<CloseIcon color="primary" />
</IconButton>
)}
</div>
<SearchBoxFilters filters={filters} onChange={onFilterChange} />
<SearchBoxResults
title={title}
query={query}
filters={filters}
fetching={fetching}
showDetails={collapsed}
numberOfResults={numberOfResults}
containerRef={resultsRef}
/>
</Container>
)
}
@ -301,125 +179,51 @@ const Container = styled.div`
z-index: 1;
background: rgba(var(--lsd-surface-primary), 1);
&.active {
.search-input {
font-size: var(--lsd-h4-lineHeight);
line-height: var(--lsd-h4-lineHeight);
}
}
.search-input {
background: transparent;
font-size: var(--lsd-label1-fontSize);
line-height: var(--lsd-label1-lineHeight);
outline: none;
border: none;
width: 100%;
height: 44px;
transition: all 0.2s ease-in-out;
::placeholder {
color: rgba(var(--lsd-text-primary), 0.3);
}
:-ms-input-placeholder {
color: rgba(var(--lsd-text-primary), 0.3);
}
::-ms-input-placeholder {
color: rgba(var(--lsd-text-primary), 0.3);
}
}
${({ theme }) => lsdUtils.breakpoint(theme, 'xs', 'exact')} {
padding: 8px 0;
}
.search-box__controls {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
&:not(.search-box--global) {
padding: 0 0 0 14px;
.search-box-results {
display: none;
}
&.search-box--active {
padding: 8px 0px 8px 14px;
.search-box-results {
display: block;
}
}
}
.search-box__close-button {
transition: 0.2s;
}
&.search-box--collapsed.search-box--sticky-results {
.search-box-results {
width: calc(100% - 32px) !important;
}
.search-box__close-button {
transform: translateY(160%);
}
}
`
const FirstRow = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`
const ViewButtons = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 12px;
`
const Filters = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
${({ theme }) => lsdUtils.breakpoint(theme, 'xs', 'exact')} {
.lsd-dropdown--small {
width: 135px;
}
}
`
const Results = styled.div`
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
overflow: hidden;
> span {
white-space: nowrap;
}
.search-details {
}
`
const Clear = styled(Typography)`
opacity: 0;
transition: all 0.2s ease-in-out;
text-decoration: underline;
&.show {
opacity: 1;
}
${(props) => lsdUtils.breakpoint(props.theme, 'xs', 'exact')} {
span:not(:first-child) {
display: none;
}
}
`
const Details = styled(Typography)`
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
opacity: 0;
transform: translateY(-40px);
transition: all 0.2s ease-in-out;
&.show {
opacity: 1;
transform: translateY(0);
}
> span {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
.dot {
margin-right: 4px;
}
}
`
export default SearchBox

View File

@ -0,0 +1,117 @@
import { Dropdown, Typography } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import clsx from 'clsx'
import React, { useEffect, useState } from 'react'
import { nope } from '../../utils/general.utils'
import { lsdUtils } from '../../utils/lsd.utils'
export type SearchBoxFiltersProps = Partial<
Omit<React.ComponentProps<typeof Container>, 'title' | 'onChange'>
> & {
filters?: {
key: string
label: string
value: string[]
defaultValue?: string[]
options?: {
label: string
value: string
}[]
}[]
onChange?: (key: string, value: string[]) => void
containerRef?: React.LegacyRef<HTMLDivElement>
}
export const SearchBoxFilters: React.FC<SearchBoxFiltersProps> = ({
filters = [],
containerRef,
onChange = nope,
...props
}) => {
const [showClear, setShowClear] = useState(false)
useEffect(() => {
const showClear = filters.some((f) =>
!f.defaultValue
? f.value.length > 0
: f.value.length < (f.defaultValue || []).length,
)
setShowClear(showClear)
}, [filters])
const handleClear = () => {
for (const filter of filters) {
setTimeout(() => {
onChange(filter.key, filter.defaultValue ?? [])
}, 0)
}
}
if (!filters || filters.length === 0) return null
return (
<Container
{...props}
ref={containerRef}
className={clsx('search-box-filters', props.className)}
>
{filters.map((filter) => (
<Dropdown
key={filter.key}
multi
size="small"
placeholder={filter.label}
triggerLabel={filter.label}
options={(filter.options || []).map((o) => ({
name: o.label,
value: o.value,
}))}
value={filter.value}
onChange={(value) => onChange(filter.key, value as string[])}
/>
))}
<Clear
variant={'label2'}
onClick={handleClear}
className={`${showClear ? 'show' : ''}`}
>
<span>clear</span>
<span> </span>
<span>filters</span>
</Clear>
</Container>
)
}
const Container = styled.div`
&.search-box-filters {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
${({ theme }) => lsdUtils.breakpoint(theme, 'xs', 'exact')} {
.lsd-dropdown--small {
width: 135px;
}
}
}
`
const Clear = styled(Typography)`
opacity: 0;
transition: all 0.2s ease-in-out;
text-decoration: underline;
cursor: pointer;
&.show {
opacity: 1;
}
${(props) => lsdUtils.breakpoint(props.theme, 'xs', 'exact')} {
span:not(:first-child) {
display: none;
}
}
`

View File

@ -0,0 +1,240 @@
import { CloseIcon, Typography } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import clsx from 'clsx'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import { copyConfigs } from '../../configs/copy.configs'
import { nope } from '../../utils/general.utils'
const placeholders = {
global: copyConfigs.search.searchbarPlaceholders.global(),
post: (
<>
<Typography variant="label1" component="span">
{copyConfigs.search.searchbarPlaceholders.article()}
</Typography>
<Typography variant="label1" component="span">
&nbsp;
</Typography>
<Link href="/search">
<Typography variant="label1" component="span">
global search
</Typography>
</Link>
</>
),
}
export type SearchBoxInputProps = Partial<
Omit<
React.ComponentProps<typeof Container>,
'value' | 'onChange' | 'placeholder'
>
> & {
value?: string
keepEnlarged?: boolean
showClearButton?: boolean
globalMode?: boolean
triggerOnBlur?: boolean
onChange?: (value: string, event?: 'clear') => void
onFocusChange?: (value: boolean) => void
onActive?: (value: boolean) => void
}
export const SearchBoxInput: React.FC<SearchBoxInputProps> = ({
value = '',
keepEnlarged = false,
showClearButton = false,
globalMode = false,
triggerOnBlur = false,
onChange = nope,
onFocusChange = nope,
onActive = nope,
...props
}) => {
const [input, setInput] = useState(value)
const [focused, setFocused] = useState(false)
const [placeholder, setPlaceholder] = useState<React.ReactNode>(
placeholders[globalMode ? 'global' : 'post'],
)
const enlarged = !keepEnlarged
? focused || input.length > 0
: input.length !== 0 || focused
const handleKeyUp = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onChange(input)
}
}
useEffect(() => {
if (input !== value) setInput(value)
}, [value])
useEffect(() => {
onFocusChange(focused)
}, [focused])
const handleClear = () => {
setInput('')
onChange('', 'clear')
}
useEffect(() => {
if (focused) {
setPlaceholder('')
} else {
setTimeout(() => {
setPlaceholder(placeholders[globalMode ? 'global' : 'post'])
}, 200)
}
}, [focused, globalMode])
useEffect(() => {
onActive(focused || input.length > 0)
}, [focused, input])
return (
<Container
{...props}
className={clsx(
'search-box-input',
enlarged && 'search-box-input--active',
props.className,
)}
>
<div className="search-box-input__wrapper">
<input
className={`search-box-input__input`}
placeholder={typeof placeholder === 'string' ? placeholder : ''}
value={input as string}
onFocus={() => {
setFocused(true)
}}
onKeyUp={handleKeyUp}
onChange={(e) => setInput(e.target.value)}
onBlurCapture={() => {
setFocused(false)
if (triggerOnBlur) onChange(input)
}}
/>
{placeholder &&
typeof placeholder !== 'string' &&
input.length === 0 && (
<>
<span className="search-box-input__placeholder">
{placeholder}
</span>
</>
)}
<div className="search-box-input__clear">
<span className="search-box-input__text">
<pre>{input}</pre>
</span>
{showClearButton && !focused && input.trim().length > 0 && (
<CloseIcon
className="search-box-input__clear-button"
color="primary"
onClick={handleClear}
/>
)}
</div>
</div>
</Container>
)
}
const Container = styled.div`
&.search-box-input {
flex-grow: 1;
min-width: 0;
max-width: calc(100% - 32px);
}
.search-box-input__input {
background: transparent;
font-size: var(--lsd-label1-fontSize);
line-height: var(--lsd-label1-lineHeight);
outline: none;
border: none;
width: 100%;
height: 44px;
::placeholder {
color: rgba(var(--lsd-text-primary), 0.3);
}
:-ms-input-placeholder {
color: rgba(var(--lsd-text-primary), 0.3);
}
::-ms-input-placeholder {
color: rgba(var(--lsd-text-primary), 0.3);
}
}
.search-box-input__wrapper {
display: grid;
grid-template-rows: auto;
grid-template-columns: 100%;
width: 100%;
max-width: 100%;
overflow: hidden;
& > * {
grid-area: 1 / 1 / 1 / 1;
font-size: var(--lsd-label1-fontSize);
line-height: var(--lsd-label1-lineHeight);
height: 44px;
padding: 1px 2px;
transition: all 0.2s ease-in-out;
max-width: 100%;
}
}
.search-box-input__clear {
display: flex;
flex-direction: row;
align-items: center;
pointer-events: none;
max-width: 100%;
span {
display: inline;
opacity: 0;
visibility: hidden;
}
.lsd-icon {
margin-left: 14px;
cursor: pointer;
pointer-events: all;
}
}
.search-box-input__placeholder {
display: flex;
flex-direction: row;
align-items: center;
pointer-events: none;
& > *:not(a) {
opacity: 0.34;
}
a {
pointer-events: all;
}
}
&.search-box-input--active {
.search-box-input__wrapper > * {
font-size: var(--lsd-h4-lineHeight);
line-height: var(--lsd-h4-lineHeight);
}
}
`

View File

@ -0,0 +1,142 @@
import { copyConfigs } from '@/configs/copy.configs'
import { Typography } from '@acid-info/lsd-react'
import styled from '@emotion/styled'
import clsx from 'clsx'
import React from 'react'
import { useIsMobile } from '../../utils/ui.utils'
import { DotIcon } from '../Icons/DotIcon'
import { SearchBoxProps } from './SearchBox'
export type SearchBoxResultsProps = Partial<
Omit<React.ComponentProps<typeof Container>, 'title'>
> &
Pick<SearchBoxProps, 'filters'> & {
title?: string
query?: string
fetching?: boolean
showDetails?: boolean
numberOfResults?: number
containerRef?: React.LegacyRef<HTMLDivElement>
}
export const SearchBoxResults: React.FC<SearchBoxResultsProps> = ({
title,
query,
filters,
fetching,
numberOfResults,
showDetails = false,
containerRef,
...props
}) => {
const isMobile = useIsMobile()
const content = fetching ? (
<Typography variant="subtitle2">Searching...</Typography>
) : (
<>
<Typography variant={'subtitle2'}>
{numberOfResults === 0
? copyConfigs.search.results.noResults
: `${numberOfResults} ${copyConfigs.search.results.results}`}
</Typography>
{title && !isMobile && (
<>
<span className="dot">
<DotIcon color="primary" />
</span>
<Typography
component="span"
variant="subtitle2"
className="search-box-results__title"
>
{title}
</Typography>
</>
)}
{query && query.length > 0 && (
<>
<span className="dot ">
<DotIcon className="search-box-results__details" color="primary" />
</span>
<Typography
component="span"
variant="subtitle2"
className="search-box-results__details"
>
{query}
</Typography>
</>
)}
{filters &&
filters.length > 0 &&
filters
.filter((f) => f.value.length > 0)
.map((filter) => (
<React.Fragment key={filter.key}>
<span className="dot ">
<DotIcon
className="search-box-results__details"
color="primary"
/>
</span>
{filter.value.map((filterTag, index) => (
<Typography
key={index}
component="span"
variant="subtitle2"
className="search-box-results__details"
>
[{filterTag}]
</Typography>
))}
</React.Fragment>
))}
</>
)
return (
<Container
{...props}
ref={containerRef}
className={clsx(
props.className,
'search-box-results',
showDetails && 'search-box-results--show-details',
)}
>
{content}
</Container>
)
}
const Container = styled.div`
&.search-box-results {
width: 100%;
display: block;
overflow: hidden;
white-space: nowrap;
}
.dot {
margin: 0 8px;
> * {
vertical-align: middle;
}
}
.search-box-results__details {
opacity: 0;
display: inline-block;
transform: translateY(-40px);
transition: all 0.2s ease-in-out;
}
&.search-box-results--show-details {
.search-box-results__details {
opacity: 1;
transform: translateY(0);
}
}
`

View File

@ -1 +1 @@
export { default as SearchBox } from './SearchBox'
export * from './SearchBox'

View File

@ -13,7 +13,7 @@ export const copyConfigs = {
search: {
searchbarPlaceholders: {
global: () => 'Search through the LPE posts...',
article: () => `Search through the article or switch to `,
article: () => `Search on this page or go to`,
},
views: {
default: 'List',

View File

@ -1,11 +1,14 @@
import ArticleBody from '@/components/Article/Article.Body'
import { Grid, GridItem } from '@/components/Grid/Grid'
import { TableOfContents } from '@/components/TableOfContents'
import { ArticleContainerContext } from '@/containers/ArticleContainer.Context'
import { css } from '@emotion/react'
import styled from '@emotion/styled'
import { useState } from 'react'
import { TableOfContents } from '../components/TableOfContents'
import { LPE } from '../types/lpe.types'
import { lsdUtils } from '../utils/lsd.utils'
import { PostSearchContainer } from './PostSearchContainer'
import { PostSearchContext } from './PostSearchContainer/PostSearch.context'
interface Props {
data: LPE.Article.Document
@ -16,20 +19,42 @@ const ArticleContainer = (props: Props) => {
const [tocId, setTocId] = useState<string | null>(null)
return (
<ArticleContainerContext.Provider value={{ tocId, setTocId }}>
<ArticleGrid>
<ArticleTocContainer className={'w-3'}>
<TableOfContents contents={data.data.toc ?? []} />
</ArticleTocContainer>
<Gap className={'w-1'} />
<ArticleBodyContainer className={'w-8'}>
<ArticleBody data={data} />
</ArticleBodyContainer>
</ArticleGrid>
</ArticleContainerContext.Provider>
<PostSearchContainer postId={data.data.id} postTitle={data.data.title}>
<PostSearchContext.Consumer>
{(search) => (
<ArticleContainerContext.Provider value={{ tocId, setTocId }}>
<ArticleGrid searchMode={search.active}>
<ArticleTocContainer className={'w-3'}>
{!search.active && (
<TableOfContents contents={data.data.toc ?? []} />
)}
</ArticleTocContainer>
<Gap className={'w-1'} />
<ArticleBodyContainer className={'w-8'}>
<ArticleBody
data={data}
header={!search.active}
footer={!search.active}
/>
</ArticleBodyContainer>
</ArticleGrid>
</ArticleContainerContext.Provider>
)}
</PostSearchContext.Consumer>
</PostSearchContainer>
)
}
const ArticleGrid = styled(Grid)<{ searchMode?: boolean }>`
width: 100%;
${(props) =>
props.searchMode &&
css`
padding-top: 90px;
`}
`
const ArticleBodyContainer = styled(GridItem)`
${(props) => lsdUtils.breakpoint(props.theme, 'sm', 'between', 'lg')} {
grid-column: span 10 !important;
@ -42,10 +67,6 @@ const ArticleTocContainer = styled(GridItem)`
}
`
const ArticleGrid = styled(Grid)`
width: 100%;
`
const Gap = styled(GridItem)`
${(props) => lsdUtils.breakpoint(props.theme, 'xs', 'down')} {
display: none;

View File

@ -0,0 +1,134 @@
import { LPE } from '@/types/lpe.types'
import { useHydrated } from '@/utils/useHydrated.util'
import React from 'react'
import {
ArrayParam,
StringParam,
useQueryParam,
withDefault,
} from 'use-query-params'
import { SearchBox, SearchBoxProps } from '../../components/SearchBox/SearchBox'
import { nope } from '../../utils/general.utils'
import { formatTagText } from '../../utils/string.utils'
import { useIsMobile } from '../../utils/ui.utils'
import PostTypes = LPE.PostTypes
import ContentBlockTypes = LPE.Post.ContentBlockTypes
const ContentTypesCategories = {
Post: 'post',
Block: 'block',
} as const
const contentTypes = [
{
label: 'Articles',
value: PostTypes.Article,
category: ContentTypesCategories.Post,
},
{
label: 'Podcasts',
value: PostTypes.Podcast,
category: ContentTypesCategories.Post,
},
{
label: 'Paragraphs',
value: ContentBlockTypes.Text,
category: ContentTypesCategories.Block,
},
{
label: 'Images',
value: ContentBlockTypes.Image,
category: ContentTypesCategories.Block,
},
]
const allContentTypes = contentTypes.map((c) => c.value)
export type GlobalSearchBoxProps = Pick<
SearchBoxProps,
'view' | 'views' | 'onViewChange' | 'fetching'
> & {
fetching?: boolean
tags?: string[]
resultsNumber: number | null
onSearch?: (query: string, tags: string[], types: LPE.ContentType[]) => void
}
export const GlobalSearchBox: React.FC<GlobalSearchBoxProps> = ({
fetching,
tags = [],
view,
views,
resultsNumber,
onSearch = nope,
onViewChange,
}) => {
const hydrated = useHydrated()
const isMobile = useIsMobile()
const [filterTags, setFilterTags] = useQueryParam(
'topic',
withDefault(ArrayParam, []),
{
skipUpdateWhenNoChange: false,
},
)
const [filterContentTypes, setFilterContentTypes] = useQueryParam(
'type',
withDefault(ArrayParam, allContentTypes),
{
skipUpdateWhenNoChange: true,
},
)
const [query, setQuery] = useQueryParam('q', withDefault(StringParam, ''), {
skipUpdateWhenNoChange: true,
})
return (
<SearchBox
query={!hydrated ? '' : query}
fetching={fetching}
onFilterChange={(key, value) => {
setFilterContentTypes([])
if (key === 'type') setFilterContentTypes(value)
else setFilterTags(value)
}}
onQueryChange={(q) => setQuery(q)}
onViewChange={onViewChange}
numberOfResults={resultsNumber || 0}
onSearch={() => {
onSearch(
query,
filterTags as string[],
filterContentTypes as LPE.ContentType[],
)
}}
view={view}
{...(isMobile ? {} : { views })}
filters={[
{
key: 'type',
label: 'Content Type',
value: !hydrated ? [] : (filterContentTypes as string[]),
defaultValue: allContentTypes,
options: contentTypes.map((type) => ({
label: type.label,
value: type.value,
})),
},
{
key: 'topic',
label: 'Topics',
value: !hydrated ? [] : (filterTags as string[]),
options: tags.map((tag) => ({
label: formatTagText(tag),
value: tag,
})),
},
]}
/>
)
}

View File

@ -0,0 +1 @@
export * from './GlobalSearchBox'

View File

@ -0,0 +1,31 @@
import React, { useContext } from 'react'
import { LPE } from '../../types/lpe.types'
type Block =
| LPE.Search.ResultItemBase<LPE.Post.TextBlock>
| LPE.Search.ResultItemBase<LPE.Post.ImageBlock>
export type PostSearchContextType = {
active?: boolean
query?: string
fetching?: boolean
results: Block[]
mappedResults?: Record<string, Block>
}
export const PostSearchContext = React.createContext<PostSearchContextType>({
active: false,
query: '',
fetching: false,
results: [],
mappedResults: {},
})
export const usePostSearch = () =>
useContext(PostSearchContext) || {
query: '',
results: [],
mappedResults: {},
active: false,
fetching: false,
}

View File

@ -0,0 +1,106 @@
import styled from '@emotion/styled'
import React, { useEffect, useState } from 'react'
import { SearchBox } from '../../components/SearchBox'
import { uiConfigs } from '../../configs/ui.configs'
import { usePostSearchQuery } from '../../queries/usePostSearch.query'
import { useNavbarState } from '../../states/navbarState'
import { useOnWindowResize } from '../../utils/ui.utils'
import { PostSearchContext } from './PostSearch.context'
export type PostSearchContainerProps = {
postId?: string
postTitle?: string
}
export const PostSearchContainer: React.FC<
React.PropsWithChildren<PostSearchContainerProps>
> = ({ postId = '', postTitle, children, ...props }) => {
const navbarState = useNavbarState()
const [query, setQuery] = useState('')
const [active, setActive] = useState(false)
const [height, setHeight] = useState(
typeof window === 'undefined' ? 0 : document.body.scrollHeight,
)
const res = usePostSearchQuery({
id: postId,
query,
active: active && query.length > 0,
})
useEffect(() => {
const onSearch = () => {
setActive(true)
}
navbarState.setOnSearchCallback(onSearch)
return () => {
navbarState.setOnSearchCallback(null)
navbarState.setShowSearchButton(true)
}
}, [])
useEffect(() => {
navbarState.setShowSearchButton(!active)
if (!active) {
setQuery('')
}
}, [active])
useOnWindowResize(() => {
setHeight(document.body.scrollHeight)
})
useEffect(() => {
setHeight(document.body.scrollHeight)
if (active && query.length > 0) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [active, query, res.data])
return (
<>
<SearchBoxContainer active={active} height={height}>
<SearchBox
query={query}
onClearQuery={() => setQuery('')}
onClose={() => setActive(false)}
onQueryChange={(q) => setQuery(q)}
title={postTitle}
showClearQueryButton
globalMode={false}
showCloseButton
fetching={res.isLoading}
numberOfResults={res.data?.result?.length}
/>
</SearchBoxContainer>
<PostSearchContext.Provider
value={{
query,
fetching: res.isLoading,
active: active && query.length > 0,
results: res.data?.result || [],
mappedResults: res.data?.mapped || {},
}}
>
{children}
</PostSearchContext.Provider>
</>
)
}
const SearchBoxContainer = styled.div<{ active?: boolean; height?: number }>`
position: absolute;
height: ${(props) => props.height || 0}px;
min-height: 100vh;
width: calc(100% - 2 * var(--main-content-padding));
max-width: ${uiConfigs.maxContainerWidth}px;
top: ${uiConfigs.navbarRenderedHeight - 1}px;
& > div {
transform: translateY(${(props) => (props.active ? '0' : '-100%')});
transition: 0.3s;
}
`

View File

@ -0,0 +1 @@
export * from './PostSearchContainer'

View File

@ -1,15 +1,15 @@
import { SearchBox } from '@/components/SearchBox'
import { uiConfigs } from '@/configs/ui.configs'
import { SearchResultsExploreView } from '@/containers/Search/ExploreView'
import { SearchResultsListView } from '@/containers/Search/ListView'
import { LPE } from '@/types/lpe.types'
import { searchBlocksBasicFilter } from '@/utils/search.utils'
import useWindowSize from '@/utils/ui.utils'
import { useQuery } from '@tanstack/react-query'
import NextAdapterPages from 'next-query-params'
import { ReactNode, useEffect, useState } from 'react'
import { ReactNode, useState } from 'react'
import { QueryParamProvider } from 'use-query-params'
import SEO from '../components/SEO/SEO'
import { copyConfigs } from '../configs/copy.configs'
import { GlobalSearchBox } from '../containers/GlobalSearchBox/GlobalSearchBox'
import { DefaultLayout } from '../layouts/DefaultLayout'
import { api } from '../services/api.service'
import unbodyApi from '../services/unbody/unbody.service'
@ -22,17 +22,7 @@ interface SearchPageProps {
}
export default function SearchPage({ topics, shows }: SearchPageProps) {
const [mounted, setMounted] = useState(false)
const [busy, setBusy] = useState(false)
const [view, setView] = useState<string>('list')
const isMobile = useWindowSize().width < 768
useEffect(() => {
setMounted(true)
return () => {
setMounted(false)
}
}, [])
const [query, setQuery] = useState<string>('')
const [tags, setTags] = useState<string[]>([])
@ -64,6 +54,7 @@ export default function SearchPage({ topics, shows }: SearchPageProps) {
[]) as LPE.Search.ResultItemBase<LPE.Post.ContentBlock>[]
const posts = (data?.posts ||
[]) as LPE.Search.ResultItemBase<LPE.Post.Document>[]
const handleSearch = async (
query: string,
filteredTags: string[],
@ -83,13 +74,17 @@ export default function SearchPage({ topics, shows }: SearchPageProps) {
return (
<div style={{ minHeight: '80vh' }}>
<SEO title="Search" pagePath={`/search`} />
<SearchBox
<GlobalSearchBox
view={view}
views={[
{ key: 'list', label: copyConfigs.search.views.default },
{ key: 'explore', label: copyConfigs.search.views.explore },
]}
tags={topics}
onSearch={handleSearch}
resultsNumber={resultsNumber}
busy={isLoading}
fetching={isLoading}
onViewChange={setView}
showModeSwitch={!isMobile}
/>
{view === 'list' && (
<SearchResultsListView

View File

@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '../services/api.service'
import { LPE } from '../types/lpe.types'
import { searchBlocksBasicFilter } from '../utils/search.utils'
export const usePostSearchQuery = ({
id,
query,
limit = 50,
active = false,
}: {
id: string
query: string
limit?: number
active?: boolean
}) =>
useQuery<{
result: LPE.Search.ResultBlockItem[]
mapped: Record<string, LPE.Search.ResultBlockItem>
}>(['post-search-query', active, id, query, limit], async () =>
!active
? { result: [], mapped: {} }
: api.searchPostBlocks({ limit, id, query, skip: 0 }).then((res) => {
let items = res.data.blocks as LPE.Search.ResultBlockItem[]
items = items.filter(searchBlocksBasicFilter)
return {
result: items,
mapped: Object.fromEntries(
items.map((item) => [item.data.id, item]),
),
}
}),
)

View File

@ -5,11 +5,15 @@ import { copyConfigs } from '../../configs/copy.configs'
export type NavbarState = {
title: string
showTitle: boolean
showSearchButton: boolean
onSearch: (() => void) | null
}
export const defaultNavbarState: NavbarState = {
showTitle: true,
title: copyConfigs.navbar.title,
onSearch: null,
showSearchButton: true,
}
const navbarState = hookstate<NavbarState>(defaultNavbarState)
@ -18,7 +22,12 @@ const wrapThemeState = (state: State<NavbarState>) => ({
state,
title: state.title,
showTitle: state.showTitle,
onSearch: state.get({ noproxy: true }).onSearch,
showSearchButton: state.showSearchButton,
setShowTitle: (value: boolean) => state.showTitle.set(value),
setShowSearchButton: (value: boolean) => state.showSearchButton.set(value),
setOnSearchCallback: (callback: (() => void) | null) =>
state.set((current) => ({ ...current, onSearch: callback })),
})
export const useNavbarState = (defaultState?: Partial<NavbarState>) => {

View File

@ -257,11 +257,12 @@ export namespace LPE {
type: ContentType
}
export type ResultItem =
| ResultItemBase<LPE.Post.Document>
export type ResultBlockItem =
| ResultItemBase<LPE.Post.TextBlock>
| ResultItemBase<LPE.Post.ImageBlock>
export type ResultItem = ResultItemBase<LPE.Post.Document> | ResultBlockItem
export type Result = {
posts: Search.ResultItem[]
blocks: Search.ResultItem[]