diff --git a/package.json b/package.json index a5972d7..31191e8 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "graphql": "^16.7.1", "graphql-request": "^6.0.0", "next": "13.3.0", + "next-query-params": "^4.2.3", "nextjs-progressbar": "^0.0.16", "react": "18.2.0", "react-blurhash": "^0.3.0", @@ -53,7 +54,8 @@ "react-imgix": "^9.7.0", "react-player": "^2.12.0", "react-use": "^17.4.0", - "typescript": "5.0.4" + "typescript": "5.0.4", + "use-query-params": "^2.2.1" }, "devDependencies": { "@graphql-codegen/cli": "5.0.0", diff --git a/src/components/Search/SearchResult.TopPost.tsx b/src/components/Search/SearchResult.TopPost.tsx index d1eff42..332c99d 100644 --- a/src/components/Search/SearchResult.TopPost.tsx +++ b/src/components/Search/SearchResult.TopPost.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled' import { PostCard } from '@/components/PostCard' import { SearchResultTopPostBlocks } from '@/components/Search/SearchResult.TopPost.Blocks' import { Typography } from '@acid-info/lsd-react' +import { lsdUtils } from '@/utils/lsd.utils' interface Props { post: LPE.Search.ResultItemBase @@ -29,4 +30,8 @@ export const SearchResultTopPost = ({ post, shows, blocks }: Props) => { const Container = styled.div` padding: 24px 0; + + ${({ theme }) => lsdUtils.breakpoint(theme, 'xs', 'exact')} { + padding: 0; + } ` diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index dd855ec..0582c1f 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -1,40 +1,29 @@ import styled from '@emotion/styled' -import styles from '@/components/Searchbar/Search.module.css' -import { - Button, - Dropdown, - DropdownItem, - DropdownMenu, - TabItem, - Tabs, - TextField, - Typography, -} from '@acid-info/lsd-react' -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { - addContentTypesToQuery, - addQueryToQuery, - addTopicsToQuery, - extractContentTypesFromQuery, - extractQueryFromQuery, - extractTopicsFromQuery, -} from '@/utils/search.utils' -import { NextRouter, useRouter } from 'next/router' +import { Dropdown, TabItem, Tabs, Typography } from '@acid-info/lsd-react' +import React, { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/router' import { nope } from '@/utils/general.utils' import { LPE } from '@/types/lpe.types' import PostTypes = LPE.PostTypes import ContentBlockTypes = LPE.Post.ContentBlockTypes -import { ESearchScope } from '@/types/ui.types' import { copyConfigs } from '@/configs/copy.configs' -import { useOutsideClick, useSticky } from '@/utils/ui.utils' import { uiConfigs } from '@/configs/ui.configs' - +import { useHydrated } from '@/utils/useHydrated.util' +import { + ArrayParam, + StringParam, + useQueryParam, + withDefault, +} from 'use-query-params' +import { useDeepCompareEffect } from 'react-use' +import { lsdUtils } from '@/utils/lsd.utils' interface SearchBoxProps { onSearch?: (query: string, tags: string[], types: LPE.ContentType[]) => void tags?: string[] onViewChange?: (view: string) => void resultsNumber: number | null busy?: boolean + showModeSwitch?: boolean } const ContentTypesCategories = { @@ -67,83 +56,6 @@ const contentTypes = [ const allContentTypes = contentTypes.map((c) => c.value) -const useSearchBox = ( - router: NextRouter, - callback: (query: string, tags: string[], types: LPE.ContentType[]) => void, -) => { - const [query, setQuery] = useState( - extractQueryFromQuery(router.query), - ) - const [filterTags, setFilterTags] = useState( - extractTopicsFromQuery(router.query), - ) - const [filterContentTypes, setFilterContentTypes] = - useState(allContentTypes) - - useEffect(() => { - setQuery(extractQueryFromQuery(router.query)) - setFilterTags(extractTopicsFromQuery(router.query)) - const contentTypes = extractContentTypesFromQuery(router.query) - setFilterContentTypes(contentTypes.length ? contentTypes : allContentTypes) - }, [router.query, router.query.topics, router.pathname]) - - const performSearch = useCallback( - async ( - q: string = query, - _filterTags: string[] = filterTags, - _contentTypes: string[] = [], - ) => { - const queries = [ - addQueryToQuery(q), - addTopicsToQuery(_filterTags), - addContentTypesToQuery( - _contentTypes.length === allContentTypes.length ? [] : _contentTypes, - ), - ].filter((n) => n && n) - - callback(q, _filterTags, _contentTypes as LPE.ContentType[]) - - await router.push( - { - pathname: '/search', - query: queries.length ? queries.join('&') : undefined, - }, - undefined, - { - shallow: true, - }, - ) - }, - [router, filterTags, filterContentTypes, query], - ) - - const handleEnter = async (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - await performSearch() - } - } - - const handleTypesChange = async (n: string | string[]) => { - await performSearch(undefined, undefined, n as string[]) - } - const handleTagsChange = async (n: string | string[]) => { - await performSearch(undefined, n as string[]) - } - - return { - query, - filterTags, - filterContentTypes, - setQuery, - setFilterTags, - setFilterContentTypes, - handleEnter, - handleTypesChange, - handleTagsChange, - performSearch, - } -} - const SearchBox = (props: SearchBoxProps) => { const { onSearch = nope, @@ -151,20 +63,30 @@ const SearchBox = (props: SearchBoxProps) => { tags = [], resultsNumber, busy = false, + showModeSwitch = true, } = props - const router = useRouter() - const { - query, - filterTags, - filterContentTypes, - setQuery, - handleEnter, - handleTypesChange, - handleTagsChange, - performSearch, - } = useSearchBox(router, onSearch) - const [view, setView] = useState('list') + 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 [queryInput, setQueryInput] = useState(query) + + const [view, setView] = useState('list') const [enlargeQuery, setEnlargeQuery] = useState(false) const [placeholder, setPlaceholder] = useState( copyConfigs.search.searchbarPlaceholders.global(), @@ -185,18 +107,24 @@ const SearchBox = (props: SearchBoxProps) => { const handleViewChange = async (n: string) => { setView(n) onViewChange(n) - performSearch() + onSearch( + query, + filterTags as string[], + filterContentTypes as LPE.ContentType[], + ) } useEffect(() => { - if (enlargeQuery) { + setEnlargeQuery(!(queryInput.length === 0 && !focused)) + if (focused) { setPlaceholder('') } else { setTimeout(() => { setPlaceholder(copyConfigs.search.searchbarPlaceholders.global()) }, 200) + setQuery('') } - }, [enlargeQuery]) + }, [focused, queryInput]) useEffect(() => { if (filtersRef.current) { @@ -204,7 +132,7 @@ const SearchBox = (props: SearchBoxProps) => { const parentT = filtersRef.current.parentElement?.getBoundingClientRect().top || 0 const whereResultsStick = - -1 * (filtersB - parentT - uiConfigs.navbarRenderedHeight) + -1 * (filtersB - parentT - uiConfigs.navbarRenderedHeight + 2) setWhereResultsStick(whereResultsStick) setDetailsTop(filtersB + whereResultsStick) } @@ -218,17 +146,6 @@ const SearchBox = (props: SearchBoxProps) => { return () => window.removeEventListener('scroll', onScroll) }, [detailsTop]) - useEffect(() => { - if (query.length === 0 && mounted && !focused) { - setTimeout(() => { - setEnlargeQuery(false) - }, 200) - } else { - setEnlargeQuery(true) - setPlaceholder('') - } - }, [query]) - useEffect(() => { if ( filterContentTypes.length < allContentTypes.length || @@ -240,10 +157,32 @@ const SearchBox = (props: SearchBoxProps) => { } }, [filterTags, filterContentTypes]) - const handleClear = async () => { - await performSearch(undefined, [], []) + const clear = async () => { + setQuery('') + setFilterTags([]) + setFilterContentTypes(allContentTypes) } + const handleKeyUp = async (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + setQuery(queryInput) + } + } + + useEffect(() => { + if (query?.length > 0) setQueryInput(query) + }, [query]) + + useDeepCompareEffect(() => { + onSearch( + query, + filterTags as string[], + filterContentTypes as LPE.ContentType[], + ) + }, [query, filterTags, filterContentTypes]) + + const hydrated = useHydrated() + return ( { { - if (query.length === 0) { - setEnlargeQuery(true) - } setFocused(true) }} - onKeyDown={handleEnter} - onChange={(e) => setQuery(e.target.value)} + onKeyUp={handleKeyUp} + onChange={(e) => setQueryInput(e.target.value)} onBlurCapture={() => { - if (query.length === 0) { - setEnlargeQuery(false) - performSearch() - } setFocused(false) }} /> - - - {copyConfigs.search.views.default} - - {copyConfigs.search.views.explore} - - - + {showModeSwitch && ( + + + + {copyConfigs.search.views.default} + + + {copyConfigs.search.views.explore} + + + + )} ({ name: c.label, value: c.value }))} - value={filterContentTypes} - onChange={handleTypesChange} + value={hydrated ? (filterContentTypes as string[]) : []} + onChange={(n) => setFilterContentTypes(n as string[])} multi={true} /> ({ name: t, value: t }))} - value={filterTags} - onChange={handleTagsChange} + value={hydrated ? (filterTags as string[]) : []} + onChange={(n) => setFilterTags(n as string[])} multi={true} triggerLabel={'Topics'} /> Clear Filters @@ -352,7 +288,7 @@ const Container = styled.div` flex-direction: column; gap: 12px; border-bottom: 1px solid rgba(var(--lsd-text-primary), 1); - padding: 8px 0 14px 14px; + padding: 8px 14px; position: sticky; z-index: 1; @@ -391,7 +327,6 @@ const Container = styled.div` } ` -// align last item to the right and first item takes the rest of the space const FirstRow = styled.div` display: flex; flex-direction: row; @@ -410,6 +345,12 @@ const Filters = styled.div` 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%; @@ -420,6 +361,10 @@ const Results = styled.div` overflow: hidden; + > span { + white-space: nowrap; + } + .dot { width: 3px; height: 3px; diff --git a/src/configs/ui.configs.ts b/src/configs/ui.configs.ts index 893bf3d..d6702a8 100644 --- a/src/configs/ui.configs.ts +++ b/src/configs/ui.configs.ts @@ -5,4 +5,9 @@ export const uiConfigs = { articleSectionMargin: 40, maxContainerWidth: 1440, articleRenderedMT: 45 * 2, + searchExplore: { + gridColumns: 16, + largeColumn: 4, + smallColumn: 2, + }, } diff --git a/src/containers/Search/ExploreView.tsx b/src/containers/Search/ExploreView.tsx index fe380aa..13d8ae5 100644 --- a/src/containers/Search/ExploreView.tsx +++ b/src/containers/Search/ExploreView.tsx @@ -4,13 +4,76 @@ import { Grid, GridItem } from '@/components/Grid/Grid' import { useMemo } from 'react' import { PostCard } from '@/components/PostCard' import { ImageBlock, TextBlock } from '@/components/ContentBlock' +import { uiConfigs } from '@/configs/ui.configs' + +type Block = LPE.Search.ResultItemBase +type Post = LPE.Search.ResultItemBase interface Props { - posts: LPE.Search.ResultItemBase[] - blocks: LPE.Search.ResultItemBase[] + posts: Post[] + blocks: Block[] shows: LPE.Podcast.Show[] } +const distributeBlocks = (blocks: (Block | Post)[]): (Block | Post)[][] => { + let rows: (Block | Post)[][] = [] + let currentRow: (Block | Post)[] = [] + let currentRowWidth = 0 + + const getBlockWidth = (block: Block | Post): number => { + return block.type === LPE.ContentTypes.Image + ? uiConfigs.searchExplore.smallColumn + : uiConfigs.searchExplore.largeColumn + } + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] + const blockWidth = getBlockWidth(block) + + if (currentRowWidth + blockWidth <= uiConfigs.searchExplore.gridColumns) { + currentRow.push(block) + currentRowWidth += blockWidth + } else { + // Look ahead to find a block that fits. + let swapIndex = -1 + for (let j = i + 1; j < blocks.length; j++) { + const nextBlockWidth = getBlockWidth(blocks[j]) + if ( + currentRowWidth + nextBlockWidth <= + uiConfigs.searchExplore.gridColumns + ) { + swapIndex = j + break + } + } + + if (swapIndex !== -1) { + // Swap blocks and add the fitting block to the current row. + ;[blocks[i], blocks[swapIndex]] = [blocks[swapIndex], blocks[i]] + currentRow.push(blocks[i]) + currentRowWidth += blockWidth + } else { + // No fitting block found, go with the best fit. + rows.push([...currentRow]) + currentRow = [block] + currentRowWidth = blockWidth + } + } + + if (currentRowWidth === uiConfigs.searchExplore.gridColumns) { + rows.push([...currentRow]) + currentRow = [] + currentRowWidth = 0 + } + } + + if (currentRow.length) { + rows.push(currentRow) + } + + return rows +} + export const SearchResultsExploreView = (props: Props) => { const { posts, shows, blocks } = props const results = useMemo(() => { @@ -18,50 +81,88 @@ export const SearchResultsExploreView = (props: Props) => { return b.score - a.score }) }, [posts, blocks]) + + const items = distributeBlocks(results) + return ( - - {results.map((result, index) => { - return ( - - {(() => { - switch (result.type) { - case LPE.ContentTypes.Article: - case LPE.ContentTypes.Podcast: - return ( - - ) - case LPE.ContentTypes.Image: - return ( - )} - /> - ) - case LPE.ContentTypes.Text: - return ( - )} - /> - ) - default: - return null - } - })()} - - ) - })} + + {items.map((row, idx) => ( + + {row.map((result, index) => { + return ( + + {(() => { + switch (result.type) { + case LPE.ContentTypes.Podcast: + return ( + + ) + case LPE.ContentTypes.Article: + return ( + + ) + case LPE.ContentTypes.Image: + return ( + )} + /> + ) + case LPE.ContentTypes.Text: + return ( + )} + /> + ) + default: + return null + } + })()} + + ) + })} + + ))} ) } -const Container = styled(Grid)`` -const ResultItem = styled(GridItem)`` +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 56px; +` +const Row = styled(Grid)` + grid-template-columns: repeat(${uiConfigs.searchExplore.gridColumns}, 1fr); + grid-column-gap: 16px; +` +const ResultItem = styled(GridItem)` + border-top: 1px solid rgb(var(--lsd-text-primary)); + padding: 24px 0; + + > * { + padding-top: 0; + } +` diff --git a/src/containers/Search/ListView.tsx b/src/containers/Search/ListView.tsx index 32f4da1..c650dde 100644 --- a/src/containers/Search/ListView.tsx +++ b/src/containers/Search/ListView.tsx @@ -7,20 +7,18 @@ import { SearchResultListPosts } from '@/components/Search/SearchResultList.Post import { useMemo } from 'react' import { SearchResultTopPost } from '@/components/Search/SearchResult.TopPost' import { SearchResultListBlocks } from '@/components/Search/SearchResult.Blocks' +import { Typography } from '@acid-info/lsd-react' +import { lsdUtils } from '@/utils/lsd.utils' interface Props { posts: LPE.Search.ResultItemBase[] blocks: LPE.Search.ResultItemBase[] shows: LPE.Podcast.Show[] -} - -type Accumulator = { - count: number - block: LPE.Search.ResultItemBase + busy: boolean } export const SearchResultsListView = (props: Props) => { - const { posts, shows, blocks } = props + const { posts, shows, blocks, busy } = props const mostReferredPostIndex = useMemo(() => { // Extract the IDs of the first 3 posts @@ -90,25 +88,37 @@ export const SearchResultsListView = (props: Props) => { )} - {renderPosts.length > 0 && ( + {renderPosts.length > 0 ? ( <> + ) : ( + !busy && ( + + No results found + + ) )} - {renderBlocks.length > 0 && ( + {renderBlocks.length > 0 ? ( <> + ) : ( + !busy && ( + + No related content found + + ) )} @@ -117,12 +127,18 @@ export const SearchResultsListView = (props: Props) => { const Container = styled(Grid)` padding-top: 56px; + ${({ theme }) => lsdUtils.breakpoint(theme, 'xs', 'exact')} { + padding-top: 32px; + } ` const PostsList = styled(GridItem)` display: flex; flex-direction: column; gap: 56px; + ${({ theme }) => lsdUtils.breakpoint(theme, 'xs', 'exact')} { + gap: 32px; + } ` const PostsListHeader = styled.div`` const PostsListContent = styled.div`` diff --git a/src/pages/search.tsx b/src/pages/search.tsx index 0bfc692..bfde0c4 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -2,11 +2,19 @@ import { SearchBox } from '@/components/SearchBox' import { SearchResultsExploreView } from '@/containers/Search/ExploreView' import { SearchResultsListView } from '@/containers/Search/ListView' import { LPE } from '@/types/lpe.types' -import { useEffect, useState } from 'react' +import { ReactNode, useEffect, useState } from 'react' import SEO from '../components/SEO/SEO' import { DefaultLayout } from '../layouts/DefaultLayout' import { api } from '../services/api.service' import unbodyApi from '../services/unbody/unbody.service' +import { QueryParamProvider } from 'use-query-params' +import NextAdapterPages from 'next-query-params' +import { useQuery } from '@tanstack/react-query' +import { seq } from 'yaml/dist/schema/common/seq' +import { ApiResponse } from '@/types/data.types' +import useWindowSize from '@/utils/ui.utils' +import { uiConfigs } from '@/configs/ui.configs' +import themeState, { useThemeState } from '@/states/themeState/theme.state' interface SearchPageProps { topics: string[] @@ -18,15 +26,8 @@ interface SearchPageProps { export default function SearchPage({ topics, shows }: SearchPageProps) { const [mounted, setMounted] = useState(false) const [busy, setBusy] = useState(false) - const [resultNumber, setResultNumber] = useState(null) const [view, setView] = useState('list') - - const [blocks, setBlocks] = useState< - LPE.Search.ResultItemBase[] - >([]) - const [posts, setPosts] = useState< - LPE.Search.ResultItemBase[] - >([]) + const isMobile = useWindowSize().width < 768 useEffect(() => { setMounted(true) @@ -35,24 +36,38 @@ export default function SearchPage({ topics, shows }: SearchPageProps) { } }, []) + const [query, setQuery] = useState('') + const [tags, setTags] = useState([]) + const [types, setTypes] = useState([]) + + const { data, isLoading } = useQuery(['search', query, tags, types], () => { + return api.search({ + query: query.length > 0 ? query : ' ', + tags, + type: types as LPE.ContentType[], + }) + }) + + const blocks = (data?.data.blocks || + []) as LPE.Search.ResultItemBase[] + const posts = (data?.data.posts || + []) as LPE.Search.ResultItemBase[] const handleSearch = async ( query: string, filteredTags: string[], filteredTypes: LPE.ContentType[], ) => { - setBusy(true) - const { data, errors } = await api.search({ - query, - tags: filteredTags, - type: filteredTypes, - }) - setBusy(false) - setResultNumber(data.posts.length || null) - setPosts(data.posts as LPE.Search.ResultItemBase[]) - setBlocks(data.blocks as LPE.Search.ResultItemBase[]) - console.log(data) + setQuery(query) + setTags(filteredTags) + setTypes(filteredTypes) } + let resultsNumber = + types.includes(LPE.ContentTypes.Article) || + types.includes(LPE.ContentTypes.Podcast) + ? posts.length + : blocks.length + return (
{view === 'list' && ( - + )} {view === 'explore' && ( @@ -78,9 +99,13 @@ export default function SearchPage({ topics, shows }: SearchPageProps) { ) } +SearchPage.getLayout = (page: ReactNode) => ( + + {page} + +) + export async function getStaticProps() { - // const { data: articles = [] } = await unbodyApi.searchArticles() - // const { data: blocks = [] } = await unbodyApi.searchBlocks() const { data: topics, errors: topicErrors } = await unbodyApi.getTopics() const { data: shows = [] } = await unbodyApi.getPodcastShows({ populateEpisodes: true, @@ -89,8 +114,6 @@ export async function getStaticProps() { return { props: { - // articles, - // blocks: shuffle(blocks), topics, shows, }, diff --git a/yarn.lock b/yarn.lock index 6345b0c..5beaff7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4165,6 +4165,13 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +next-query-params@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/next-query-params/-/next-query-params-4.2.3.tgz#f90f1a56de8ac6e6a6adb492a11f8854cced314d" + integrity sha512-hGNCYRH8YyA5ItiBGSKrtMl21b2MAqfPkdI1mvwloNVqSU142IaGzqHN+OTovyeLIpQfonY01y7BAHb/UH4POg== + dependencies: + tslib "^2.0.3" + next@13.3.0: version "13.3.0" resolved "https://registry.yarnpkg.com/next/-/next-13.3.0.tgz#40632d303d74fc8521faa0a5bf4a033a392749b1" @@ -4963,6 +4970,11 @@ sentence-case@^3.0.4: tslib "^2.0.3" upper-case-first "^2.0.2" +serialize-query-params@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/serialize-query-params/-/serialize-query-params-2.0.2.tgz#598a3fb9e13f4ea1c1992fbd20231aa16b31db81" + integrity sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q== + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -5530,6 +5542,13 @@ urlpattern-polyfill@^9.0.0: resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-9.0.0.tgz#bc7e386bb12fd7898b58d1509df21d3c29ab3460" integrity sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g== +use-query-params@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/use-query-params/-/use-query-params-2.2.1.tgz#c558ab70706f319112fbccabf6867b9f904e947d" + integrity sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q== + dependencies: + serialize-query-params "^2.0.2" + use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"