2022-03-12 04:46:55 +00:00
/*
2023-02-06 10:06:33 +01:00
* 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/.
*/
2022-03-12 04:46:55 +00:00
2024-04-14 15:50:12 +02:00
import Card from '@mui/material/Card' ;
import CardActionArea from '@mui/material/CardActionArea' ;
import Typography from '@mui/material/Typography' ;
2025-05-03 13:24:55 +02:00
import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
2024-01-26 20:50:51 +01:00
import { Link , useLocation } from 'react-router-dom' ;
2022-11-26 13:51:56 +01:00
import { StringParam , useQueryParam } from 'use-query-params' ;
2023-03-23 13:59:32 +01:00
import { useTranslation } from 'react-i18next' ;
2024-09-14 03:01:07 +02:00
import Box from '@mui/material/Box' ;
2025-05-18 00:59:01 +02:00
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' ;
2025-05-21 02:27:56 +02:00
import IconButton from '@mui/material/IconButton' ; // ms
import ArrowForwardIcon from '@mui/icons-material/ArrowForward' ;
2024-10-06 01:27:51 +02:00
import { requestManager } from '@/lib/requests/RequestManager.ts' ;
2025-05-18 00:59:01 +02:00
import { useLocalStorage , useSessionStorage } from '@/modules/core/hooks/useStorage.tsx' ;
2025-05-09 00:44:48 +02:00
import { getDefaultLanguages } from '@/modules/core/utils/Languages.ts' ;
2024-10-05 16:09:34 +02:00
import { AppbarSearch } from '@/modules/core/components/AppbarSearch.tsx' ;
2025-04-21 18:18:13 +02:00
import { LanguageSelect } from '@/modules/core/components/inputs/LanguageSelect.tsx' ;
2024-10-05 16:09:34 +02:00
import { useDebounce } from '@/modules/core/hooks/useDebounce.ts' ;
2024-10-07 13:31:23 +02:00
import { MangaCardProps } from '@/modules/manga/Manga.types.ts' ;
2025-05-03 12:14:05 +02:00
import { EmptyView } from '@/modules/core/components/feedback/EmptyView.tsx' ;
2024-10-05 16:09:34 +02:00
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts' ;
2025-05-03 12:14:05 +02:00
import { LoadingPlaceholder } from '@/modules/core/components/feedback/LoadingPlaceholder.tsx' ;
2024-10-05 21:22:12 +02:00
import { BaseMangaGrid } from '@/modules/manga/components/BaseMangaGrid.tsx' ;
2025-05-03 12:14:05 +02:00
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx' ;
2024-11-04 01:21:50 +01:00
import { translateExtensionLanguage } from '@/modules/extension/Extensions.utils.ts' ;
2024-12-11 20:10:59 +01:00
import { AppRoutes } from '@/modules/core/AppRoute.constants.ts' ;
2024-12-21 23:27:03 +01:00
import { getErrorMessage } from '@/lib/HelperFunctions.ts' ;
2025-04-21 17:00:13 +02:00
import { Sources } from '@/modules/source/services/Sources.ts' ;
2025-05-21 02:27:56 +02:00
import {
SourceDisplayNameInfo ,
SourceIdInfo ,
SourceLanguageInfo ,
SourceMetaInfo ,
SourceNameInfo ,
} from '@/modules/source/Source.types.ts' ;
2025-05-18 00:59:01 +02:00
import {
createUpdateMetadataServerSettings ,
useMetadataServerSettings ,
} from '@/modules/settings/services/ServerSettingsMetadata.ts' ;
2025-05-03 13:37:20 +02:00
import { useAppTitleAndAction } from '@/modules/navigation-bar/hooks/useAppTitleAndAction.ts' ;
2025-05-17 23:49:44 +02:00
import { getSourceMetadata } from '@/modules/source/services/SourceMetadata.ts' ;
2025-05-18 00:59:01 +02:00
import { makeToast } from '@/modules/core/utils/Toast.ts' ;
2025-05-21 02:27:56 +02:00
import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx' ;
import { MUIUtil } from '@/lib/mui/MUI.util.ts' ;
2025-05-23 00:27:57 +02:00
import { MetadataBrowseSettings } from '@/modules/browse/Browse.types.ts' ;
2022-03-12 04:46:55 +00:00
2025-04-22 21:30:28 +02:00
type SourceLoadingState = { isLoading : boolean ; hasResults : boolean ; emptySearch : boolean ; error : any };
2023-05-20 13:23:30 +02:00
type SourceToLoadingStateMap = Map < string , SourceLoadingState >;
2023-05-20 13:19:03 +02:00
2025-04-21 18:04:03 +02:00
const compareSourceByName = ( sourceA : SourceDisplayNameInfo , sourceB : SourceDisplayNameInfo ) : number =>
sourceA . displayName . localeCompare ( sourceB . displayName );
2023-05-20 13:19:03 +02:00
const compareSourcesBySearchResult = (
2025-05-17 23:49:44 +02:00
sourceA : SourceIdInfo & SourceMetaInfo ,
sourceB : SourceIdInfo & SourceMetaInfo ,
2023-05-20 13:23:30 +02:00
sourceToFetchedStateMap : SourceToLoadingStateMap ,
2023-05-20 13:19:03 +02:00
) : - 1 | 0 | 1 => {
2025-05-17 23:49:44 +02:00
const isSourceAPinned = getSourceMetadata ( sourceA ). isPinned ;
const isSourceBPinned = getSourceMetadata ( sourceB ). isPinned ;
2025-04-22 21:30:28 +02:00
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 ;
2023-05-20 13:19:03 +02:00
if ( isSourceAFetched && ! isSourceBFetched ) {
return - 1 ;
}
if ( ! isSourceAFetched && isSourceBFetched ) {
return 1 ;
}
if ( isSourceASearchResultEmpty && ! isSourceBSearchResultEmpty ) {
return 1 ;
}
if ( isSourceBSearchResultEmpty && ! isSourceASearchResultEmpty ) {
return - 1 ;
}
2023-05-20 13:23:30 +02:00
2025-04-22 21:30:28 +02:00
if ( ! hasSourceAError && hasSourceBError ) {
return - 1 ;
}
if ( hasSourceAError && ! hasSourceBError ) {
return 1 ;
}
2025-05-17 23:49:44 +02:00
if ( isSourceAPinned && ! isSourceBPinned ) {
return - 1 ;
}
if ( ! isSourceAPinned && isSourceBPinned ) {
return 1 ;
}
2023-05-20 13:19:03 +02:00
return 0 ;
};
2023-05-20 13:23:30 +02:00
const TRIGGER_SEARCH_THRESHOLD = 1000 ; // ms
2023-05-23 13:35:09 +02:00
const SourceSearchPreview = React . memo (
({
source ,
onSearchRequestFinished ,
searchString ,
emptyQuery ,
2024-01-26 20:50:51 +01:00
mode ,
2025-05-23 00:27:57 +02:00
shouldShowOnlySourcesWithResults ,
2023-05-23 13:35:09 +02:00
} : {
2025-05-21 02:27:56 +02:00
source : SourceIdInfo & SourceDisplayNameInfo & SourceNameInfo & SourceLanguageInfo ;
2025-04-22 21:30:28 +02:00
onSearchRequestFinished : ( source : SourceIdInfo , state : SourceLoadingState ) => void ;
2023-05-23 13:35:09 +02:00
searchString : string | null | undefined ;
emptyQuery : boolean ;
2025-05-23 00:27:57 +02:00
} & Pick < MangaCardProps , 'mode' > &
Pick < MetadataBrowseSettings , 'shouldShowOnlySourcesWithResults' >) => {
2023-05-23 13:35:09 +02:00
const { t } = useTranslation ();
2023-05-20 13:23:30 +02:00
2025-05-21 02:27:56 +02:00
const { id , name , lang } = source ;
2024-10-07 13:07:57 +02:00
const currentSearchString = useRef ( searchString );
const currentAbortRequest = useRef < ( reason : any ) => void > (() => {});
const didSearchChange = currentSearchString . current !== searchString ;
if ( didSearchChange ) {
currentSearchString . current = searchString ;
2025-05-21 02:27:56 +02:00
currentAbortRequest . current ( new Error ( `SourceSearchPreview( ${ id } , ${ name } ): search string changed` ));
2024-10-07 13:07:57 +02:00
}
2024-04-29 14:26:33 +02:00
const [ refetch , results ] = requestManager . useSourceSearch ( id , searchString ?? '' , undefined , 1 , {
2023-09-12 02:36:11 +02:00
skipRequest : ! searchString ,
2023-11-11 23:42:58 +01:00
addAbortSignal : true ,
2023-09-12 02:36:11 +02:00
});
2024-10-07 13:07:57 +02:00
2023-09-12 02:36:11 +02:00
const { data : searchResult , isLoading , error , abortRequest } = results [ 0 ] ! ;
2024-10-07 13:07:57 +02:00
currentAbortRequest . current = abortRequest ;
2024-06-02 15:24:29 +02:00
const mangas = searchResult ? . fetchSourceManga ? . mangas ?? [];
2024-10-06 16:15:01 +02:00
const noMangasFound = ! error && ! isLoading && ! mangas . length ;
2023-05-20 13:23:30 +02:00
2023-05-23 13:35:09 +02:00
useEffect (() => {
2025-04-22 21:30:28 +02:00
onSearchRequestFinished ( source , {
isLoading ,
hasResults : ! noMangasFound ,
emptySearch : ! searchString ,
error ,
});
}, [ isLoading , noMangasFound , searchString , error ]);
2023-05-20 13:23:30 +02:00
2023-05-23 13:35:09 +02:00
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' );
}
2023-05-20 13:23:30 +02:00
2023-05-23 13:35:09 +02:00
if (( ! isLoading && ! searchString ) || emptyQuery ) {
return null ;
}
2025-05-23 00:27:57 +02:00
if ( shouldShowOnlySourcesWithResults && ( noMangasFound || error )) {
return null ;
}
2023-05-23 13:35:09 +02:00
return (
2025-05-23 00:27:57 +02:00
< Box sx = {{ pb : 2 }}>
2024-09-14 03:01:07 +02:00
< Card sx = {{ mb : 1 }}>
2024-12-11 20:10:59 +01:00
< CardActionArea
component = { Link }
2025-05-21 01:55:32 +02:00
to = { AppRoutes . sources . childRoutes . browse . path ( id , searchString )}
2025-05-21 02:27:56 +02:00
sx = {{ p : 1 , display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' }}
2024-12-11 20:10:59 +01:00
>
2025-05-21 02:27:56 +02:00
< Box >
< Typography variant = "h5" >{ name }</ Typography >
< Typography variant = "caption" >{ translateExtensionLanguage ( lang )}</ Typography >
</ Box >
< CustomTooltip title = { t ( 'global.button.show_more' )}>
< IconButton { ...MUIUtil.preventRippleProp () }>
< ArrowForwardIcon />
</ IconButton >
</ CustomTooltip >
2023-05-23 13:35:09 +02:00
</ CardActionArea >
</ Card >
2024-04-29 15:34:12 +02:00
{ errorMessage ? (
2024-04-29 14:26:33 +02:00
< EmptyView
2024-07-11 17:22:52 +02:00
sx = {{ alignItems : 'start' , height : undefined }}
2024-04-29 14:26:33 +02:00
noFaces
message = { errorMessage }
2024-12-21 23:27:03 +01:00
messageExtra = { getErrorMessage ( error )}
2024-04-29 14:26:33 +02:00
retry = {
error
? () =>
refetch ( 1 ). catch (
defaultPromiseErrorHandler ( `SourceSearchPreview( ${ source . id } )::refetch` ),
)
: undefined
}
/>
2024-04-29 15:34:12 +02:00
) : (
2024-07-08 18:02:50 +02:00
< BaseMangaGrid
2025-05-17 19:53:56 +02:00
// the key needs to include filters and query to force a re-render of the virtuoso grid to prevent https://github.com/petyosi/react-virtuoso/issues/1242
key = { searchString }
2024-09-14 03:01:07 +02:00
gridWrapperProps = {{ sx : { px : 0 } }}
2024-04-29 15:34:12 +02:00
mangas = { mangas }
isLoading = { isLoading }
hasNextPage = { false }
loadMore = {() => undefined }
horizontal
noFaces
message = { errorMessage }
inLibraryIndicator
mode = { mode }
/>
)}
2025-05-23 00:27:57 +02:00
</ Box >
2023-05-23 13:35:09 +02:00
);
},
);
2023-05-20 13:19:03 +02:00
2023-10-28 00:32:02 +02:00
export const SearchAll : React.FC = () => {
2023-03-23 13:59:32 +01:00
const { t } = useTranslation ();
2024-01-26 20:50:51 +01:00
const { pathname , state } = useLocation < { mangaTitle? : string } > ();
2025-05-18 00:59:01 +02:00
const { ref : filterHeaderRef , height : filterHeaderHeight } = useElementSize ();
2024-01-26 20:50:51 +01:00
const isMigrateMode = pathname . startsWith ( '/migrate/source' );
2023-05-23 13:35:09 +02:00
const [ query ] = useQueryParam ( 'query' , StringParam );
const searchString = useDebounce ( query , TRIGGER_SEARCH_THRESHOLD );
2025-05-09 00:44:48 +02:00
const [ shownLangs , setShownLangs ] = useLocalStorage < string [] >( 'shownSourceLangs' , getDefaultLanguages ());
2025-05-18 00:59:01 +02:00
const [ shouldShowOnlyPinnedSources , setShouldShowOnlyPinnedSources ] = useSessionStorage (
'SearchAll::shouldShowOnlyPinnedSources' ,
true ,
);
2025-04-21 19:18:42 +02:00
const {
2025-05-18 00:59:01 +02:00
settings : { showNsfw , shouldShowOnlySourcesWithResults },
2025-04-21 19:18:42 +02:00
} = useMetadataServerSettings ();
2022-03-12 04:46:55 +00:00
2024-05-05 01:17:18 +02:00
const { data , loading , error , refetch } = requestManager . useGetSourceList ({ notifyOnNetworkStatusChange : true });
2024-10-06 13:42:11 +02:00
const sources = useMemo (() => data ? . sources . nodes ?? [], [ data ? . sources . nodes ]);
2025-04-21 18:04:03 +02:00
2023-05-20 13:23:30 +02:00
const [ sourceToLoadingStateMap , setSourceToLoadingStateMap ] = useState < SourceToLoadingStateMap >( new Map ());
2023-05-23 13:35:09 +02:00
const debouncedSourceToLoadingStateMap = useDebounce ( sourceToLoadingStateMap , 500 );
2023-05-18 13:25:19 +02:00
2025-04-21 18:04:03 +02:00
const sourceLanguages = useMemo (() => Sources . getLanguages ( sources ), [ sources ]);
2025-05-18 00:59:01 +02:00
const filteredSources = useMemo (
() =>
Sources . filter ( sources , {
showNsfw ,
languages : shownLangs ,
keepLocalSource : true ,
pinned : shouldShowOnlyPinnedSources ,
}),
[ sources , shownLangs , shouldShowOnlyPinnedSources ],
);
2025-05-23 00:27:57 +02:00
const sourcesSortedByName = useMemo (() => [... filteredSources ]. toSorted ( compareSourceByName ), [ filteredSources ]);
2023-05-20 13:23:30 +02:00
const sourcesSortedByResult = useMemo (
() =>
2025-04-21 18:04:03 +02:00
[... sourcesSortedByName ]. sort (( sourceA , sourceB ) =>
2023-05-23 13:35:09 +02:00
compareSourcesBySearchResult ( sourceA , sourceB , debouncedSourceToLoadingStateMap ),
2023-05-20 13:23:30 +02:00
),
2025-04-21 18:04:03 +02:00
[ sourcesSortedByName , debouncedSourceToLoadingStateMap ],
2023-05-20 13:23:30 +02:00
);
2022-03-12 04:46:55 +00:00
2023-05-20 13:23:30 +02:00
const updateSourceLoadingState = useCallback (
2025-04-22 21:30:28 +02:00
({ id } : SourceIdInfo , loadState : SourceLoadingState ) => {
2023-05-20 13:23:30 +02:00
setSourceToLoadingStateMap (( currentMap ) => {
const mapCopy = new Map ( currentMap );
2025-04-22 21:30:28 +02:00
mapCopy . set ( id , loadState );
2023-05-20 13:23:30 +02:00
return mapCopy ;
});
},
2023-05-23 13:35:09 +02:00
[ setSourceToLoadingStateMap ],
2023-05-20 13:23:30 +02:00
);
2022-03-12 04:46:55 +00:00
2025-05-18 00:59:01 +02:00
const updateMetadataSettings = createUpdateMetadataServerSettings < 'shouldShowOnlySourcesWithResults' > (( e ) =>
makeToast ( t ( 'global.error.label.failed_to_save_changes' ), 'error' , getErrorMessage ( e )),
);
2025-05-03 13:37:20 +02:00
useAppTitleAndAction (
t ( isMigrateMode ? 'migrate.search.title' : 'search.title.global_search' , { title : state?.mangaTitle }),
<>
< AppbarSearch isClosable = { false } />
< LanguageSelect
selectedLanguages = { shownLangs }
setSelectedLanguages = { setShownLangs }
languages = { sourceLanguages }
/>
</>,
[ shownLangs , setShownLangs , sourceLanguages ],
2025-05-03 13:24:55 +02:00
);
2022-03-12 04:46:55 +00:00
2024-05-05 01:17:18 +02:00
if ( loading ) {
return < LoadingPlaceholder />;
}
if ( error ) {
return (
< EmptyViewAbsoluteCentered
message = { t ( 'global.error.label.failed_to_load_data' )}
2024-12-21 23:27:03 +01:00
messageExtra = { getErrorMessage ( error )}
2024-05-05 01:17:18 +02:00
retry = {() => refetch (). catch ( defaultPromiseErrorHandler ( 'SearchAll::refetch' ))}
/>
);
}
2023-05-20 13:23:30 +02:00
return (
2025-05-18 00:59:01 +02:00
< Box sx = {{ position : 'relative' , px : 1 , pb : 1 }}>
< Stack
ref = { filterHeaderRef }
sx = {{
width : '100%' ,
position : 'fixed' ,
zIndex : 1 ,
flexDirection : 'row' ,
gap : 2 ,
pt : 1 ,
pb : 2 ,
background : ( theme ) => theme . palette . background . default ,
}}
>
< Stack sx = {{ flexDirection : 'row' , gap : 1 }}>
< Button
startIcon = {< PushPinIcon />}
variant = { shouldShowOnlyPinnedSources ? 'contained' : 'outlined' }
onClick = {() => setShouldShowOnlyPinnedSources ( true )}
>
{ t ( 'global.label.pinned' )}
</ Button >
< Button
startIcon = {< DoneAllIcon />}
variant = { ! shouldShowOnlyPinnedSources ? 'contained' : 'outlined' }
onClick = {() => setShouldShowOnlyPinnedSources ( false )}
>
{ t ( 'extension.language.all' )}
</ Button >
</ Stack >
< Button
startIcon = {< FilterListIcon />}
variant = { shouldShowOnlySourcesWithResults ? 'contained' : 'outlined' }
onClick = {() =>
updateMetadataSettings ( 'shouldShowOnlySourcesWithResults' , ! shouldShowOnlySourcesWithResults )
}
>
{ t ( 'search.filter.has_results' )}
</ Button >
</ Stack >
< Box sx = {{ pt : ` ${ filterHeaderHeight } px` }}>
2025-05-23 00:27:57 +02:00
{ sourcesSortedByResult . map (( source ) => (
< SourceSearchPreview
key = { source . id }
source = { source }
onSearchRequestFinished = { updateSourceLoadingState }
searchString = { searchString }
emptyQuery = { ! query }
mode = { isMigrateMode ? 'migrate.select' : 'source' }
shouldShowOnlySourcesWithResults = { shouldShowOnlySourcesWithResults }
/>
2025-05-18 00:59:01 +02:00
))}
</ Box >
2024-09-14 03:01:07 +02:00
</ Box >
2023-05-20 13:23:30 +02:00
);
2022-11-26 13:51:56 +01:00
};