/* * 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, useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams } 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 { 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 { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx'; import { requestManager, AbortableApolloUseMutationPaginatedResponse, SPECIAL_ED_SOURCES, } from '@/lib/requests/RequestManager.ts'; import { SourceGridLayout } from '@/modules/source/components/SourceGridLayout.tsx'; import { AppbarSearch } from '@/modules/core/components/AppbarSearch.tsx'; import { SourceOptions } from '@/modules/source/components/SourceOptions.tsx'; import { BaseMangaGrid } from '@/modules/manga/components/BaseMangaGrid.tsx'; import { GetSourceBrowseQuery, GetSourceBrowseQueryVariables, GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables, } from '@/lib/graphql/generated/graphql.ts'; import { updateMetadataServerSettings, useMetadataServerSettings, } from '@/modules/settings/services/ServerSettingsMetadata.ts'; import { useLocalStorage, useSessionStorage } from '@/modules/core/hooks/useStorage.tsx'; import { MANGA_GRID_SNAPSHOT_KEY } from '@/modules/manga/components/MangaGrid.tsx'; import { createUpdateSourceMetadata, useGetSourceMetadata } from '@/modules/source/services/SourceMetadata.ts'; import { makeToast } from '@/modules/core/utils/Toast.ts'; import { GET_SOURCE_BROWSE } from '@/lib/graphql/queries/SourceQuery.ts'; import { TranslationKey } from '@/Base.types.ts'; import { IPos, SourceIdInfo } from '@/modules/source/Source.types.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { EmptyView } from '@/modules/core/components/feedback/EmptyView.tsx'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx'; import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; import { GridLayout, SearchParam } from '@/modules/core/Core.types.ts'; import { AppRoutes } from '@/modules/core/AppRoute.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { Sources } from '@/modules/source/services/Sources.ts'; import { useAppTitleAndAction } from '@/modules/navigation-bar/hooks/useAppTitleAndAction.ts'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx'; const DEFAULT_SOURCE: SourceIdInfo = { id: '-1' }; const ContentTypeMenu = styled('div')(({ theme }) => ({ display: 'flex', position: 'sticky', width: '100%', zIndex: 1, padding: theme.spacing(1), gap: theme.spacing(1), backgroundColor: theme.palette.background.default, })); const ContentTypeButton = styled(Button)(() => ({})); const StyledGridWrapper = styled(Box)(() => ({ minHeight: '100%', position: 'relative', })); export enum SourceContentType { POPULAR, LATEST, SEARCH, } 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: Manga[]): Manga[] => { const mangaIdToManga: Record = {}; const uniqueMangas: Manga[] = []; 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 = NonNullable['mangas']; let allItems: FetchItemsResult = []; pages.forEach((page, index) => { const pageItems = page.data?.fetchSourceManga?.mangas ?? []; 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 { appBarHeight } = useNavBarContext(); const { sourceId } = useParams<{ sourceId: string }>(); const [searchParams, setSearchParams] = useSearchParams(); 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 ?? {}; const { settings: { hideLibraryEntries }, } = useMetadataServerSettings(); const [sourceGridLayout] = useLocalStorage('source-grid-layout', GridLayout.Compact); const [query] = useQueryParam(SearchParam.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!, ); const { key: persistedGridStateKey, deleteState: deletePersistedGridState } = VirtuosoUtil.usePersistState(MANGA_GRID_SNAPSHOT_KEY); const scrollToTop = useCallback(() => { deletePersistedGridState(); window.scrollTo(0, 0); }, [persistedGridStateKey]); const currentQuery = useRef(query); const currentAbortRequest = useRef<(reason: any) => void>(() => {}); const didSearchChange = currentQuery.current !== query; if (didSearchChange && contentType === SourceContentType.SEARCH) { currentQuery.current = query; currentAbortRequest.current(new Error(`SourceMangas(${sourceId}): search string changed`)); scrollToTop(); } useEffect( () => () => { setCurrentFiltersToApply(undefined); setCurrentContentType(undefined); }, [sourceId], ); const setFiltersToApply = (filters: IPos[]) => { setCurrentFiltersToApply(filters); setLocationFiltersToApply(filters); scrollToTop(); }; const setContentType = (newContentType: SourceContentType) => { setCurrentContentType(newContentType); setLocationContentType(newContentType); }; const gridKey = `${contentType}${JSON.stringify(filtersToApply)}${query}`; const [ loadPage, { data, error, isLoading: loading, size: lastPageNum, abortRequest, filteredOutAllItemsOfFetchedPage }, ] = useSourceManga(sourceId, contentType, query, filtersToApply, 1, hideLibraryEntries); currentAbortRequest.current = abortRequest; const isLoading = loading || filteredOutAllItemsOfFetchedPage; const mangas = data?.fetchSourceManga?.mangas ?? []; const hasNextPage = !!data?.fetchSourceManga?.hasNextPage; const { data: sourceData } = requestManager.useGetSource( GET_SOURCE_BROWSE, sourceId, ); const source = sourceData?.source; const filters = source?.filters ?? []; const { savedSearches = {} } = useGetSourceMetadata(source ?? DEFAULT_SOURCE); const updateSourceMetadata = createUpdateSourceMetadata<'savedSearches'>(source ?? { id: '-1' }, (e) => makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)), ); const selectSavedSearch = useCallback( (savedSearch: string) => { const { query: savedSearchQuery, filters: savedSearchFilters } = savedSearches[savedSearch]; if (savedSearchFilters) { setDialogFiltersToApply(savedSearchFilters); setFiltersToApply(savedSearchFilters); } if (savedSearchQuery) { searchParams.set(SearchParam.QUERY, savedSearchQuery); setSearchParams(searchParams); } }, [savedSearches, locationState], ); const handleSavedSearchesUpdate = useCallback( (savedSearch: string, updateType: 'create' | 'delete') => { if (updateType === 'delete') { const savedSearchesCopy = { ...savedSearches }; delete savedSearchesCopy[savedSearch]; updateSourceMetadata('savedSearches', savedSearchesCopy); return; } const updatedSavedSearches = { ...savedSearches, [savedSearch]: { query: query ?? undefined, filters: dialogFiltersToApply }, }; updateSourceMetadata('savedSearches', updatedSavedSearches); }, [savedSearches, query, dialogFiltersToApply], ); const message = !isLoading ? t(SOURCE_CONTENT_TYPE_TO_ERROR_MSG_KEY[contentType]) : undefined; const isLocalSource = sourceId === Sources.LOCAL_SOURCE_ID; 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 = () => { setDialogFiltersToApply([]); setFiltersToApply([]); }; useEffect(() => { updateMetadataServerSettings('lastUsedSourceId', sourceId).catch( defaultPromiseErrorHandler('SourceMangas::setLastUsedSourceId'), ); }, [sourceId]); 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]); useAppTitleAndAction( source?.displayName ?? t('source.title_one'), <> {source?.isConfigurable && ( navigate(AppRoutes.sources.childRoutes.configure.path(sourceId))} aria-label="display more actions" edge="end" color="inherit" > )} , [source], ); const EmptyViewComponent = mangas.length ? EmptyView : EmptyViewAbsoluteCentered; 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')} {(isLoading || !error || (!!error && !!mangas.length)) && ( )} {error && ( loadPage(lastPageNum).catch(defaultPromiseErrorHandler('SourceMangas::refetch'))} /> )} {contentType === SourceContentType.SEARCH && ( { setFiltersToApply(dialogFiltersToApply); }} resetFilterValue={resetFilters} update={dialogFiltersToApply} /> )} ); }