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 && ( - )} - 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} /> )} - + ); }