diff --git a/src/components/source/SourceMangaGrid.tsx b/src/components/source/SourceMangaGrid.tsx index 2b4f7b36..9b6cb7a8 100644 --- a/src/components/source/SourceMangaGrid.tsx +++ b/src/components/source/SourceMangaGrid.tsx @@ -7,10 +7,10 @@ */ import { useTranslation } from 'react-i18next'; -import { IMangaCard } from '@/typings'; import MangaGrid, { IMangaGridProps } from '@/components/MangaGrid'; +import { MangaType } from '@/lib/graphql/generated/graphql.ts'; -function filterManga(mangas: IMangaCard[]): IMangaCard[] { +function filterManga(mangas: MangaType[]): MangaType[] { return mangas; } diff --git a/src/lib/graphql/mutations/SourceMutation.ts b/src/lib/graphql/mutations/SourceMutation.ts index f8a8a1e3..c258d812 100644 --- a/src/lib/graphql/mutations/SourceMutation.ts +++ b/src/lib/graphql/mutations/SourceMutation.ts @@ -7,16 +7,16 @@ */ import gql from 'graphql-tag'; -import { FULL_MANGA_FIELDS, FULL_SOURCE_FIELDS } from '@/lib/graphql/Fragments'; +import { BASE_MANGA_FIELDS, FULL_SOURCE_FIELDS } from '@/lib/graphql/Fragments'; export const GET_SOURCE_MANGAS_FETCH = gql` - ${FULL_MANGA_FIELDS} + ${BASE_MANGA_FIELDS} mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) { fetchSourceManga(input: $input) { clientMutationId hasNextPage mangas { - ...FULL_MANGA_FIELDS + ...BASE_MANGA_FIELDS } } } diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 4bbdc403..46fa9421 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -21,6 +21,7 @@ import { useQuery, } from '@apollo/client'; import { OperationVariables } from '@apollo/client/core'; +import { useState } from 'react'; import { BackupValidationResult, ICategory, @@ -58,6 +59,9 @@ import { EnqueueChapterDownloadMutationVariables, EnqueueChapterDownloadsMutation, EnqueueChapterDownloadsMutationVariables, + FetchSourceMangaInput, + FetchSourceMangaType, + FilterChangeInput, GetAboutQuery, GetAboutQueryVariables, GetExtensionsFetchMutation, @@ -74,6 +78,8 @@ import { GetMangaFetchMutationVariables, GetMangaQuery, GetMangaQueryVariables, + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables, GetSourceQuery, GetSourceQueryVariables, GetSourcesQuery, @@ -214,6 +220,13 @@ type AbortableApolloUseMutationResponse[0], MutationTuple[1] & AbortableRequest, ]; +type AbortableApolloUseMutationPaginatedResponse< + Data = any, + Variables extends OperationVariables = OperationVariables, +> = [ + (page: number) => Promise>, + (MutationTuple[1] & AbortableRequest & { size: number; loadingMore: boolean })[], +]; type AbortableApolloMutationResponse = { response: Promise> } & AbortableRequest; const isLoadingMore = (swrResult: SWRInfiniteResponse): boolean => { @@ -625,6 +638,99 @@ export class RequestManager { return this.doRequestNew(GQLMethod.USE_QUERY, GET_SOURCE, { id }, options); } + public useGetSourceMangas( + input: FetchSourceMangaInput, + options?: MutationHookOptions, + ): AbortableApolloUseMutationPaginatedResponse< + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables + > { + const createPaginatedResult = ( + result: AbortableApolloUseMutationResponse[1], + page: number, + ): AbortableApolloUseMutationPaginatedResponse[1][number] => { + const loading = result.loading || !result.called; + return { + ...result, + loading, + size: page, + loadingMore: loading && page > 1, + }; + }; + + // TODO - implement caching + // - ? global cache with revalidating (same as SWR does, revalidate each page starting with 1st until the first page is reached whose data didn't change) + // - ? saving fetched mangas in location state and only "cache" when navigating prev/next + const [mutate, result] = this.doRequestNew( + GQLMethod.USE_MUTATION, + GET_SOURCE_MANGAS_FETCH, + { input }, + options, + ); + + const [previousResults, setPreviousResults] = useState([ + createPaginatedResult(result, input.page), + ]); + + const [contentType, setContentType] = useState(input.type); + const [query, setQuery] = useState(input.query); + const [page, setPage] = useState(input.page); + + const paginatedResult = createPaginatedResult(result, page); + + // TODO - option "global cache with revalidating" + // replace previousResults with cache + // cache specific response + // cache "base" key to specific page keys to be able to retrieve all necessary cached pages + // get cached results + // revalidate in background - revalidate first page -> result changed? revalidate every page until cached result and response is the same + + // wrap "mutate" function to align with the expected type, which allows only passing a "page" argument + const wrappedMutate = (newPage: number) => { + const resetPreviousResultForInitialLoad = newPage < page; + if (resetPreviousResultForInitialLoad) { + setPreviousResults(previousResults.filter((prevResult) => prevResult.size <= newPage)); + } + + if (newPage !== page) { + setPage(newPage); + } + + return mutate({ + variables: { + input: { + ...input, + page: newPage, + }, + }, + }); + }; + + const contentTypeChanged = contentType !== input.type; + const queryChanged = query !== input.query; + // instantly return empty results in case the provided variables changed - wait until the hook returns empty data, + // otherwise, updating the previous results will revert the reset + const resetPreviousResult = (queryChanged || contentTypeChanged) && !paginatedResult.data; + let updatedResults = [ + ...(resetPreviousResult ? [{ ...paginatedResult, size: page, loadingMore: false }] : previousResults), + ]; + + if (resetPreviousResult) { + setContentType(input.type); + setQuery(input.query); + setPreviousResults([paginatedResult]); + } + + const resultChanged = previousResults[page - 1]?.loading !== paginatedResult.loading; + const updatePreviousResult = resultChanged && !resetPreviousResult; + if (updatePreviousResult) { + updatedResults = [...previousResults.slice(0, page - 1), paginatedResult]; + setPreviousResults(updatedResults); + } + + return [wrappedMutate, updatedResult]; + } + public useGetSourcePopularMangas( sourceId: string, initialPages?: number, @@ -683,21 +789,15 @@ export class RequestManager { } public useSourceSearch( - sourceId: string, - searchTerm: string, - initialPages?: number, - swrOptions?: SWRInfiniteOptions, - ): AbortableSWRInfiniteResponse { - return this.doRequest(HttpMethod.SWR_GET_INFINITE, '', { - swrOptions: { - getEndpoint: (page, previousData) => - previousData?.hasNextPage ?? true - ? `source/${sourceId}/search?searchTerm=${searchTerm}&pageNum=${page + 1}` - : null, - initialSize: initialPages, - ...swrOptions, - } as typeof swrOptions, - }); + source: string, + query: string, + filters?: FilterChangeInput[], + options?: MutationHookOptions, + ): AbortableApolloUseMutationPaginatedResponse< + GetSourceMangasFetchMutation, + GetSourceMangasFetchMutationVariables + > { + return this.useGetSourceMangas({ type: FetchSourceMangaType.Search, source, query, filters, page: 1 }, options); } public useSourceQuickSearch( diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx index 1a337a30..1cc6bbd0 100644 --- a/src/screens/SearchAll.tsx +++ b/src/screens/SearchAll.tsx @@ -21,6 +21,7 @@ import LangSelect from '@/components/navbar/action/LangSelect'; import MangaGrid from '@/components/MangaGrid'; import NavbarContext from '@/components/context/NavbarContext'; import { useDebounce } from '@/components/manga/hooks'; +import { MangaType } from '@/lib/graphql/generated/graphql.ts'; type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean }; type SourceToLoadingStateMap = Map; @@ -99,15 +100,17 @@ const SourceSearchPreview = React.memo( const skipRequest = !searchString; const { id, displayName, lang } = source; - const { - data: searchResult, - isLoading, - error, - abortRequest, - } = requestManager.useSourceQuickSearch(id, searchString ?? '', [], 1, { skipRequest }); - const mangas = !isLoading ? searchResult?.[0]?.mangaList ?? [] : []; + const [loadPage, results] = requestManager.useSourceSearch(id, searchString ?? '', []); + const { data: searchResult, loading: isLoading, error, abortRequest } = results[0]!; + const mangas = (searchResult?.fetchSourceManga.mangas as MangaType[]) ?? []; const noMangasFound = !isLoading && !mangas.length; + useEffect(() => { + if (!skipRequest) { + loadPage(1); + } + }, [skipRequest, searchString]); + useEffect(() => { onSearchRequestFinished(source, isLoading, !noMangasFound, !searchString); }, [isLoading, noMangasFound, searchString]); diff --git a/src/typings.ts b/src/typings.ts index 6efc54d2..fc08e7ae 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -12,6 +12,14 @@ import { ParseKeys } from 'i18next'; import { Location } from 'react-router-dom'; import { ExtensionType, MangaType, MetaType } from '@/lib/graphql/generated/graphql.ts'; +export type RecursivePartial = { + [P in keyof T]?: T[P] extends (infer U)[] + ? RecursivePartial[] + : T[P] extends object | undefined + ? RecursivePartial + : T[P]; +}; + type GenericLocation = Omit & { state?: State }; declare module 'react-router-dom' {