feat: implement post search; refs #137
This commit is contained in:
parent
ef5b2671fe
commit
5ed6fd7dc2
|
@ -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) => (
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
`
|
|
@ -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">
|
||||
|
||||
</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);
|
||||
}
|
||||
}
|
||||
`
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
`
|
|
@ -1 +1 @@
|
|||
export { default as SearchBox } from './SearchBox'
|
||||
export * from './SearchBox'
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './GlobalSearchBox'
|
|
@ -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,
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
`
|
|
@ -0,0 +1 @@
|
|||
export * from './PostSearchContainer'
|
|
@ -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
|
||||
|
|
|
@ -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]),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
|
@ -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>) => {
|
||||
|
|
|
@ -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[]
|
||||
|
|
Loading…
Reference in New Issue