/* * Copyright (C) Contributors to the Suwayomi project * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import Card from '@mui/material/Card'; import CardActionArea from '@mui/material/CardActionArea'; import Typography from '@mui/material/Typography'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { StringParam, useQueryParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; import Button from '@mui/material/Button'; import PushPinIcon from '@mui/icons-material/PushPin'; import DoneAllIcon from '@mui/icons-material/DoneAll'; import FilterListIcon from '@mui/icons-material/FilterList'; import { useElementSize } from '@mantine/hooks'; import IconButton from '@mui/material/IconButton'; // ms import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { AppbarSearch } from '@/features/core/components/AppbarSearch.tsx'; import { useDebounce } from '@/features/core/hooks/useDebounce.ts'; import { MangaCardProps } from '@/features/manga/Manga.types.ts'; import { EmptyView } from '@/features/core/components/feedback/EmptyView.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { LoadingPlaceholder } from '@/features/core/components/feedback/LoadingPlaceholder.tsx'; import { BaseMangaGrid } from '@/features/manga/components/BaseMangaGrid.tsx'; import { EmptyViewAbsoluteCentered } from '@/features/core/components/feedback/EmptyViewAbsoluteCentered.tsx'; import { translateExtensionLanguage } from '@/features/extension/Extensions.utils.ts'; import { AppRoutes } from '@/features/core/AppRoute.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { Sources } from '@/features/source/services/Sources.ts'; import { SourceDisplayNameInfo, SourceIdInfo, SourceLanguageInfo, SourceMetaInfo, SourceNameInfo, } from '@/features/source/Source.types.ts'; import { createUpdateMetadataServerSettings, useMetadataServerSettings, } from '@/features/settings/services/ServerSettingsMetadata.ts'; import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts'; import { getSourceMetadata } from '@/features/source/services/SourceMetadata.ts'; import { makeToast } from '@/features/core/utils/Toast.ts'; import { CustomTooltip } from '@/features/core/components/CustomTooltip.tsx'; import { MUIUtil } from '@/lib/mui/MUI.util.ts'; import { MetadataBrowseSettings } from '@/features/browse/Browse.types.ts'; import { SourceLanguageSelect } from '@/features/source/components/SourceLanguageSelect.tsx'; import { SearchParam } from '@/features/core/Core.types.ts'; type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean; error: any }; type SourceToLoadingStateMap = Map; const compareSourceByName = (sourceA: SourceDisplayNameInfo, sourceB: SourceDisplayNameInfo): number => sourceA.displayName.localeCompare(sourceB.displayName); const compareSourcesBySearchResult = ( sourceA: SourceIdInfo & SourceMetaInfo, sourceB: SourceIdInfo & SourceMetaInfo, sourceToFetchedStateMap: SourceToLoadingStateMap, ): -1 | 0 | 1 => { const isSourceAPinned = getSourceMetadata(sourceA).isPinned; const isSourceBPinned = getSourceMetadata(sourceB).isPinned; const sourceAState = sourceToFetchedStateMap.get(sourceA.id); const sourceBState = sourceToFetchedStateMap.get(sourceB.id); const isSourceAFetched = !sourceAState?.isLoading; const hasSourceAError = !!sourceAState?.error; const isSourceASearchResultEmpty = !sourceAState?.hasResults && !hasSourceAError; const isSourceBFetched = !sourceBState?.isLoading; const hasSourceBError = !!sourceBState?.error; const isSourceBSearchResultEmpty = !sourceBState?.hasResults && !hasSourceBError; if (isSourceAFetched && !isSourceBFetched) { return -1; } if (!isSourceAFetched && isSourceBFetched) { return 1; } if (isSourceASearchResultEmpty && !isSourceBSearchResultEmpty) { return 1; } if (isSourceBSearchResultEmpty && !isSourceASearchResultEmpty) { return -1; } if (!hasSourceAError && hasSourceBError) { return -1; } if (hasSourceAError && !hasSourceBError) { return 1; } if (isSourceAPinned && !isSourceBPinned) { return -1; } if (!isSourceAPinned && isSourceBPinned) { return 1; } return 0; }; const TRIGGER_SEARCH_THRESHOLD = 1000; // ms const SourceSearchPreview = React.memo( ({ source, onSearchRequestFinished, searchString, emptyQuery, mode, shouldShowOnlySourcesWithResults, }: { source: SourceIdInfo & SourceDisplayNameInfo & SourceNameInfo & SourceLanguageInfo; onSearchRequestFinished: (source: SourceIdInfo, state: SourceLoadingState) => void; searchString: string | null | undefined; emptyQuery: boolean; } & Pick & Pick) => { const { t } = useTranslation(); const { id, name, lang } = source; const currentSearchString = useRef(searchString); const currentAbortRequest = useRef<(reason: any) => void>(() => {}); const didSearchChange = currentSearchString.current !== searchString; if (didSearchChange) { currentSearchString.current = searchString; currentAbortRequest.current(new Error(`SourceSearchPreview(${id}, ${name}): search string changed`)); } const [refetch, results] = requestManager.useSourceSearch(id, searchString ?? '', undefined, 1, { skipRequest: !searchString, addAbortSignal: true, }); const { data: searchResult, isLoading, error, abortRequest } = results[0]!; currentAbortRequest.current = abortRequest; const mangas = searchResult?.fetchSourceManga?.mangas ?? []; const noMangasFound = !error && !isLoading && !mangas.length; useEffect(() => { onSearchRequestFinished(source, { isLoading, hasResults: !noMangasFound, emptySearch: !searchString, error, }); }, [isLoading, noMangasFound, searchString, error]); let errorMessage: string | undefined; if (error) { errorMessage = t('search.error.label.source_search_failed'); } else if (noMangasFound) { errorMessage = t('manga.error.label.no_mangas_found'); } if ((!isLoading && !searchString) || emptyQuery) { return null; } if (shouldShowOnlySourcesWithResults && (noMangasFound || error)) { return null; } return ( {name} {translateExtensionLanguage(lang)} {errorMessage ? ( refetch(1).catch( defaultPromiseErrorHandler(`SourceSearchPreview(${source.id})::refetch`), ) : undefined } /> ) : ( undefined} horizontal noFaces message={errorMessage} inLibraryIndicator mode={mode} /> )} ); }, ); export const SearchAll: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const { pathname, state } = useLocation<{ mangaTitle?: string; shouldShowOnlyPinnedSources?: boolean }>(); const { ref: filterHeaderRef, height: filterHeaderHeight } = useElementSize(); const shouldShowOnlyPinnedSources = state?.shouldShowOnlyPinnedSources ?? true; const isMigrateMode = pathname.startsWith('/migrate/source'); const [query] = useQueryParam(SearchParam.QUERY, StringParam); const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD); const { languages: shownLangs, setLanguages: setShownLangs } = Sources.useLanguages(); const { settings: { showNsfw, shouldShowOnlySourcesWithResults }, } = useMetadataServerSettings(); const { data, loading, error, refetch } = requestManager.useGetSourceList({ notifyOnNetworkStatusChange: true }); const sources = useMemo(() => data?.sources.nodes ?? [], [data?.sources.nodes]); const [sourceToLoadingStateMap, setSourceToLoadingStateMap] = useState(new Map()); const debouncedSourceToLoadingStateMap = useDebounce(sourceToLoadingStateMap, 500); const sourceLanguages = useMemo(() => Sources.getLanguages(sources), [sources]); const filteredSources = useMemo( () => Sources.filter(sources, { showNsfw, languages: shownLangs, keepLocalSource: true, pinned: shouldShowOnlyPinnedSources, enabled: true, }), [sources, shownLangs, shouldShowOnlyPinnedSources], ); const sourcesSortedByName = useMemo(() => [...filteredSources].toSorted(compareSourceByName), [filteredSources]); const sourcesSortedByResult = useMemo( () => [...sourcesSortedByName].sort((sourceA, sourceB) => compareSourcesBySearchResult(sourceA, sourceB, debouncedSourceToLoadingStateMap), ), [sourcesSortedByName, debouncedSourceToLoadingStateMap], ); const updateSourceLoadingState = useCallback( ({ id }: SourceIdInfo, loadState: SourceLoadingState) => { setSourceToLoadingStateMap((currentMap) => { const mapCopy = new Map(currentMap); mapCopy.set(id, loadState); return mapCopy; }); }, [setSourceToLoadingStateMap], ); const updateMetadataSettings = createUpdateMetadataServerSettings<'shouldShowOnlySourcesWithResults'>((e) => makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)), ); useAppTitleAndAction( t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: state?.mangaTitle }), <> , [shownLangs, setShownLangs, sourceLanguages, sources], ); if (loading) { return ; } if (error) { return ( refetch().catch(defaultPromiseErrorHandler('SearchAll::refetch'))} /> ); } return ( theme.palette.background.default, }} > {sourcesSortedByResult.map((source) => ( ))} ); };