diff --git a/src/App.tsx b/src/App.tsx
index a5f5da28..30000a67 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -71,11 +71,8 @@ const App: React.FC = () => (
{/* Manga Routes */}
-
-
-
-
-
+
+
diff --git a/src/components/SourceCard.tsx b/src/components/SourceCard.tsx
index 3b24e7d8..3ef0fa8b 100644
--- a/src/components/SourceCard.tsx
+++ b/src/components/SourceCard.tsx
@@ -15,10 +15,11 @@ import Typography from '@mui/material/Typography';
import { Box, styled } from '@mui/system';
import React from 'react';
import { useTranslation } from 'react-i18next';
-import { Link, useHistory } from 'react-router-dom';
+import { Link } from 'react-router-dom';
import { ISource } from 'typings';
import { translateExtensionLanguage } from 'screens/util/Extensions';
import requestManager from 'lib/RequestManager';
+import { SourceContentType } from 'screens/SourceMangas';
const MobileWidthButtons = styled('div')(({ theme }) => ({
display: 'flex',
@@ -49,22 +50,16 @@ const SourceCard: React.FC = (props: IProps) => {
source: { id, name, lang, iconUrl, supportsLatest, isNsfw },
} = props;
- const history = useHistory();
-
- const redirectTo = (e: any, to: string) => {
- history.push(to);
-
- // prevent parent tags from getting the event
- e.stopPropagation();
- };
-
return (
-
+
= (props: IProps) => {
<>
{supportsLatest && (
-
{supportsLatest && (
-
+
{t('global.button.latest')}
)}
-
- {t('global.button.browse')}
+
+ {t('global.button.popular')}
>
diff --git a/src/components/source/SourceOptions.tsx b/src/components/source/SourceOptions.tsx
index 2d4355ee..12867c65 100644
--- a/src/components/source/SourceOptions.tsx
+++ b/src/components/source/SourceOptions.tsx
@@ -37,7 +37,6 @@ interface IFilters1 {
updateFilterValue: Function;
resetFilterValue: Function;
setTriggerUpdate: Function;
- setSearch: Function;
update: any;
}
@@ -142,7 +141,6 @@ export default function SourceOptions({
updateFilterValue,
resetFilterValue,
setTriggerUpdate,
- setSearch,
update,
}: IFilters1) {
const { t } = useTranslation();
@@ -155,7 +153,6 @@ export default function SourceOptions({
function handleSubmit() {
setTriggerUpdate(0);
- setSearch(true);
setFilterOptions(false);
}
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index 828b7086..2762bc0f 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -197,6 +197,7 @@
"latest": "Latest",
"ok": "Ok",
"open_site": "Open Site",
+ "popular": "Popular",
"reset": "Reset",
"reset_to_default": "Reset to Default",
"resume": "Resume",
diff --git a/src/lib/RequestManager.ts b/src/lib/RequestManager.ts
index 0a7b0c9b..9ceab418 100644
--- a/src/lib/RequestManager.ts
+++ b/src/lib/RequestManager.ts
@@ -57,9 +57,9 @@ type SWRInfiniteResponseLoadInfo = {
isLoadMore: boolean;
};
type AbortableRequest = { abortRequest: AbortController['abort'] };
-type AbortableAxiosResponse = { response: Promise> } & AbortableRequest;
-type AbortableSWRResponse = SWRResponse & AbortableRequest;
-type AbortableSWRInfiniteResponse = SWRInfiniteResponse &
+export type AbortableAxiosResponse = { response: Promise> } & AbortableRequest;
+export type AbortableSWRResponse = SWRResponse & AbortableRequest;
+export type AbortableSWRInfiniteResponse = SWRInfiniteResponse &
AbortableRequest &
SWRInfiniteResponseLoadInfo;
diff --git a/src/screens/Manga.tsx b/src/screens/Manga.tsx
index 44029553..182a00a7 100644
--- a/src/screens/Manga.tsx
+++ b/src/screens/Manga.tsx
@@ -14,7 +14,6 @@ import ChapterList from 'components/manga/ChapterList';
import { useRefreshManga } from 'components/manga/hooks';
import MangaDetails from 'components/manga/MangaDetails';
import MangaToolbarMenu from 'components/manga/MangaToolbarMenu';
-import { NavbarToolbar } from 'components/navbar/DefaultNavBar';
import EmptyView from 'components/util/EmptyView';
import LoadingPlaceholder from 'components/util/LoadingPlaceholder';
import React, { useContext, useEffect, useRef } from 'react';
@@ -27,7 +26,7 @@ const AUTOFETCH_AGE = 60 * 60 * 24; // 24 hours
const Manga: React.FC = () => {
const { t } = useTranslation();
- const { setTitle } = useContext(NavbarContext);
+ const { setTitle, setAction } = useContext(NavbarContext);
const { id } = useParams<{ id: string }>();
const autofetchedRef = useRef(false);
@@ -36,7 +35,7 @@ const Manga: React.FC = () => {
const [refresh, { loading: refreshing }] = useRefreshManga(id);
useSetDefaultBackTo(
- manga?.inLibrary === false && manga.sourceId != null ? `/sources/${manga.sourceId}/popular` : '/library',
+ manga?.inLibrary === false && manga.sourceId != null ? `/sources/${manga.sourceId}` : '/library',
);
useEffect(() => {
@@ -56,39 +55,42 @@ const Manga: React.FC = () => {
useEffect(() => {
setTitle(manga?.title ?? t('manga.title'));
+ setAction(null);
}, [t, manga?.title]);
+ useEffect(() => {
+ setAction(
+
+ {error && !isValidating && !refreshing && (
+
+ {t('manga.error.label.request_failure')}
+
+ {error.message ?? error}
+ >
+ }
+ >
+ mutate()}>
+
+
+
+ )}
+ {manga && (refreshing || isValidating) && (
+
+
+
+ )}
+ {manga && }
+ ,
+ );
+ }, [t, error, isValidating, refreshing, mutate, manga, refresh]);
+
if (error && !manga) {
return ;
}
return (
-
-
- {error && !isValidating && !refreshing && (
-
- {t('manga.error.label.request_failure')}
-
- {error.message ?? error}
- >
- }
- >
- mutate()}>
-
-
-
- )}
- {manga && (refreshing || isValidating) && (
-
-
-
- )}
- {manga && }
-
-
-
{isLoading && }
{manga && }
diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx
index ebdcbed0..a3ef1608 100644
--- a/src/screens/SearchAll.tsx
+++ b/src/screens/SearchAll.tsx
@@ -137,11 +137,7 @@ const SourceSearchPreview = React.memo(
return (
<>
-
+
{displayName}
{translateExtensionLanguage(lang)}
diff --git a/src/screens/SourceMangas.tsx b/src/screens/SourceMangas.tsx
index 02ebe2ce..521f9fb6 100644
--- a/src/screens/SourceMangas.tsx
+++ b/src/screens/SourceMangas.tsx
@@ -6,7 +6,7 @@
* 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 IconButton from '@mui/material/IconButton';
import SourceMangaGrid from 'components/source/SourceMangaGrid';
@@ -17,10 +17,56 @@ import AppbarSearch from 'components/util/AppbarSearch';
import { useQueryParam, StringParam } from 'use-query-params';
import SourceGridLayout from 'components/source/GridLayouts';
import { useLibraryOptionsContext } from 'components/context/LibraryOptionsContext';
-import { IManga, IMangaCard, ISourceFilters } from 'typings';
import { useTranslation } from 'react-i18next';
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 {
position: number;
@@ -28,124 +74,198 @@ interface IPos {
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, '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;
+ 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 { setTitle, setAction } = useContext(NavbarContext);
- const history = useHistory();
const { sourceId } = useParams<{ sourceId: string }>();
- const { data: source } = requestManager.useGetSource(sourceId);
-
- const [isConfigurable, setIsConfigurable] = useState(false);
- const [mangas, setMangas] = useState([]);
- const [hasNextPage, setHasNextPage] = useState(false);
- const [lastPageNum, setLastPageNum] = useState(1);
- const [fetched, setFetched] = useState(false);
-
- const [Search, setSearch] = useState();
- const [query, setquery] = useQueryParam('query', StringParam);
- const [reset, setReset] = React.useState(2);
- const [update, setUpdate] = useState([]);
- const [triggerUpdate, setTriggerUpdate] = useState(2);
- const [Data, SetData] = useState();
-
- const [Init, setInit] = useState();
- const [Noreset, setNoreset] = useQueryParam('R');
+ const history = useHistory<{ contentType: SourceContentType }>();
+ const { contentType: currentLocationContentType = SourceContentType.POPULAR } = history.location.state ?? {};
const { options } = useLibraryOptionsContext();
+ const [query] = useQueryParam('query', StringParam);
+ const [dialogFiltersToApply, setDialogFiltersToApply] = useState([]);
+ const [filtersToApply, setFiltersToApply] = useState([]);
+ 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() {
- requestManager
- .getClient()
- .get(`/api/v1/source/${sourceId}/filters`)
- .then((response) => response.data)
- .then((data: ISourceFilters[]) => {
- SetData(data);
- });
+ const isValidatingMangasForFilter = !isLoading && isValidating && contentType === SourceContentType.FILTER;
+
+ const message = !isLoading ? (t(SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY[contentType]) as string) : undefined;
+ const isLocalSource = sourceId === '0';
+ const messageExtra = isLocalSource ? (
+ <>
+ {t('source.local_source.label.checkout')}
+
+ {t('source.local_source.label.guide')}
+
+ >
+ ) : undefined;
+
+ const isSearchTermAvailable = searchTerm && query?.length;
+ const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType.SEARCH;
+ if (setSearchContentType) {
+ setContentType(SourceContentType.SEARCH);
}
- useEffect(() => {
- setTitle(t('source.title')); // title is later set after a fetch but we set it here once
- }, [t]);
+ const closeSearch = !query?.length && contentType === SourceContentType.SEARCH;
+ if (closeSearch) {
+ setContentType(currentLocationContentType);
+ }
- useEffect(() => {
- if (!source) {
+ let wasLoadMoreTriggered = false;
+ const setLastPageNum = useCallback(() => {
+ if (!hasNextPage || wasLoadMoreTriggered) {
return;
}
- setTitle(source.displayName);
- setIsConfigurable(source.isConfigurable);
- }, [source]);
+ wasLoadMoreTriggered = true;
+ setPages(lastPageNum + 1);
+ }, [setPages, hasNextPage, lastPageNum]);
- useEffect(() => {
- if (triggerUpdate === 2) {
- return;
+ 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
}
- if (triggerUpdate === 0) {
- setTriggerUpdate(1);
- return;
- }
- if (update.length > 0) {
- const rep = update;
- setUpdate([]);
- requestManager
- .setSourceFilters(
- sourceId,
- rep.map((e: IPos) => {
- const { position, state, group }: IPos = e;
- return group === undefined
- ? {
- position,
- 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);
+ setTriggerDataRefresh(true);
+ }, [sourceId]);
+
+ const updateContentType = useCallback(
+ (newContentType: SourceContentType) => {
+ history.replace(sourceId, { contentType: newContentType });
+ setContentType(newContentType);
+ },
+ [setContentType],
+ );
+
+ useEffect(
+ () => () => {
+ if (contentType !== SourceContentType.SEARCH) {
+ return;
}
- }
- }, [triggerUpdate]);
+
+ abortRequest(new Error(`SourceMangas(${sourceId}): search string changed`));
+ },
+ [searchTerm, contentType],
+ );
useEffect(() => {
- if (reset === 0) {
- setquery(undefined);
- setNoreset(undefined);
- setReset(1);
- } else if (Noreset === undefined) {
- requestManager.resetSourceFilters(sourceId).response.then(() => {
- makeFilters();
- setSearch(false);
- if (reset === 1) {
- setTriggerUpdate(0);
- }
- });
+ if (!triggerDataRefresh) {
return;
}
- makeFilters();
- }, [reset]);
+
+ refreshData();
+ setTriggerDataRefresh(false);
+ }, [triggerDataRefresh]);
useEffect(() => {
+ setTitle(source?.displayName ?? t('source.title'));
setAction(
<>
-
- {isConfigurable && (
+
+ {source?.isConfigurable && (
history.push(`/sources/${sourceId}/configure/`)}
aria-label="display more actions"
@@ -158,111 +278,55 @@ export default function SourceMangas({ popular }: { popular: boolean }) {
)}
>,
);
-
- 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 = (
- <>
- {t('source.local_source.label.checkout')}
-
- {t('source.local_source.label.guide')}
-
- >
- );
- }
- }
+ }, [t, source]);
return (
- <>
+
+
+ }
+ onClick={() => updateContentType(SourceContentType.POPULAR)}
+ >
+ {t('global.button.popular')}
+
+ }
+ onClick={() => updateContentType(SourceContentType.LATEST)}
+ >
+ {t('global.button.latest')}
+
+ }
+ onClick={() => updateContentType(SourceContentType.FILTER)}
+ >
+ {t('global.button.filter')}
+
+
- {Data !== undefined && (
+ {contentType === SourceContentType.FILTER && (
{
+ setFiltersToApply(dialogFiltersToApply);
+ setTriggerDataRefresh(true);
+ }}
+ resetFilterValue={resetFilters}
+ update={dialogFiltersToApply}
/>
)}
- >
+
);
}