/* * Copyright (C) Contributors to the Suwayomi project * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import IconButton from '@mui/material/IconButton'; import SettingsIcon from '@mui/icons-material/Settings'; import { useQueryParam, StringParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; import Link from '@mui/material/Link'; import { Box, Button, styled, useTheme, useMediaQuery } 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'; 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'; 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 ? '- 8px' : ''})`, // 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 item) marginTop: `calc(62.5px - 8px ${hasContent ? '- 8px' : ''})`, // 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; state: any; group?: number; } 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[], initialPages = 1, ): SourceMangaResponse => { let result: AbortableSWRInfiniteResponse; switch (contentType) { case SourceContentType.POPULAR: result = requestManager.useGetSourcePopularMangas(sourceId, initialPages); break; case SourceContentType.LATEST: result = requestManager.useGetSourceLatestMangas(sourceId, initialPages); break; case SourceContentType.SEARCH: result = requestManager.useSourceQuickSearch(sourceId, searchTerm ?? '', [], initialPages); break; case SourceContentType.FILTER: result = requestManager.useSourceQuickSearch( sourceId, '', filters.map((filter) => { const { position, state, group } = filter; const isPartOfGroup = group !== undefined; if (isPartOfGroup) { return { position: group, state: JSON.stringify({ position, state, }), }; } return filter; }), initialPages, { disableCache: true }, ); 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 theme = useTheme(); const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm')); const { sourceId } = useParams<{ sourceId: string }>(); const navigate = useNavigate(); const { contentType: currentLocationContentType = SourceContentType.POPULAR } = useLocation<{ contentType: SourceContentType; }>().state ?? {}; const { options } = useLibraryOptionsContext(); const [query] = useQueryParam('query', StringParam); const [dialogFiltersToApply, setDialogFiltersToApply] = useState([]); const [filtersToApply, setFiltersToApply] = useState([]); const searchTerm = useDebounce(query, 1000); const [resetScrollPosition, setResetScrollPosition] = useState(false); const [contentType, setContentType] = useState(currentLocationContentType); const { data: { items: mangas, hasNextPage } = { items: [], hasNextPage: false }, isLoading, size: lastPageNum, setSize: setPages, mutate: refreshData, abortRequest, } = useSourceManga(sourceId, contentType, searchTerm, filtersToApply, isLargeScreen ? 2 : 1); const { data: filters = [], mutate: mutateFilters } = requestManager.useGetSourceFilters(sourceId); const { data: source } = requestManager.useGetSource(sourceId); const [triggerDataRefresh, setTriggerDataRefresh] = useState(false); const message = !isLoading ? t(SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY[contentType]) : undefined; const isLocalSource = sourceId === '0'; const messageExtra = isLocalSource ? ( <> {t('source.local_source.label.checkout')} {t('source.local_source.label.guide')} ) : undefined; const updateContentType = useCallback( (newContentType: SourceContentType, updateLocationState: boolean = true) => { if (updateLocationState) { navigate('', { replace: true, state: { contentType: newContentType } }); } setContentType(newContentType); setResetScrollPosition(true); }, [setContentType], ); const isSearchTermAvailable = searchTerm && query?.length; const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType.SEARCH; if (setSearchContentType) { updateContentType(SourceContentType.SEARCH, false); } const closeSearch = !query?.length && contentType === SourceContentType.SEARCH; if (closeSearch) { updateContentType(currentLocationContentType, false); } const loadMore = useCallback(() => { if (!hasNextPage) { return; } setPages(lastPageNum + 1); }, [setPages, lastPageNum, hasNextPage]); 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 } setTriggerDataRefresh(true); }, [sourceId]); useEffect( () => () => { if (contentType !== SourceContentType.SEARCH) { return; } // 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 abortRequest(new Error(`SourceMangas(${sourceId}): search string changed`)); setResetScrollPosition(true); }, [searchTerm, contentType], ); useEffect(() => { if (!triggerDataRefresh) { return; } refreshData(); setTriggerDataRefresh(false); }, [triggerDataRefresh]); useEffect(() => { setTitle(source?.displayName ?? t('source.title')); setAction( <> {source?.isConfigurable && ( navigate(`/sources/${sourceId}/configure/`)} aria-label="display more actions" edge="end" color="inherit" size="large" > )} , ); }, [t, source]); useEffect(() => { if (!resetScrollPosition) { return; } window.scrollTo(0, 0); setResetScrollPosition(false); }, [resetScrollPosition]); 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')} {contentType === SourceContentType.FILTER && ( { setFiltersToApply(dialogFiltersToApply); setTriggerDataRefresh(true); }} resetFilterValue={resetFilters} update={dialogFiltersToApply} /> )} ); }