diff --git a/public/locales/en.json b/public/locales/en.json index 8c190209..db5a3f22 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1522,5 +1522,13 @@ } }, "title": "Updates" + }, + "history": { + "error": { + "label": { + "no_history_available": "You have not read any series yet." + } + }, + "title": "History" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index ee656f53..9b8487f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ const { CategorySettings } = loadable( const { SourceConfigure } = loadable(() => import('@/modules/source/screens/SourceConfigure.tsx'), lazyLoadFallback); const { SourceMangas } = loadable(() => import('@/modules/source/screens/SourceMangas.tsx'), lazyLoadFallback); const { Updates } = loadable(() => import('@/modules/updates/screens/Updates.tsx'), lazyLoadFallback); +const { History } = loadable(() => import('@/modules/history/screens/History.tsx'), lazyLoadFallback); const { LibrarySettings } = loadable(() => import('@/modules/library/screens/LibrarySettings.tsx'), lazyLoadFallback); const { DownloadSettings } = loadable( () => import('@/modules/downloads/screens/DownloadSettings.tsx'), @@ -158,6 +159,7 @@ const MainApp = () => { } /> } /> + } /> } /> } /> diff --git a/src/lib/graphql/fragments/ChapterFragments.ts b/src/lib/graphql/fragments/ChapterFragments.ts index b6886b3c..73351828 100644 --- a/src/lib/graphql/fragments/ChapterFragments.ts +++ b/src/lib/graphql/fragments/ChapterFragments.ts @@ -64,6 +64,7 @@ export const CHAPTER_LIST_FIELDS = gql` fetchedAt uploadDate + lastReadAt } `; @@ -79,3 +80,16 @@ export const CHAPTER_UPDATE_LIST_FIELDS = gql` } } `; + +export const CHAPTER_HISTORY_LIST_FIELDS = gql` + ${CHAPTER_LIST_FIELDS} + ${MANGA_BASE_FIELDS} + + fragment CHAPTER_HISTORY_LIST_FIELDS on ChapterType { + ...CHAPTER_LIST_FIELDS + + manga { + ...MANGA_BASE_FIELDS + } + } +`; diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index 545ad400..0b8b82be 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -2773,6 +2773,8 @@ export type ChapterListFieldsFragment = { __typename?: 'ChapterType', fetchedAt: export type ChapterUpdateListFieldsFragment = { __typename?: 'ChapterType', fetchedAt: string, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } }; +export type ChapterHistoryListFieldsFragment = { __typename?: 'ChapterType', lastReadAt: string, lastPageRead: number, pageCount: number, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } }; + export type DownloadTypeFieldsFragment = { __typename?: 'DownloadType', progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } }; export type DownloadStatusFieldsFragment = { __typename?: 'DownloadStatus', state: DownloaderState, queue: Array<{ __typename?: 'DownloadType', progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } }> }; @@ -3376,9 +3378,21 @@ export type GetChaptersUpdatesQueryVariables = Exact<{ order?: InputMaybe | ChapterOrderInput>; }>; - export type GetChaptersUpdatesQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', fetchedAt: string, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } }>, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null } } }; +export type GetChaptersHistoryQueryVariables = Exact<{ + after?: InputMaybe; + before?: InputMaybe; + condition?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + offset?: InputMaybe; + order?: InputMaybe | ChapterOrderInput>; +}>; + +export type GetChaptersHistoryQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', lastReadAt: string, lastPageRead: number, pageCount: number, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } }>, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null } } }; + export type GetMangasChapterIdsWithStateQueryVariables = Exact<{ mangaIds: Array | Scalars['Int']['input']; isDownloaded?: InputMaybe; diff --git a/src/lib/graphql/queries/ChapterQuery.ts b/src/lib/graphql/queries/ChapterQuery.ts index e04da0a4..1c844a79 100644 --- a/src/lib/graphql/queries/ChapterQuery.ts +++ b/src/lib/graphql/queries/ChapterQuery.ts @@ -13,6 +13,7 @@ import { CHAPTER_READER_FIELDS, CHAPTER_STATE_FIELDS, CHAPTER_UPDATE_LIST_FIELDS, + CHAPTER_HISTORY_LIST_FIELDS, } from '@/lib/graphql/fragments/ChapterFragments.ts'; // returns the current chapters from the database @@ -123,6 +124,42 @@ export const GET_CHAPTERS_UPDATES = gql` } `; +// returns the current chapters from the database +export const GET_CHAPTERS_HISTORY = gql` + ${CHAPTER_HISTORY_LIST_FIELDS} + ${PAGE_INFO} + + query GET_CHAPTERS_HISTORY( + $after: Cursor + $before: Cursor + $condition: ChapterConditionInput + $filter: ChapterFilterInput + $first: Int + $last: Int + $offset: Int + $order: [ChapterOrderInput!] + ) { + chapters( + after: $after + before: $before + condition: $condition + filter: $filter + first: $first + last: $last + offset: $offset + order: $order + ) { + nodes { + ...CHAPTER_HISTORY_LIST_FIELDS + } + pageInfo { + ...PAGE_INFO + } + totalCount + } + } +`; + export const GET_MANGAS_CHAPTER_IDS_WITH_STATE = gql` ${CHAPTER_STATE_FIELDS} diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index d5947a76..0fb81eb7 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -87,6 +87,8 @@ import { GetChaptersMangaQueryVariables, GetChaptersUpdatesQuery, GetChaptersUpdatesQueryVariables, + GetChaptersHistoryQuery, + GetChaptersHistoryQueryVariables, GetDownloadStatusQuery, GetDownloadStatusQueryVariables, GetExtensionsFetchMutation, @@ -267,6 +269,7 @@ import { STOP_DOWNLOADER, } from '@/lib/graphql/mutations/DownloaderMutation.ts'; import { + GET_CHAPTERS_HISTORY, GET_CHAPTERS_MANGA, GET_CHAPTERS_UPDATES, GET_MANGAS_CHAPTER_IDS_WITH_STATE, @@ -2886,6 +2889,42 @@ export class RequestManager { } as typeof result; } + public useGetRecentlyReadChapters( + initialPages: number = 1, + options?: QueryHookOptions, + ): AbortableApolloUseQueryResponse { + const PAGE_SIZE = 50; + const CACHE_KEY = 'useGetRecentlyReadChapters'; + + const offset = this.cache.getResponseFor(CACHE_KEY, undefined) ?? 0; + const [lastOffset] = useState(offset); + + const result = this.useGetChapters( + GET_CHAPTERS_HISTORY, + { + filter: { lastReadAt: { isNull: false, notEqualToAll: ['0'] } }, + order: [ + { by: ChapterOrderBy.LastReadAt, byType: SortOrder.Desc }, + { by: ChapterOrderBy.SourceOrder, byType: SortOrder.Desc }, + ], + first: initialPages * PAGE_SIZE + lastOffset, + }, + options, + ); + + return { + ...result, + fetchMore: (...args: Parameters<(typeof result)['fetchMore']>) => { + const fetchMoreOptions = args[0] ?? {}; + this.cache.cacheResponse(CACHE_KEY, undefined, fetchMoreOptions.variables?.offset); + return result.fetchMore({ + ...fetchMoreOptions, + variables: { first: PAGE_SIZE, ...fetchMoreOptions.variables }, + }); + }, + } as typeof result; + } + public startGlobalUpdate( categories?: undefined, options?: MutationOptions, diff --git a/src/modules/core/AppRoute.constants.ts b/src/modules/core/AppRoute.constants.ts index 3964b692..5767d8b1 100644 --- a/src/modules/core/AppRoute.constants.ts +++ b/src/modules/core/AppRoute.constants.ts @@ -129,6 +129,14 @@ export const AppRoutes = { match: 'updates', path: '/updates', }, + history: { + match: 'history', + path: '/history', + }, + recent: { + match: 'recent', + path: '/recent', + }, browse: { match: 'browse', path: '/browse', diff --git a/src/modules/history/components/ChapterHistoryCard.tsx b/src/modules/history/components/ChapterHistoryCard.tsx new file mode 100644 index 00000000..2f593dee --- /dev/null +++ b/src/modules/history/components/ChapterHistoryCard.tsx @@ -0,0 +1,148 @@ +/* + * 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 DownloadIcon from '@mui/icons-material/Download'; +import Box from '@mui/material/Box'; +import CardActionArea from '@mui/material/CardActionArea'; +import Avatar from '@mui/material/Avatar'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import IconButton from '@mui/material/IconButton'; +import { Link } from 'react-router-dom'; +import Refresh from '@mui/icons-material/Refresh'; +import { useTranslation } from 'react-i18next'; +import { memo } from 'react'; +import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx'; +import { DownloadStateIndicator } from '@/modules/core/components/DownloadStateIndicator.tsx'; +import { ChapterHistoryListFieldsFragment, DownloadState } from '@/lib/graphql/generated/graphql.ts'; +import { Mangas } from '@/modules/manga/services/Mangas.ts'; +import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx'; +import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx'; +import { AppRoutes } from '@/modules/core/AppRoute.constants.ts'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { makeToast } from '@/modules/core/utils/Toast.ts'; +import { getErrorMessage } from '@/lib/HelperFunctions.ts'; +import { Chapters } from '@/modules/chapter/services/Chapters.ts'; + +export const ChapterHistoryCard = memo(({ chapter }: { chapter: ChapterHistoryListFieldsFragment }) => { + const { manga } = chapter; + const download = Chapters.useDownloadStatusFromCache(chapter.id); + + const { t } = useTranslation(); + + const handleRetry = async () => { + try { + await requestManager.addChapterToDownloadQueue(chapter.id).response; + } catch (e) { + makeToast(t('download.queue.error.label.failed_to_remove'), 'error', getErrorMessage(e)); + } + }; + + const downloadChapter = () => { + requestManager + .addChapterToDownloadQueue(chapter.id) + .response.catch((e) => + makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)), + ); + }; + + return ( + + theme.palette.text[chapter.isRead ? 'disabled' : 'primary'], + }} + > + + + + + + + + + + {manga.title} + + + {chapter.name} + + + + + {download?.state === DownloadState.Error && ( + + { + e.preventDefault(); + e.stopPropagation(); + handleRetry(); + }} + size="large" + > + + + + )} + {download == null && !chapter.isDownloaded && ( + + { + e.stopPropagation(); + e.preventDefault(); + downloadChapter(); + }} + size="large" + > + + + + )} + + + + ); +}); diff --git a/src/modules/history/screens/History.tsx b/src/modules/history/screens/History.tsx new file mode 100644 index 00000000..a0dcb6cd --- /dev/null +++ b/src/modules/history/screens/History.tsx @@ -0,0 +1,123 @@ +/* + * 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 Typography from '@mui/material/Typography'; +import React, { useCallback, useLayoutEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { requestManager } from '@/lib/requests/RequestManager.ts'; +import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; +import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; +import { ChapterType } from '@/lib/graphql/generated/graphql.ts'; +import { StyledGroupedVirtuoso } from '@/modules/core/components/virtuoso/StyledGroupedVirtuoso.tsx'; +import { StyledGroupHeader } from '@/modules/core/components/virtuoso/StyledGroupHeader.tsx'; +import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx'; +import { epochToDate, getDateString } from '@/util/DateHelper.ts'; +import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; +import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx'; +import { getErrorMessage } from '@/lib/HelperFunctions.ts'; +import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; +import { ChapterHistoryCard } from '@/modules/history/components/ChapterHistoryCard.tsx'; + +const groupByDate = (histories: Pick[]): [date: string, items: number][] => { + if (!histories.length) { + return []; + } + + const dateToItemMap = new Map(); + histories.forEach((item) => { + const date = getDateString(epochToDate(Number(item.lastReadAt))); + dateToItemMap.set(date, (dateToItemMap.get(date) ?? 0) + 1); + }); + + return [...dateToItemMap.entries()]; +}; + +export const History: React.FC = () => { + const { t } = useTranslation(); + + const { setTitle } = useNavBarContext(); + const { + data: chapterHistoryData, + loading: isLoading, + error, + fetchMore, + refetch, + } = requestManager.useGetRecentlyReadChapters(undefined, { + fetchPolicy: 'cache-and-network', + notifyOnNetworkStatusChange: true, + }); + const hasNextPage = !!chapterHistoryData?.chapters.pageInfo.hasNextPage; + const endCursor = chapterHistoryData?.chapters.pageInfo.endCursor; + const readEntries = chapterHistoryData?.chapters.nodes ?? []; + const groupedHistory = useMemo(() => groupByDate(readEntries), [readEntries]); + const groupCounts: number[] = useMemo(() => groupedHistory.map((group) => group[1]), [groupedHistory]); + + const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey( + groupCounts, + useCallback((index) => groupedHistory[index][0], [groupedHistory]), + useCallback((index) => readEntries[index].id, [readEntries]), + ); + + useLayoutEffect(() => { + setTitle(t('history.title')); + + return () => { + setTitle(''); + }; + }, [t]); + + const loadMore = useCallback(() => { + if (!hasNextPage) { + return; + } + + fetchMore({ variables: { offset: readEntries.length } }); + }, [hasNextPage, endCursor]); + + if (error) { + return ( + refetch().catch(defaultPromiseErrorHandler('History::refetch'))} + /> + ); + } + + if (!isLoading && readEntries.length === 0) { + return ; + } + + return ( + (isLoading ? : null), + }} + overscan={window.innerHeight * 0.5} + endReached={loadMore} + groupCounts={groupCounts} + groupContent={(index) => ( + + + {groupedHistory[index][0]} + + + )} + computeItemKey={computeItemKey} + itemContent={(index) => ( + + + + )} + /> + ); +}; diff --git a/src/modules/navigation-bar/components/DefaultNavBar.tsx b/src/modules/navigation-bar/components/DefaultNavBar.tsx index ae7dd84c..f1c3d6d1 100644 --- a/src/modules/navigation-bar/components/DefaultNavBar.tsx +++ b/src/modules/navigation-bar/components/DefaultNavBar.tsx @@ -15,6 +15,8 @@ import CollectionsBookmarkIcon from '@mui/icons-material/CollectionsBookmark'; import CollectionsOutlinedBookmarkIcon from '@mui/icons-material/CollectionsBookmarkOutlined'; import NewReleasesIcon from '@mui/icons-material/NewReleases'; import NewReleasesOutlinedIcon from '@mui/icons-material/NewReleasesOutlined'; +import HistoryIcon from '@mui/icons-material/History'; +import HistoryOutlinedIcon from '@mui/icons-material/HistoryOutlined'; import ExploreIcon from '@mui/icons-material/Explore'; import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined'; import GetAppIcon from '@mui/icons-material/GetApp'; @@ -51,6 +53,13 @@ const navbarItems: Array = [ IconComponent: NewReleasesOutlinedIcon, show: 'both', }, + { + path: AppRoutes.history.path, + title: 'history.title', + SelectedIconComponent: HistoryIcon, + IconComponent: HistoryOutlinedIcon, + show: 'both', + }, { path: AppRoutes.browse.path, title: 'global.label.browse',