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-05-28 12:16:06 +02:00
import React , { 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' ;
import requestManager , { AbortableSWRInfiniteResponse } from '@/lib/RequestManager' ;
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 ?? {};
2022-03-04 01:25:10 +00:00
2022-03-06 21:02:06 +00:00
const { options } = useLibraryOptionsContext ();
2023-05-28 12:16:06 +02:00
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 );
const { data : source } = requestManager . useGetSource ( sourceId );
const [ triggerDataRefresh , setTriggerDataRefresh ] = useState ( false );
2022-03-06 21:02:06 +00:00
2023-05-28 12:16:06 +02:00
const message = ! isLoading ? ( t ( SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY [ contentType ]) as string ) : undefined ;
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 ;
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 ]);
2021-01-22 17:00:33 +03:30
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` ));
},
[ 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
useEffect (() => {
2023-05-28 12:16:06 +02:00
setTitle ( source ? . displayName ?? t ( 'source.title' ));
2022-03-04 01:25:10 +00:00
setAction (
<>
< AppbarSearch />
2023-05-28 12:16:06 +02:00
< SourceGridLayout />
{ source ? . isConfigurable && (
2022-03-04 01:25:10 +00:00
< IconButton
2023-06-04 21:30:15 +02:00
onClick = {() => navigate ( `/sources/ ${ sourceId } /configure/` )}
2022-03-04 01:25:10 +00:00
aria-label = "display more actions"
edge = "end"
color = "inherit"
size = "large"
>
< SettingsIcon />
</ IconButton >
)}
2023-02-06 10:06:33 +01:00
</>,
2022-03-04 01:25:10 +00:00
);
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
}