2021-03-26 04:17:02 +04:30
/ *
* Copyright ( C ) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
2021-01-26 23:32:12 +03:30
* License , v . 2.0 . If a copy of the MPL was not distributed with this
2023-05-18 13:17:41 +02:00
* file , You can obtain one at https : //mozilla.org/MPL/2.0/.
* /
2021-01-26 23:32:12 +03:30
2023-08-14 21:43:06 +02:00
import { useCallback , useContext , useEffect , useMemo , useState } from 'react' ;
2023-06-04 21:30:15 +02:00
import { useParams , useNavigate , useLocation } from 'react-router-dom' ;
2021-09-09 22:57:19 +04:30
import IconButton from '@mui/material/IconButton' ;
import SettingsIcon from '@mui/icons-material/Settings' ;
2022-03-04 01:25:10 +00:00
import { useQueryParam , StringParam } from 'use-query-params' ;
2023-03-23 13:59:32 +01:00
import { useTranslation } from 'react-i18next' ;
2023-05-12 11:52:00 +02:00
import Link from '@mui/material/Link' ;
2023-06-17 16:50:17 +02:00
import { Box , Button , styled , useTheme , useMediaQuery } from '@mui/material' ;
2023-05-28 12:16:06 +02:00
import FavoriteIcon from '@mui/icons-material/Favorite' ;
import NewReleasesIcon from '@mui/icons-material/NewReleases' ;
import FilterListIcon from '@mui/icons-material/FilterList' ;
2023-06-05 01:42:39 +02:00
import { IManga , PaginatedMangaList , TranslationKey } from '@/typings' ;
2023-08-15 13:54:29 +02:00
import requestManager , { AbortableSWRInfiniteResponse } from '@/lib/requests/RequestManager.ts' ;
2023-06-05 01:42:39 +02:00
import { useDebounce } from '@/components/manga/hooks' ;
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext' ;
import SourceGridLayout from '@/components/source/GridLayouts' ;
import AppbarSearch from '@/components/util/AppbarSearch' ;
import SourceOptions from '@/components/source/SourceOptions' ;
import NavbarContext from '@/components/context/NavbarContext' ;
import SourceMangaGrid from '@/components/source/SourceMangaGrid' ;
2023-05-28 12:16:06 +02:00
const ContentTypeMenu = styled ( 'div' ) ( ( { theme } ) = > ( {
display : 'flex' ,
position : 'fixed' ,
top : '64px' ,
width : '100%' ,
zIndex : 1 ,
backgroundColor : theme.palette.background.default ,
[ theme . breakpoints . down ( 'sm' ) ] : {
top : '56px' , // header height
} ,
} ) ) ;
const ContentTypeButton = styled ( Button ) ( ( ) = > ( {
marginTop : '13px' ,
marginBottom : '13px' ,
marginLeft : '13px' ,
} ) ) ;
const StyledGridWrapper = styled ( Box , { shouldForwardProp : ( prop ) = > prop !== 'hasContent' } ) < { hasContent : boolean } > (
( { theme , hasContent } ) = > ( {
// 62.5px ContentTypeMenu height (- padding of grid + grid item)
2023-06-17 16:50:17 +02:00
marginTop : ` calc(62.5px ${ hasContent ? '- 8px' : '' } ) ` ,
2023-05-28 12:16:06 +02:00
// header height - ContentTypeMenu height
minHeight : 'calc(100vh - 64px - 62.5px)' ,
position : 'relative' ,
[ theme . breakpoints . down ( 'sm' ) ] : {
2023-06-17 16:50:17 +02:00
// 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid item)
marginTop : ` calc(62.5px - 8px ${ hasContent ? '- 8px' : '' } ) ` ,
2023-05-28 12:16:06 +02:00
// header height (+ 8px margin) - footer height - ContentTypeMenu height
minHeight : 'calc(100vh - 64px - 64px - 62.5px)' ,
} ,
} ) ,
) ;
export enum SourceContentType {
POPULAR ,
LATEST ,
SEARCH ,
FILTER ,
}
2022-03-04 01:25:10 +00:00
interface IPos {
2023-02-06 10:06:33 +01:00
position : number ;
state : any ;
group? : number ;
2022-03-04 01:25:10 +00:00
}
2021-01-19 14:47:07 +03:30
2023-05-28 12:16:06 +02:00
const SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY : { [ contentType in SourceContentType ] : TranslationKey } = {
[ SourceContentType . POPULAR ] : 'manga.error.label.no_mangas_found' ,
[ SourceContentType . LATEST ] : 'manga.error.label.no_mangas_found' ,
[ SourceContentType . FILTER ] : 'manga.error.label.no_matches' ,
[ SourceContentType . SEARCH ] : 'manga.error.label.no_mangas_found' ,
} ;
type SourceMangaResponse = Omit < AbortableSWRInfiniteResponse < PaginatedMangaList > , 'data' > & {
data : {
items : IManga [ ] ;
hasNextPage : boolean ;
} ;
} ;
const getUniqueMangas = ( mangas : IManga [ ] ) : IManga [ ] = > {
const uniqueMangas : IManga [ ] = [ ] ;
mangas . forEach ( ( manga ) = > {
const isDuplicate = uniqueMangas . some ( ( uniqueManga ) = > uniqueManga . id === manga . id ) ;
if ( ! isDuplicate ) {
uniqueMangas . push ( manga ) ;
}
} ) ;
return uniqueMangas ;
} ;
const useSourceManga = (
sourceId : string ,
contentType : SourceContentType ,
searchTerm : string | null | undefined ,
filters : IPos [ ] ,
2023-06-17 16:50:17 +02:00
initialPages = 1 ,
2023-05-28 12:16:06 +02:00
) : SourceMangaResponse = > {
let result : AbortableSWRInfiniteResponse < PaginatedMangaList > ;
switch ( contentType ) {
case SourceContentType . POPULAR :
2023-06-17 16:50:17 +02:00
result = requestManager . useGetSourcePopularMangas ( sourceId , initialPages ) ;
2023-05-28 12:16:06 +02:00
break ;
case SourceContentType . LATEST :
2023-06-17 16:50:17 +02:00
result = requestManager . useGetSourceLatestMangas ( sourceId , initialPages ) ;
2023-05-28 12:16:06 +02:00
break ;
case SourceContentType . SEARCH :
2023-06-17 16:50:17 +02:00
result = requestManager . useSourceQuickSearch ( sourceId , searchTerm ? ? '' , [ ] , initialPages ) ;
2023-05-28 12:16:06 +02:00
break ;
case SourceContentType . FILTER :
result = requestManager . useSourceQuickSearch (
sourceId ,
2023-06-17 15:45:43 +02:00
'' ,
2023-05-28 12:16:06 +02:00
filters . map ( ( filter ) = > {
const { position , state , group } = filter ;
const isPartOfGroup = group !== undefined ;
if ( isPartOfGroup ) {
return {
position : group ,
state : JSON.stringify ( {
position ,
state ,
} ) ,
} ;
}
return filter ;
} ) ,
2023-06-17 16:50:17 +02:00
initialPages ,
2023-06-17 15:45:18 +02:00
{ disableCache : true } ,
2023-05-28 12:16:06 +02:00
) ;
break ;
default :
throw new Error ( ` Unknown ContentType " ${ contentType } " ` ) ;
}
const pages = result . data ;
const { hasNextPage } = pages ? . [ pages . length - 1 ] ? ? { hasNextPage : false } ;
const items = useMemo (
( ) = > ( pages ? ? [ ] ) . map ( ( page ) = > page . mangaList ) . reduce ( ( prevList , list ) = > [ . . . prevList , . . . list ] , [ ] ) ,
[ pages ] ,
) ;
const uniqueItems = useMemo ( ( ) = > getUniqueMangas ( items ) , [ items ] ) ;
return { . . . result , data : { items : uniqueItems , hasNextPage } } ;
} ;
export default function SourceMangas() {
2023-03-23 13:59:32 +01:00
const { t } = useTranslation ( ) ;
2021-03-09 16:44:09 +03:30
const { setTitle , setAction } = useContext ( NavbarContext ) ;
2023-06-17 16:50:17 +02:00
const theme = useTheme ( ) ;
const isLargeScreen = useMediaQuery ( theme . breakpoints . up ( 'sm' ) ) ;
2021-03-09 16:44:09 +03:30
2021-05-13 17:46:40 +04:30
const { sourceId } = useParams < { sourceId : string } > ( ) ;
2023-05-18 13:25:19 +02:00
2023-06-04 21:30:15 +02:00
const navigate = useNavigate ( ) ;
2023-06-10 00:42:26 +02:00
const { contentType : currentLocationContentType = SourceContentType . POPULAR } =
useLocation < {
contentType : SourceContentType ;
} > ( ) . state ? ? { } ;
2021-01-19 14:47:07 +03:30
2023-05-28 12:16:06 +02:00
const { options } = useLibraryOptionsContext ( ) ;
const [ query ] = useQueryParam ( 'query' , StringParam ) ;
const [ dialogFiltersToApply , setDialogFiltersToApply ] = useState < IPos [ ] > ( [ ] ) ;
const [ filtersToApply , setFiltersToApply ] = useState < IPos [ ] > ( [ ] ) ;
const searchTerm = useDebounce ( query , 1000 ) ;
2023-06-17 16:29:55 +02:00
const [ resetScrollPosition , setResetScrollPosition ] = useState ( false ) ;
2023-05-28 12:16:06 +02:00
const [ contentType , setContentType ] = useState ( currentLocationContentType ) ;
const {
data : { items : mangas , hasNextPage } = { items : [ ] , hasNextPage : false } ,
isLoading ,
size : lastPageNum ,
setSize : setPages ,
mutate : refreshData ,
abortRequest ,
2023-06-17 16:50:17 +02:00
} = useSourceManga ( sourceId , contentType , searchTerm , filtersToApply , isLargeScreen ? 2 : 1 ) ;
2023-05-28 12:16:06 +02:00
const { data : filters = [ ] , mutate : mutateFilters } = requestManager . useGetSourceFilters ( sourceId ) ;
2023-09-01 20:29:19 +02:00
const { data } = requestManager . useGetSource ( sourceId ) ;
const source = data ? . source ;
2023-05-28 12:16:06 +02:00
const [ triggerDataRefresh , setTriggerDataRefresh ] = useState ( false ) ;
2022-03-04 01:25:10 +00:00
2023-07-04 21:53:25 +02:00
const message = ! isLoading ? t ( SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY [ contentType ] ) : undefined ;
2023-05-28 12:16:06 +02:00
const isLocalSource = sourceId === '0' ;
const messageExtra = isLocalSource ? (
< >
< span > { t ( 'source.local_source.label.checkout' ) } < / span >
< Link href = "https://github.com/Suwayomi/Tachidesk-Server/wiki/Local-Source" >
{ t ( 'source.local_source.label.guide' ) }
< / Link >
< / >
) : undefined ;
2022-03-06 21:02:06 +00:00
2023-06-17 16:29:55 +02:00
const updateContentType = useCallback (
( newContentType : SourceContentType , updateLocationState : boolean = true ) = > {
if ( updateLocationState ) {
navigate ( '' , { replace : true , state : { contentType : newContentType } } ) ;
}
setContentType ( newContentType ) ;
setResetScrollPosition ( true ) ;
} ,
[ setContentType ] ,
) ;
2023-05-28 12:16:06 +02:00
const isSearchTermAvailable = searchTerm && query ? . length ;
const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType . SEARCH ;
if ( setSearchContentType ) {
2023-06-17 16:29:55 +02:00
updateContentType ( SourceContentType . SEARCH , false ) ;
2022-03-04 01:25:10 +00:00
}
2023-05-28 12:16:06 +02:00
const closeSearch = ! query ? . length && contentType === SourceContentType . SEARCH ;
if ( closeSearch ) {
2023-06-17 16:29:55 +02:00
updateContentType ( currentLocationContentType , false ) ;
2023-05-28 12:16:06 +02:00
}
2021-09-13 19:18:21 +04:30
2023-06-17 16:50:17 +02:00
const loadMore = useCallback ( ( ) = > {
if ( ! hasNextPage ) {
2023-05-18 13:25:19 +02:00
return ;
}
2023-05-28 12:16:06 +02:00
setPages ( lastPageNum + 1 ) ;
2023-06-17 16:50:17 +02:00
} , [ setPages , lastPageNum , hasNextPage ] ) ;
2022-03-04 01:25:10 +00:00
2023-05-28 12:16:06 +02:00
const resetFilters = useCallback ( async ( ) = > {
setDialogFiltersToApply ( [ ] ) ;
setFiltersToApply ( [ ] ) ;
try {
// required since previous implementation used to set the filters on server side (server caches them), thus, it has to be made sure that they are reset
await requestManager . resetSourceFilters ( sourceId ) ;
mutateFilters ( ) ;
} catch ( error ) {
// ignore
2022-03-04 01:25:10 +00:00
}
2023-05-28 12:16:06 +02:00
setTriggerDataRefresh ( true ) ;
} , [ sourceId ] ) ;
useEffect (
( ) = > ( ) = > {
if ( contentType !== SourceContentType . SEARCH ) {
return ;
2023-02-06 10:06:33 +01:00
}
2023-05-28 12:16:06 +02:00
2023-06-10 00:42:26 +02:00
// INFO:
// with strict mode + dev mode the first request will be aborted. due to using SWR there won't be an
// immediate second request since it's the same key. instead the "second" request will be the error handling of SWR
2023-05-28 12:16:06 +02:00
abortRequest ( new Error ( ` SourceMangas( ${ sourceId } ): search string changed ` ) ) ;
2023-06-20 01:12:10 +02:00
setResetScrollPosition ( true ) ;
2023-05-28 12:16:06 +02:00
} ,
[ searchTerm , contentType ] ,
) ;
2022-03-04 01:25:10 +00:00
useEffect ( ( ) = > {
2023-05-28 12:16:06 +02:00
if ( ! triggerDataRefresh ) {
2022-03-04 01:25:10 +00:00
return ;
}
2023-05-28 12:16:06 +02:00
refreshData ( ) ;
setTriggerDataRefresh ( false ) ;
} , [ triggerDataRefresh ] ) ;
2022-03-04 01:25:10 +00:00
2021-09-13 19:18:21 +04:30
useEffect ( ( ) = > {
2023-05-28 12:16:06 +02:00
setTitle ( source ? . displayName ? ? t ( 'source.title' ) ) ;
2021-09-09 22:57:19 +04:30
setAction (
< >
2022-03-04 01:25:10 +00:00
< AppbarSearch / >
2023-05-28 12:16:06 +02:00
< SourceGridLayout / >
{ source ? . isConfigurable && (
2021-09-09 22:57:19 +04:30
< IconButton
2023-06-04 21:30:15 +02:00
onClick = { ( ) = > navigate ( ` /sources/ ${ sourceId } /configure/ ` ) }
2021-09-09 22:57:19 +04:30
aria - label = "display more actions"
edge = "end"
color = "inherit"
size = "large"
>
< SettingsIcon / >
< / IconButton >
) }
2023-02-06 10:06:33 +01:00
< / > ,
2021-09-09 22:57:19 +04:30
) ;
2023-05-28 12:16:06 +02:00
} , [ t , source ] ) ;
2021-09-18 00:05:00 +04:30
2023-06-16 00:15:13 +02:00
useEffect ( ( ) = > {
2023-06-17 16:29:55 +02:00
if ( ! resetScrollPosition ) {
return ;
}
2023-06-16 00:15:13 +02:00
window . scrollTo ( 0 , 0 ) ;
2023-06-20 00:59:19 +02:00
setResetScrollPosition ( false ) ;
2023-06-17 16:29:55 +02:00
} , [ resetScrollPosition ] ) ;
2023-06-16 00:15:13 +02:00
2021-01-22 21:11:00 +03:30
return (
2023-05-28 12:16:06 +02:00
< StyledGridWrapper hasContent = { ! ! mangas . length } >
< ContentTypeMenu >
< ContentTypeButton
variant = { contentType === SourceContentType . POPULAR ? 'contained' : 'outlined' }
startIcon = { < FavoriteIcon / > }
onClick = { ( ) = > updateContentType ( SourceContentType . POPULAR ) }
>
{ t ( 'global.button.popular' ) }
< / ContentTypeButton >
< ContentTypeButton
variant = { contentType === SourceContentType . LATEST ? 'contained' : 'outlined' }
startIcon = { < NewReleasesIcon / > }
onClick = { ( ) = > updateContentType ( SourceContentType . LATEST ) }
>
{ t ( 'global.button.latest' ) }
< / ContentTypeButton >
< ContentTypeButton
variant = { contentType === SourceContentType . FILTER ? 'contained' : 'outlined' }
startIcon = { < FilterListIcon / > }
onClick = { ( ) = > updateContentType ( SourceContentType . FILTER ) }
>
{ t ( 'global.button.filter' ) }
< / ContentTypeButton >
< / ContentTypeMenu >
2022-03-04 01:25:10 +00:00
< SourceMangaGrid
2023-06-17 15:45:18 +02:00
mangas = { mangas }
2022-03-04 01:25:10 +00:00
hasNextPage = { hasNextPage }
2023-06-17 16:50:17 +02:00
loadMore = { loadMore }
2022-03-04 01:25:10 +00:00
message = { message }
messageExtra = { messageExtra }
2023-06-17 15:45:18 +02:00
isLoading = { isLoading }
2022-03-06 21:02:06 +00:00
gridLayout = { options . SourcegridLayout }
2022-03-04 01:25:10 +00:00
/ >
2023-05-28 12:16:06 +02:00
{ contentType === SourceContentType . FILTER && (
2022-03-04 01:25:10 +00:00
< SourceOptions
2023-05-28 12:16:06 +02:00
sourceFilter = { filters }
updateFilterValue = { setDialogFiltersToApply }
setTriggerUpdate = { ( ) = > {
setFiltersToApply ( dialogFiltersToApply ) ;
setTriggerDataRefresh ( true ) ;
} }
resetFilterValue = { resetFilters }
update = { dialogFiltersToApply }
2022-03-04 01:25:10 +00:00
/ >
) }
2023-05-28 12:16:06 +02:00
< / StyledGridWrapper >
2021-01-22 21:11:00 +03:30
) ;
2021-01-19 14:47:07 +03:30
}