diff --git a/src/components/manga/hooks.ts b/src/components/manga/hooks.ts index 9f16c2dc..eb1288b0 100644 --- a/src/components/manga/hooks.ts +++ b/src/components/manga/hooks.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { mutate } from 'swr'; import requestManager from 'lib/RequestManager'; @@ -34,3 +34,19 @@ export const useRefreshManga = (mangaId: string) => { return [handleRefresh, { loading: fetchingOnline }] as const; }; + +export const useDebounce = (value: Value, delay: number): Value => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx index cb20833e..bfa096c9 100644 --- a/src/screens/SearchAll.tsx +++ b/src/screens/SearchAll.tsx @@ -11,19 +11,19 @@ import NavbarContext from 'components/context/NavbarContext'; import MangaGrid from 'components/MangaGrid'; import LangSelect from 'components/navbar/action/LangSelect'; import AppbarSearch from 'components/util/AppbarSearch'; -import PQueue from 'p-queue'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { StringParam, useQueryParam } from 'use-query-params'; import { langSortCmp, sourceDefualtLangs, sourceForcedDefaultLangs } from 'util/language'; import useLocalStorage from 'util/useLocalStorage'; -import { IManga, ISource, SourceSearchResult } from 'typings'; +import { ISource } from 'typings'; import { useTranslation } from 'react-i18next'; import { translateExtensionLanguage } from 'screens/util/Extensions'; import requestManager from 'lib/RequestManager'; +import { useDebounce } from 'components/manga/hooks'; -type SourceToMangasMap = { [source: string]: IManga[] }; -type SourceToFetchedStateMap = { [source: string]: boolean }; +type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean }; +type SourceToLoadingStateMap = Map; function sourceToLangList(sources: ISource[]) { const result: string[] = []; @@ -51,11 +51,10 @@ const compareSourceByName = (sourceA: ISource, sourceB: ISource): -1 | 0 | 1 => const compareSourcesBySearchResult = ( sourceA: ISource, sourceB: ISource, - sourceToFetchedStateMap: SourceToFetchedStateMap, - sourceToMangasMap: SourceToMangasMap, + sourceToFetchedStateMap: SourceToLoadingStateMap, ): -1 | 0 | 1 => { - const isSourceAFetched = sourceToFetchedStateMap[sourceA.id]; - const isSourceBFetched = sourceToFetchedStateMap[sourceB.id]; + const isSourceAFetched = !sourceToFetchedStateMap.get(sourceA.id)?.isLoading ?? true; + const isSourceBFetched = !sourceToFetchedStateMap.get(sourceB.id)?.isLoading ?? true; if (isSourceAFetched && !isSourceBFetched) { return -1; } @@ -66,94 +65,125 @@ const compareSourcesBySearchResult = ( return 0; } - const isSourceASearchResultEmpty = sourceToMangasMap[sourceA.id].length === 0; - const isSourceBSearchResultEmpty = sourceToMangasMap[sourceB.id].length === 0; + const isSourceASearchResultEmpty = !sourceToFetchedStateMap.get(sourceA.id)?.hasResults; + const isSourceBSearchResultEmpty = !sourceToFetchedStateMap.get(sourceB.id)?.hasResults; if (isSourceASearchResultEmpty && !isSourceBSearchResultEmpty) { return 1; } if (isSourceBSearchResultEmpty && !isSourceASearchResultEmpty) { return -1; } + return 0; }; +const TRIGGER_SEARCH_THRESHOLD = 1000; // ms + +const SourceSearchPreview = ({ + source, + onSearchRequestFinished, +}: { + source: ISource; + onSearchRequestFinished: (source: ISource, isLoading: boolean, hasResults: boolean, emptySearch: boolean) => void; +}) => { + const { t } = useTranslation(); + const [query] = useQueryParam('query', StringParam); + const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD); + const skipRequest = !searchString; + + const { id, displayName, lang } = source; + const { + data: searchResult, + size, + setSize, + isLoading, + } = requestManager.useSourceSearch(id, searchString ?? '', 1, { skipRequest }); + const mangas = !isLoading ? searchResult?.[0]?.mangaList ?? [] : []; + const noMangasFound = !isLoading && !mangas.length; + + useEffect(() => { + onSearchRequestFinished(source, isLoading, !noMangasFound, !searchString); + }, [isLoading, noMangasFound, searchString]); + + if (!isLoading && !searchString) { + return null; + } + + return ( + <> + + + {displayName} + {translateExtensionLanguage(lang)} + + + + + ); +}; const SearchAll: React.FC = () => { const { t } = useTranslation(); - const [query] = useQueryParam('query', StringParam); const { setTitle, setAction } = useContext(NavbarContext); - const [triggerUpdate, setTriggerUpdate] = useState(2); - const [sourceToMangasMap, setSourceToMangasMap] = useState({}); const [shownLangs, setShownLangs] = useLocalStorage('shownSourceLangs', sourceDefualtLangs()); const [showNsfw] = useLocalStorage('showNsfw', true); - const { data: sources = [], isLoading: isLoadingSources } = requestManager.useGetSourceList(); - const sortedSources = useMemo(() => [...sources].sort(compareSourceByName), [sources]); + const { data: sources = [] } = requestManager.useGetSourceList(); + const [sourceToLoadingStateMap, setSourceToLoadingStateMap] = useState(new Map()); - const [sourceToFetchedStateMap, setSourceToFetchedStateMap] = useState({}); + const sourcesSortedByName = useMemo(() => [...sources].sort(compareSourceByName), [sources]); + const sourcesFilteredByLang = useMemo( + () => sourcesSortedByName.filter((source) => shownLangs.includes(source.lang)), + [sourcesSortedByName, shownLangs], + ); + const sourcesFilteredByNsfw = useMemo( + () => sourcesFilteredByLang.filter((source) => showNsfw || !source.isNsfw), + [sourcesFilteredByLang, showNsfw], + ); + const sourcesSortedByResult = useMemo( + () => + [...sourcesFilteredByNsfw].sort((sourceA, sourceB) => + compareSourcesBySearchResult(sourceA, sourceB, sourceToLoadingStateMap), + ), + [sourcesFilteredByNsfw, sourceToLoadingStateMap], + ); - const [lastPageNum, setLastPageNum] = useState(1); - - const [resetUI, setResetUI] = useState(0); - - const searchRequestsQueue = new PQueue({ concurrency: 5 }); + const updateSourceLoadingState = useCallback( + ({ id }: ISource, isLoading: boolean, hasResults: boolean, emptySearch: boolean) => { + setSourceToLoadingStateMap((currentMap) => { + const mapCopy = new Map(currentMap); + mapCopy.set(id, { isLoading, hasResults, emptySearch }); + return mapCopy; + }); + }, + [sourceToLoadingStateMap, setSourceToLoadingStateMap], + ); useEffect(() => { setTitle(t('search.title.global_search')); - setAction(); - }, [t]); - - async function performSearch(sourcesToSearchIn: ISource[]) { - sourcesToSearchIn.map((source) => - searchRequestsQueue.add(async () => { - const response = await requestManager - .getClient() - .get(`/api/v1/source/${source.id}/search?searchTerm=${query || ''}&pageNum=1`); - const searchResult = await response.data; - const tmpMangas = sourceToMangasMap; - tmpMangas[source.id] = searchResult.mangaList; - setSourceToMangasMap(tmpMangas); - const tmpFetched = sourceToFetchedStateMap; - tmpFetched[source.id] = true; - setSourceToFetchedStateMap(tmpFetched); - setResetUI(1); - }), + setAction( + <> + + + , ); - } - - useEffect(() => { - if (triggerUpdate === 2) { - return; - } - if (triggerUpdate === 0) { - setTriggerUpdate(1); - return; - } - setSourceToFetchedStateMap({}); - setSourceToMangasMap({}); - performSearch( - sortedSources - .filter(({ lang }) => shownLangs.indexOf(lang) !== -1) - .filter((source) => showNsfw || !source.isNsfw), - ); - }, [triggerUpdate]); - - useEffect(() => { - if (resetUI === 1) { - setResetUI(0); - } - }, [resetUI]); - - useEffect(() => { - if (query && !isLoadingSources) { - const delayDebounceFn = setTimeout(() => { - setTriggerUpdate(0); - }, 1000); - return () => clearTimeout(delayDebounceFn); - } - return () => {}; - }, [query, shownLangs, sortedSources]); + }, [t, shownLangs, setShownLangs, sources]); useEffect(() => { // make sure all of forcedDefaultLangs() exists in shownLangs @@ -163,62 +193,17 @@ const SearchAll: React.FC = () => { setShownLangs([...shownLangs, ...missingDefaultLangs]); }, []); - useEffect(() => { - setTitle(t('source.title')); - setAction( - <> - - + {sourcesSortedByResult.map((source) => ( + - , - ); - }, [t, shownLangs, sortedSources]); - - if (query) { - return ( - <> - {sortedSources - .filter(({ lang }) => shownLangs.indexOf(lang) !== -1) - .filter((source) => showNsfw || !source.isNsfw) - .sort((sourceA, sourceB) => - compareSourcesBySearchResult(sourceA, sourceB, sourceToFetchedStateMap, sourceToMangasMap), - ) - .map(({ lang, id, displayName }) => ( - <> - - - {displayName} - {translateExtensionLanguage(lang)} - - - - - ))} - - ); - } - - return null; + ))} + + ); }; export default SearchAll;