/* * 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 { 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 from '@mui/material/Box'; import Button from '@mui/material/Button'; import Tooltip from '@mui/material/Tooltip'; import { styled } from '@mui/material/styles'; import FavoriteIcon from '@mui/icons-material/Favorite'; import NewReleasesIcon from '@mui/icons-material/NewReleases'; import FilterListIcon from '@mui/icons-material/FilterList'; import { TPartialManga, TranslationKey } from '@/typings'; import { requestManager, AbortableApolloUseMutationPaginatedResponse, SPECIAL_ED_SOURCES, } from '@/lib/requests/RequestManager.ts'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { SourceGridLayout } from '@/components/source/SourceGridLayout'; import { AppbarSearch } from '@/components/util/AppbarSearch'; import { SourceOptions } from '@/components/source/SourceOptions'; import { SourceMangaGrid } from '@/components/source/SourceMangaGrid'; import { GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables, } from '@/lib/graphql/generated/graphql.ts'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts'; import { useSessionStorage } from '@/util/useStorage.tsx'; import { AppStorage } from '@/util/AppStorage.ts'; import { getGridSnapshotKey } from '@/components/MangaGrid.tsx'; 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, } interface IPos { type: 'selectState' | 'textState' | 'checkBoxState' | 'triState' | 'sortState'; 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.SEARCH]: 'manga.error.label.no_matches', }; const getUniqueMangas = (mangas: TPartialManga[]): TPartialManga[] => { const mangaIdToManga: Record = {}; const uniqueMangas: TPartialManga[] = []; mangas.forEach((manga) => { const isDuplicate = !!mangaIdToManga[manga.id]; if (!isDuplicate) { mangaIdToManga[manga.id] = manga; uniqueMangas.push(manga); } }); return uniqueMangas; }; const useSourceManga = ( sourceId: string, contentType: SourceContentType, searchTerm: string | null | undefined, filters: IPos[], initialPages: number, hideLibraryEntries: boolean, ): [ AbortableApolloUseMutationPaginatedResponse[0], AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables >[1][number] & { filteredOutAllItemsOfFetchedPage: boolean }, ] => { let result: AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables >; 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.useSourceSearch( sourceId, searchTerm ?? '', filters.map((filter) => { const { position, state, group } = filter; const isPartOfGroup = group !== undefined; if (isPartOfGroup) { return { position: group, groupChange: { position, [filter.type]: state, }, }; } return { position, [filter.type]: state, }; }), initialPages, ); break; default: throw new Error(`Unknown ContentType "${contentType}"`); } const pages = result[1]!; const lastLoadedPageIndex = pages.findLastIndex((page) => !!page.data?.fetchSourceManga); const lastLoadedPage = pages[lastLoadedPageIndex]; const isPageLoading = pages.slice(-1)[0].isLoading; let filteredOutAllItemsOfFetchedPage = !isPageLoading; const items = useMemo(() => { type FetchItemsResult = GetSourceMangasFetchMutation['fetchSourceManga']['mangas']; let allItems: FetchItemsResult = []; pages.forEach((page, index) => { const pageItems = page.data?.fetchSourceManga.mangas ?? ([] as FetchItemsResult); const nonLibraryPageItems = pageItems.filter((item) => !hideLibraryEntries || !item.inLibrary); const uniqueItems = getUniqueMangas([...allItems, ...nonLibraryPageItems]); const isLastPage = !isPageLoading && pages.length === index + 1; filteredOutAllItemsOfFetchedPage = isLastPage && !nonLibraryPageItems.length && !!pageItems.length; allItems = uniqueItems; }); return allItems; }, [pages, hideLibraryEntries]); if (lastLoadedPageIndex === -1) { return [result[0], { ...result[1][result[1].length - 1], filteredOutAllItemsOfFetchedPage }]; } return [ result[0], { ...pages[pages.length - 1], data: { ...lastLoadedPage!.data, fetchSourceManga: { ...lastLoadedPage!.data!.fetchSourceManga, hasNextPage: pages.length > lastLoadedPageIndex + 1 ? false : lastLoadedPage!.data!.fetchSourceManga.hasNextPage, mangas: items, }, }, filteredOutAllItemsOfFetchedPage, }, ]; }; export function SourceMangas() { const { t } = useTranslation(); const { setTitle, setAction } = useContext(NavBarContext); const { sourceId } = useParams<{ sourceId: string }>(); const navigate = useNavigate(); const location = useLocation(); const { key: locationKey, state: locationState } = location; const { contentType: initialContentType = SourceContentType.POPULAR, clearCache = false } = useLocation<{ contentType: SourceContentType; clearCache: boolean; }>().state ?? {}; useSetDefaultBackTo('browse'); const [isFirstRender, setIsFirstRender] = useState(true); useEffect(() => { setIsFirstRender(false); }, []); const { settings: { hideLibraryEntries }, } = useMetadataServerSettings(); const { options } = useLibraryOptionsContext(); const [query] = useQueryParam('query', StringParam); const [currentFiltersToApply, setCurrentFiltersToApply] = useSessionStorage( `source-mangas-${sourceId}-filters`, [], ); const [filtersToApply, setLocationFiltersToApply] = useSessionStorage( `source-mangas-location-${locationKey}-${sourceId}-filters`, currentFiltersToApply ?? [], ); const [dialogFiltersToApply, setDialogFiltersToApply] = useState(filtersToApply); const [currentContentType, setCurrentContentType] = useSessionStorage( `source-mangas-${sourceId}-content-type`, initialContentType, ); const [contentType, setLocationContentType] = useSessionStorage( `source-mangas-location-${locationKey}-${sourceId}-content-type`, query ? SourceContentType.SEARCH : currentContentType!, ); useEffect( () => () => { setCurrentFiltersToApply(undefined); setCurrentContentType(undefined); }, [sourceId], ); const scrollToTop = useCallback(() => { AppStorage.session.setItem(getGridSnapshotKey(location), undefined, false); window.scrollTo(0, 0); }, [locationKey]); const setFiltersToApply = (filters: IPos[]) => { setCurrentFiltersToApply(filters); setLocationFiltersToApply(filters); scrollToTop(); }; const setContentType = (newContentType: SourceContentType) => { setCurrentContentType(newContentType); setLocationContentType(newContentType); }; const [loadPage, { data, isLoading: loading, size: lastPageNum, abortRequest, filteredOutAllItemsOfFetchedPage }] = useSourceManga(sourceId, contentType, query, filtersToApply, 1, hideLibraryEntries); const isLoading = loading || filteredOutAllItemsOfFetchedPage; const mangas = data?.fetchSourceManga.mangas ?? []; const hasNextPage = data?.fetchSourceManga.hasNextPage ?? false; const { data: sourceData } = requestManager.useGetSource(sourceId); const source = sourceData?.source; const filters = source?.filters ?? []; 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, newSearch?: string | null) => { setContentType(newContentType); scrollToTop(); if (query && !newSearch) { navigate( { pathname: '', }, { state: { ...locationState, contentType: newContentType }, }, ); } }, [setContentType, query, scrollToTop], ); const setSearchContentType = !!query && contentType !== SourceContentType.SEARCH; if (setSearchContentType) { updateContentType(SourceContentType.SEARCH, query); } const loadMore = useCallback(() => { if (!hasNextPage) { return; } loadPage(lastPageNum + 1); }, [lastPageNum, hasNextPage, contentType]); const resetFilters = useCallback(() => { setDialogFiltersToApply([]); setFiltersToApply([]); }, [sourceId, contentType]); useEffect(() => { if (filteredOutAllItemsOfFetchedPage && hasNextPage && !loading) { loadPage(lastPageNum + 1); } }, [filteredOutAllItemsOfFetchedPage, loading]); useEffect(() => { if (!clearCache) { return; } const requiresClear = SPECIAL_ED_SOURCES.REVALIDATION.includes(sourceId); if (!requiresClear) { return; } requestManager.clearBrowseCacheFor(sourceId); }, [clearCache]); useEffect( () => () => { if (contentType !== SourceContentType.SEARCH || isFirstRender) { 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`)); scrollToTop(); }, [query], ); 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" > )} , ); return () => { setTitle(''); setAction(null); }; }, [t, source]); return ( } onClick={() => updateContentType(SourceContentType.POPULAR)} > {t('global.button.popular')} {source?.supportsLatest === undefined || source.supportsLatest ? ( } onClick={() => updateContentType(SourceContentType.LATEST)} > {t('global.button.latest')} ) : null} } onClick={() => updateContentType(SourceContentType.SEARCH, query)} > {t('global.button.filter')} {contentType === SourceContentType.SEARCH && ( { setFiltersToApply(dialogFiltersToApply); }} resetFilterValue={resetFilters} update={dialogFiltersToApply} /> )} ); }