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' ;
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 ,
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 ;
2024-01-26 20:50:51 +01:00
} & Pick < MangaCardProps , 'mode' > ) = > {
2023-05-23 13:35:09 +02:00
const { t } = useTranslation ( ) ;
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-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-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 ;
}
2023-05-20 13:25:10 +02:00
2023-05-23 13:35:09 +02:00
return (
< >
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 }
/ >
) }
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-20 13:23:30 +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 ] ,
) ;
const filteredSourcesByHasResult = useMemo ( ( ) = > {
if ( ! shouldShowOnlySourcesWithResults ) {
return filteredSources ;
}
return filteredSources . filter ( ( source ) = > {
const sourceState = debouncedSourceToLoadingStateMap . get ( source . id ) ;
return ! sourceState || sourceState ? . isLoading || ( sourceState ? . hasResults && ! sourceState ? . error ) ;
} ) ;
} , [ filteredSources , shouldShowOnlySourcesWithResults , debouncedSourceToLoadingStateMap ] ) ;
const sourcesSortedByName = useMemo (
( ) = > [ . . . filteredSourcesByHasResult ] . toSorted ( compareSourceByName ) ,
[ filteredSourcesByHasResult ] ,
) ;
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
) ;
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 ` } } >
{ sourcesSortedByResult . map ( ( source , index ) = > (
< Box key = { source . id } sx = { { pb : index + 1 !== sourcesSortedByResult . length ? 2 : 0 } } >
< SourceSearchPreview
source = { source }
onSearchRequestFinished = { updateSourceLoadingState }
searchString = { searchString }
emptyQuery = { ! query }
mode = { isMigrateMode ? 'migrate.select' : 'source' }
/ >
< / Box >
) ) }
< / 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
} ;