Feature/refactor source mangas screen (#314)
* Refactor "SourceMangas" * Refactor "SourceMangas" - Show loading placeholder on filter reset or submit Due to the url being the same for all "filter requests" the loading state doesn't get changed by SWR. Instead, only the "isValidating" state gets updated. * Refactor "SourceMangas" - Update routing * Refactor "SourceMangas" - Prevent duplicated mangas in grid There is a possibility that the "latest" endpoint returns the same manga on different pages * Always set the toolbar content on the manga page "setAction" wasn't called on the manga page, thus, in case the previous page didn't unset the action, it never got unset * Rename source content type "Browse" to "Popular"
This commit is contained in:
@@ -71,11 +71,8 @@ const App: React.FC = () => (
|
|||||||
|
|
||||||
{/* Manga Routes */}
|
{/* Manga Routes */}
|
||||||
|
|
||||||
<Route path="/sources/:sourceId/popular/">
|
<Route exact path="/sources/:sourceId">
|
||||||
<SourceMangas popular />
|
<SourceMangas />
|
||||||
</Route>
|
|
||||||
<Route path="/sources/:sourceId/latest/">
|
|
||||||
<SourceMangas popular={false} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/sources/:sourceId/configure/">
|
<Route path="/sources/:sourceId/configure/">
|
||||||
<SourceConfigure />
|
<SourceConfigure />
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import Typography from '@mui/material/Typography';
|
|||||||
import { Box, styled } from '@mui/system';
|
import { Box, styled } from '@mui/system';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ISource } from 'typings';
|
import { ISource } from 'typings';
|
||||||
import { translateExtensionLanguage } from 'screens/util/Extensions';
|
import { translateExtensionLanguage } from 'screens/util/Extensions';
|
||||||
import requestManager from 'lib/RequestManager';
|
import requestManager from 'lib/RequestManager';
|
||||||
|
import { SourceContentType } from 'screens/SourceMangas';
|
||||||
|
|
||||||
const MobileWidthButtons = styled('div')(({ theme }) => ({
|
const MobileWidthButtons = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -49,22 +50,16 @@ const SourceCard: React.FC<IProps> = (props: IProps) => {
|
|||||||
source: { id, name, lang, iconUrl, supportsLatest, isNsfw },
|
source: { id, name, lang, iconUrl, supportsLatest, isNsfw },
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const redirectTo = (e: any, to: string) => {
|
|
||||||
history.push(to);
|
|
||||||
|
|
||||||
// prevent parent tags from getting the event
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
sx={{
|
sx={{
|
||||||
margin: '10px',
|
margin: '10px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardActionArea component={Link} to={`/sources/${id}/popular/`}>
|
<CardActionArea
|
||||||
|
component={Link}
|
||||||
|
to={{ pathname: `/sources/${id}`, state: { contentType: SourceContentType.POPULAR } }}
|
||||||
|
>
|
||||||
<CardContent
|
<CardContent
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -110,19 +105,37 @@ const SourceCard: React.FC<IProps> = (props: IProps) => {
|
|||||||
<>
|
<>
|
||||||
<MobileWidthButtons>
|
<MobileWidthButtons>
|
||||||
{supportsLatest && (
|
{supportsLatest && (
|
||||||
<Button variant="outlined" onClick={(e) => redirectTo(e, `/sources/${id}/latest/`)}>
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component={Link}
|
||||||
|
to={{
|
||||||
|
pathname: `/sources/${id}`,
|
||||||
|
state: { contentType: SourceContentType.LATEST },
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('global.button.latest')}
|
{t('global.button.latest')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</MobileWidthButtons>
|
</MobileWidthButtons>
|
||||||
<WiderWidthButtons>
|
<WiderWidthButtons>
|
||||||
{supportsLatest && (
|
{supportsLatest && (
|
||||||
<Button component={Link} to={`/sources/${id}/latest/`} variant="outlined">
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component={Link}
|
||||||
|
to={{
|
||||||
|
pathname: `/sources/${id}`,
|
||||||
|
state: { contentType: SourceContentType.LATEST },
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('global.button.latest')}
|
{t('global.button.latest')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button component={Link} to={`/sources/${id}/popular/`} variant="outlined">
|
<Button
|
||||||
{t('global.button.browse')}
|
variant="outlined"
|
||||||
|
component={Link}
|
||||||
|
to={{ pathname: `/sources/${id}`, state: { contentType: SourceContentType.POPULAR } }}
|
||||||
|
>
|
||||||
|
{t('global.button.popular')}
|
||||||
</Button>
|
</Button>
|
||||||
</WiderWidthButtons>
|
</WiderWidthButtons>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ interface IFilters1 {
|
|||||||
updateFilterValue: Function;
|
updateFilterValue: Function;
|
||||||
resetFilterValue: Function;
|
resetFilterValue: Function;
|
||||||
setTriggerUpdate: Function;
|
setTriggerUpdate: Function;
|
||||||
setSearch: Function;
|
|
||||||
update: any;
|
update: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +141,6 @@ export default function SourceOptions({
|
|||||||
updateFilterValue,
|
updateFilterValue,
|
||||||
resetFilterValue,
|
resetFilterValue,
|
||||||
setTriggerUpdate,
|
setTriggerUpdate,
|
||||||
setSearch,
|
|
||||||
update,
|
update,
|
||||||
}: IFilters1) {
|
}: IFilters1) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -155,7 +153,6 @@ export default function SourceOptions({
|
|||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
setTriggerUpdate(0);
|
setTriggerUpdate(0);
|
||||||
setSearch(true);
|
|
||||||
setFilterOptions(false);
|
setFilterOptions(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,7 @@
|
|||||||
"latest": "Latest",
|
"latest": "Latest",
|
||||||
"ok": "Ok",
|
"ok": "Ok",
|
||||||
"open_site": "Open Site",
|
"open_site": "Open Site",
|
||||||
|
"popular": "Popular",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"reset_to_default": "Reset to Default",
|
"reset_to_default": "Reset to Default",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ type SWRInfiniteResponseLoadInfo = {
|
|||||||
isLoadMore: boolean;
|
isLoadMore: boolean;
|
||||||
};
|
};
|
||||||
type AbortableRequest = { abortRequest: AbortController['abort'] };
|
type AbortableRequest = { abortRequest: AbortController['abort'] };
|
||||||
type AbortableAxiosResponse<Data = any> = { response: Promise<AxiosResponse<Data>> } & AbortableRequest;
|
export type AbortableAxiosResponse<Data = any> = { response: Promise<AxiosResponse<Data>> } & AbortableRequest;
|
||||||
type AbortableSWRResponse<Data = any, Error = any> = SWRResponse<Data, Error> & AbortableRequest;
|
export type AbortableSWRResponse<Data = any, Error = any> = SWRResponse<Data, Error> & AbortableRequest;
|
||||||
type AbortableSWRInfiniteResponse<Data = any, Error = any> = SWRInfiniteResponse<Data, Error> &
|
export type AbortableSWRInfiniteResponse<Data = any, Error = any> = SWRInfiniteResponse<Data, Error> &
|
||||||
AbortableRequest &
|
AbortableRequest &
|
||||||
SWRInfiniteResponseLoadInfo;
|
SWRInfiniteResponseLoadInfo;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import ChapterList from 'components/manga/ChapterList';
|
|||||||
import { useRefreshManga } from 'components/manga/hooks';
|
import { useRefreshManga } from 'components/manga/hooks';
|
||||||
import MangaDetails from 'components/manga/MangaDetails';
|
import MangaDetails from 'components/manga/MangaDetails';
|
||||||
import MangaToolbarMenu from 'components/manga/MangaToolbarMenu';
|
import MangaToolbarMenu from 'components/manga/MangaToolbarMenu';
|
||||||
import { NavbarToolbar } from 'components/navbar/DefaultNavBar';
|
|
||||||
import EmptyView from 'components/util/EmptyView';
|
import EmptyView from 'components/util/EmptyView';
|
||||||
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
|
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
|
||||||
import React, { useContext, useEffect, useRef } from 'react';
|
import React, { useContext, useEffect, useRef } from 'react';
|
||||||
@@ -27,7 +26,7 @@ const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours
|
|||||||
const Manga: React.FC = () => {
|
const Manga: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { setTitle } = useContext(NavbarContext);
|
const { setTitle, setAction } = useContext(NavbarContext);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const autofetchedRef = useRef(false);
|
const autofetchedRef = useRef(false);
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ const Manga: React.FC = () => {
|
|||||||
const [refresh, { loading: refreshing }] = useRefreshManga(id);
|
const [refresh, { loading: refreshing }] = useRefreshManga(id);
|
||||||
|
|
||||||
useSetDefaultBackTo(
|
useSetDefaultBackTo(
|
||||||
manga?.inLibrary === false && manga.sourceId != null ? `/sources/${manga.sourceId}/popular` : '/library',
|
manga?.inLibrary === false && manga.sourceId != null ? `/sources/${manga.sourceId}` : '/library',
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,39 +55,42 @@ const Manga: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitle(manga?.title ?? t('manga.title'));
|
setTitle(manga?.title ?? t('manga.title'));
|
||||||
|
setAction(null);
|
||||||
}, [t, manga?.title]);
|
}, [t, manga?.title]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAction(
|
||||||
|
<Stack direction="row" alignItems="center">
|
||||||
|
{error && !isValidating && !refreshing && (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{t('manga.error.label.request_failure')}
|
||||||
|
<br />
|
||||||
|
{error.message ?? error}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton onClick={() => mutate()}>
|
||||||
|
<Warning color="error" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{manga && (refreshing || isValidating) && (
|
||||||
|
<IconButton disabled>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />}
|
||||||
|
</Stack>,
|
||||||
|
);
|
||||||
|
}, [t, error, isValidating, refreshing, mutate, manga, refresh]);
|
||||||
|
|
||||||
if (error && !manga) {
|
if (error && !manga) {
|
||||||
return <EmptyView message={t('manga.error.label.request_failure')} messageExtra={error.message ?? error} />;
|
return <EmptyView message={t('manga.error.label.request_failure')} messageExtra={error.message ?? error} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: { md: 'flex' }, overflow: 'hidden' }}>
|
<Box sx={{ display: { md: 'flex' }, overflow: 'hidden' }}>
|
||||||
<NavbarToolbar>
|
|
||||||
<Stack direction="row" alignItems="center">
|
|
||||||
{error && !isValidating && !refreshing && (
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
{t('manga.error.label.request_failure')}
|
|
||||||
<br />
|
|
||||||
{error.message ?? error}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton onClick={() => mutate()}>
|
|
||||||
<Warning color="error" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{manga && (refreshing || isValidating) && (
|
|
||||||
<IconButton disabled>
|
|
||||||
<CircularProgress size={16} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />}
|
|
||||||
</Stack>
|
|
||||||
</NavbarToolbar>
|
|
||||||
|
|
||||||
{isLoading && <LoadingPlaceholder />}
|
{isLoading && <LoadingPlaceholder />}
|
||||||
|
|
||||||
{manga && <MangaDetails manga={manga} />}
|
{manga && <MangaDetails manga={manga} />}
|
||||||
|
|||||||
@@ -137,11 +137,7 @@ const SourceSearchPreview = React.memo(
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card sx={{ margin: '10px' }}>
|
<Card sx={{ margin: '10px' }}>
|
||||||
<CardActionArea
|
<CardActionArea component={Link} to={`/sources/${id}?query=${searchString}`} sx={{ p: 3 }}>
|
||||||
component={Link}
|
|
||||||
to={`/sources/${id}/popular/?R&query=${searchString}`}
|
|
||||||
sx={{ p: 3 }}
|
|
||||||
>
|
|
||||||
<Typography variant="h5">{displayName}</Typography>
|
<Typography variant="h5">{displayName}</Typography>
|
||||||
<Typography variant="caption">{translateExtensionLanguage(lang)}</Typography>
|
<Typography variant="caption">{translateExtensionLanguage(lang)}</Typography>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useContext, useEffect, useState } from 'react';
|
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useHistory } from 'react-router-dom';
|
import { useParams, useHistory } from 'react-router-dom';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import SourceMangaGrid from 'components/source/SourceMangaGrid';
|
import SourceMangaGrid from 'components/source/SourceMangaGrid';
|
||||||
@@ -17,10 +17,56 @@ import AppbarSearch from 'components/util/AppbarSearch';
|
|||||||
import { useQueryParam, StringParam } from 'use-query-params';
|
import { useQueryParam, StringParam } from 'use-query-params';
|
||||||
import SourceGridLayout from 'components/source/GridLayouts';
|
import SourceGridLayout from 'components/source/GridLayouts';
|
||||||
import { useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
|
import { useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
|
||||||
import { IManga, IMangaCard, ISourceFilters } from 'typings';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Link from '@mui/material/Link';
|
import Link from '@mui/material/Link';
|
||||||
import requestManager from 'lib/RequestManager';
|
import requestManager, { AbortableSWRInfiniteResponse } from 'lib/RequestManager';
|
||||||
|
import { useDebounce } from 'components/manga/hooks';
|
||||||
|
import { Box, Button, styled } from '@mui/material';
|
||||||
|
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
|
import NewReleasesIcon from '@mui/icons-material/NewReleases';
|
||||||
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
|
import { IManga, PaginatedMangaList, TranslationKey } from 'typings';
|
||||||
|
|
||||||
|
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)
|
||||||
|
marginTop: `calc(62.5px ${hasContent ? '- 13px' : ''})`,
|
||||||
|
// header height - ContentTypeMenu height
|
||||||
|
minHeight: 'calc(100vh - 64px - 62.5px)',
|
||||||
|
position: 'relative',
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
// 62.5px ContentTypeMenu - 8px margin diff header height (56px) (- padding of grid + grid item)
|
||||||
|
marginTop: `calc(62.5px - 8px ${hasContent ? '- 13px' : ''})`,
|
||||||
|
// header height (+ 8px margin) - footer height - ContentTypeMenu height
|
||||||
|
minHeight: 'calc(100vh - 64px - 64px - 62.5px)',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export enum SourceContentType {
|
||||||
|
POPULAR,
|
||||||
|
LATEST,
|
||||||
|
SEARCH,
|
||||||
|
FILTER,
|
||||||
|
}
|
||||||
|
|
||||||
interface IPos {
|
interface IPos {
|
||||||
position: number;
|
position: number;
|
||||||
@@ -28,124 +74,198 @@ interface IPos {
|
|||||||
group?: number;
|
group?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SourceMangas({ popular }: { popular: boolean }) {
|
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[],
|
||||||
|
): SourceMangaResponse => {
|
||||||
|
let result: AbortableSWRInfiniteResponse<PaginatedMangaList>;
|
||||||
|
switch (contentType) {
|
||||||
|
case SourceContentType.POPULAR:
|
||||||
|
result = requestManager.useGetSourcePopularMangas(sourceId, 1);
|
||||||
|
break;
|
||||||
|
case SourceContentType.LATEST:
|
||||||
|
result = requestManager.useGetSourceLatestMangas(sourceId, 1);
|
||||||
|
break;
|
||||||
|
case SourceContentType.SEARCH:
|
||||||
|
result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], 1);
|
||||||
|
break;
|
||||||
|
case SourceContentType.FILTER:
|
||||||
|
result = requestManager.useSourceQuickSearch(
|
||||||
|
sourceId,
|
||||||
|
searchTerm ?? '',
|
||||||
|
filters.map((filter) => {
|
||||||
|
const { position, state, group } = filter;
|
||||||
|
|
||||||
|
const isPartOfGroup = group !== undefined;
|
||||||
|
if (isPartOfGroup) {
|
||||||
|
return {
|
||||||
|
position: group,
|
||||||
|
state: JSON.stringify({
|
||||||
|
position,
|
||||||
|
state,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
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() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setTitle, setAction } = useContext(NavbarContext);
|
const { setTitle, setAction } = useContext(NavbarContext);
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const { sourceId } = useParams<{ sourceId: string }>();
|
const { sourceId } = useParams<{ sourceId: string }>();
|
||||||
|
|
||||||
const { data: source } = requestManager.useGetSource(sourceId);
|
const history = useHistory<{ contentType: SourceContentType }>();
|
||||||
|
const { contentType: currentLocationContentType = SourceContentType.POPULAR } = history.location.state ?? {};
|
||||||
const [isConfigurable, setIsConfigurable] = useState<boolean>(false);
|
|
||||||
const [mangas, setMangas] = useState<IMangaCard[]>([]);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState<boolean>(false);
|
|
||||||
const [lastPageNum, setLastPageNum] = useState<number>(1);
|
|
||||||
const [fetched, setFetched] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const [Search, setSearch] = useState<boolean>();
|
|
||||||
const [query, setquery] = useQueryParam('query', StringParam);
|
|
||||||
const [reset, setReset] = React.useState(2);
|
|
||||||
const [update, setUpdate] = useState<IPos[]>([]);
|
|
||||||
const [triggerUpdate, setTriggerUpdate] = useState<number>(2);
|
|
||||||
const [Data, SetData] = useState<ISourceFilters[]>();
|
|
||||||
|
|
||||||
const [Init, setInit] = useState<undefined | null>();
|
|
||||||
const [Noreset, setNoreset] = useQueryParam('R');
|
|
||||||
|
|
||||||
const { options } = useLibraryOptionsContext();
|
const { options } = useLibraryOptionsContext();
|
||||||
|
const [query] = useQueryParam('query', StringParam);
|
||||||
|
const [dialogFiltersToApply, setDialogFiltersToApply] = useState<IPos[]>([]);
|
||||||
|
const [filtersToApply, setFiltersToApply] = useState<IPos[]>([]);
|
||||||
|
const searchTerm = useDebounce(query, 1000);
|
||||||
|
const [contentType, setContentType] = useState(currentLocationContentType);
|
||||||
|
const {
|
||||||
|
data: { items: mangas, hasNextPage } = { items: [], hasNextPage: false },
|
||||||
|
isLoading,
|
||||||
|
size: lastPageNum,
|
||||||
|
setSize: setPages,
|
||||||
|
mutate: refreshData,
|
||||||
|
abortRequest,
|
||||||
|
isValidating,
|
||||||
|
} = useSourceManga(sourceId, contentType, searchTerm, filtersToApply);
|
||||||
|
const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId);
|
||||||
|
const { data: source } = requestManager.useGetSource(sourceId);
|
||||||
|
const [triggerDataRefresh, setTriggerDataRefresh] = useState(false);
|
||||||
|
|
||||||
function makeFilters() {
|
const isValidatingMangasForFilter = !isLoading && isValidating && contentType === SourceContentType.FILTER;
|
||||||
requestManager
|
|
||||||
.getClient()
|
const message = !isLoading ? (t(SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY[contentType]) as string) : undefined;
|
||||||
.get(`/api/v1/source/${sourceId}/filters`)
|
const isLocalSource = sourceId === '0';
|
||||||
.then((response) => response.data)
|
const messageExtra = isLocalSource ? (
|
||||||
.then((data: ISourceFilters[]) => {
|
<>
|
||||||
SetData(data);
|
<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;
|
||||||
|
|
||||||
|
const isSearchTermAvailable = searchTerm && query?.length;
|
||||||
|
const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType.SEARCH;
|
||||||
|
if (setSearchContentType) {
|
||||||
|
setContentType(SourceContentType.SEARCH);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const closeSearch = !query?.length && contentType === SourceContentType.SEARCH;
|
||||||
setTitle(t('source.title')); // title is later set after a fetch but we set it here once
|
if (closeSearch) {
|
||||||
}, [t]);
|
setContentType(currentLocationContentType);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
let wasLoadMoreTriggered = false;
|
||||||
if (!source) {
|
const setLastPageNum = useCallback(() => {
|
||||||
|
if (!hasNextPage || wasLoadMoreTriggered) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTitle(source.displayName);
|
wasLoadMoreTriggered = true;
|
||||||
setIsConfigurable(source.isConfigurable);
|
setPages(lastPageNum + 1);
|
||||||
}, [source]);
|
}, [setPages, hasNextPage, lastPageNum]);
|
||||||
|
|
||||||
useEffect(() => {
|
const resetFilters = useCallback(async () => {
|
||||||
if (triggerUpdate === 2) {
|
setDialogFiltersToApply([]);
|
||||||
return;
|
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
|
||||||
}
|
}
|
||||||
if (triggerUpdate === 0) {
|
setTriggerDataRefresh(true);
|
||||||
setTriggerUpdate(1);
|
}, [sourceId]);
|
||||||
return;
|
|
||||||
}
|
const updateContentType = useCallback(
|
||||||
if (update.length > 0) {
|
(newContentType: SourceContentType) => {
|
||||||
const rep = update;
|
history.replace(sourceId, { contentType: newContentType });
|
||||||
setUpdate([]);
|
setContentType(newContentType);
|
||||||
requestManager
|
},
|
||||||
.setSourceFilters(
|
[setContentType],
|
||||||
sourceId,
|
);
|
||||||
rep.map((e: IPos) => {
|
|
||||||
const { position, state, group }: IPos = e;
|
useEffect(
|
||||||
return group === undefined
|
() => () => {
|
||||||
? {
|
if (contentType !== SourceContentType.SEARCH) {
|
||||||
position,
|
return;
|
||||||
state,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
position: group,
|
|
||||||
state: JSON.stringify({
|
|
||||||
position,
|
|
||||||
state,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.response.then(() => {
|
|
||||||
setTriggerUpdate(0);
|
|
||||||
makeFilters();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setFetched(false);
|
|
||||||
setMangas([]);
|
|
||||||
setLastPageNum(0);
|
|
||||||
if (Noreset === undefined && Search) {
|
|
||||||
setNoreset(null);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [triggerUpdate]);
|
abortRequest(new Error(`SourceMangas(${sourceId}): search string changed`));
|
||||||
|
},
|
||||||
|
[searchTerm, contentType],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reset === 0) {
|
if (!triggerDataRefresh) {
|
||||||
setquery(undefined);
|
|
||||||
setNoreset(undefined);
|
|
||||||
setReset(1);
|
|
||||||
} else if (Noreset === undefined) {
|
|
||||||
requestManager.resetSourceFilters(sourceId).response.then(() => {
|
|
||||||
makeFilters();
|
|
||||||
setSearch(false);
|
|
||||||
if (reset === 1) {
|
|
||||||
setTriggerUpdate(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
makeFilters();
|
|
||||||
}, [reset]);
|
refreshData();
|
||||||
|
setTriggerDataRefresh(false);
|
||||||
|
}, [triggerDataRefresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setTitle(source?.displayName ?? t('source.title'));
|
||||||
setAction(
|
setAction(
|
||||||
<>
|
<>
|
||||||
<SourceGridLayout />
|
|
||||||
<AppbarSearch />
|
<AppbarSearch />
|
||||||
{isConfigurable && (
|
<SourceGridLayout />
|
||||||
|
{source?.isConfigurable && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => history.push(`/sources/${sourceId}/configure/`)}
|
onClick={() => history.push(`/sources/${sourceId}/configure/`)}
|
||||||
aria-label="display more actions"
|
aria-label="display more actions"
|
||||||
@@ -158,111 +278,55 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
}, [t, source]);
|
||||||
return () => {
|
|
||||||
setAction(null);
|
|
||||||
};
|
|
||||||
}, [isConfigurable]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (query) {
|
|
||||||
setSearch(true);
|
|
||||||
} else {
|
|
||||||
setSearch(false);
|
|
||||||
}
|
|
||||||
if (Noreset === undefined) {
|
|
||||||
setInit(null);
|
|
||||||
}
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Search !== undefined && query !== undefined && Init === null) {
|
|
||||||
const delayDebounceFn = setTimeout(() => {
|
|
||||||
setTriggerUpdate(0);
|
|
||||||
}, 1000);
|
|
||||||
return () => clearTimeout(delayDebounceFn);
|
|
||||||
}
|
|
||||||
if (Search !== undefined) {
|
|
||||||
setInit(null);
|
|
||||||
}
|
|
||||||
return () => {};
|
|
||||||
}, [Search, query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastPageNum !== 0) {
|
|
||||||
const sourceType = popular ? 'popular' : 'latest';
|
|
||||||
requestManager
|
|
||||||
.getClient()
|
|
||||||
.get(
|
|
||||||
`/api/v1/source/${sourceId}/${
|
|
||||||
query !== undefined || Search || Noreset === null ? 'search' : sourceType
|
|
||||||
}${
|
|
||||||
query !== undefined || Search || Noreset === null
|
|
||||||
? `?searchTerm=${query || ''}&pageNum=${lastPageNum}`
|
|
||||||
: `/${lastPageNum}`
|
|
||||||
}`,
|
|
||||||
)
|
|
||||||
.then((response) => response.data)
|
|
||||||
.then((data: { mangaList: IManga[]; hasNextPage: boolean }) => {
|
|
||||||
setMangas([
|
|
||||||
...mangas,
|
|
||||||
...data.mangaList.map((it) => ({
|
|
||||||
title: it.title,
|
|
||||||
thumbnailUrl: it.thumbnailUrl,
|
|
||||||
id: it.id,
|
|
||||||
inLibrary: it.inLibrary,
|
|
||||||
genre: it.genre,
|
|
||||||
inLibraryAt: it.inLibraryAt,
|
|
||||||
lastReadAt: it.lastReadAt,
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
setHasNextPage(data.hasNextPage);
|
|
||||||
setFetched(true);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setLastPageNum(1);
|
|
||||||
}
|
|
||||||
}, [lastPageNum]);
|
|
||||||
|
|
||||||
let message;
|
|
||||||
let messageExtra;
|
|
||||||
|
|
||||||
if (fetched) {
|
|
||||||
message = t('manga.error.label.no_mangas_found');
|
|
||||||
if (sourceId === '0') {
|
|
||||||
messageExtra = (
|
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<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>
|
||||||
<SourceMangaGrid
|
<SourceMangaGrid
|
||||||
mangas={mangas}
|
mangas={isValidatingMangasForFilter ? [] : mangas}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
lastPageNum={lastPageNum}
|
lastPageNum={lastPageNum}
|
||||||
setLastPageNum={setLastPageNum}
|
setLastPageNum={setLastPageNum}
|
||||||
message={message}
|
message={message}
|
||||||
messageExtra={messageExtra}
|
messageExtra={messageExtra}
|
||||||
isLoading={!fetched}
|
isLoading={isLoading || isValidatingMangasForFilter}
|
||||||
gridLayout={options.SourcegridLayout}
|
gridLayout={options.SourcegridLayout}
|
||||||
/>
|
/>
|
||||||
{Data !== undefined && (
|
{contentType === SourceContentType.FILTER && (
|
||||||
<SourceOptions
|
<SourceOptions
|
||||||
sourceFilter={Data}
|
sourceFilter={filters}
|
||||||
updateFilterValue={setUpdate}
|
updateFilterValue={setDialogFiltersToApply}
|
||||||
resetFilterValue={setReset}
|
setTriggerUpdate={() => {
|
||||||
setTriggerUpdate={setTriggerUpdate}
|
setFiltersToApply(dialogFiltersToApply);
|
||||||
setSearch={setSearch}
|
setTriggerDataRefresh(true);
|
||||||
update={update}
|
}}
|
||||||
|
resetFilterValue={resetFilters}
|
||||||
|
update={dialogFiltersToApply}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</StyledGridWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user