diff --git a/package.json b/package.json index c333aaa3ea..42967ed988 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "ci": "yarn install --frozen-lockfile", - "dev": "vite", + "dev": "vite --host", "preview": "vite preview", "build": "vite build", "test": "node -e \"console.log('imagine')\"", diff --git a/src/components/chapter/ChapterActionMenuItems.tsx b/src/components/chapter/ChapterActionMenuItems.tsx index ef50418a4d..708cf1266e 100644 --- a/src/components/chapter/ChapterActionMenuItems.tsx +++ b/src/components/chapter/ChapterActionMenuItems.tsx @@ -34,6 +34,7 @@ import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts'; import { createGetMenuItemTitle, createIsMenuItemDisabled, createShouldShowMenuItem } from '@/components/menu/util.ts'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts'; +import { getPreviousChapters } from '@/components/chapter/util'; type BaseProps = { onClose: () => void }; @@ -118,15 +119,7 @@ export const ChapterActionMenuItems = ({ if (!isMarkPrevAsRead) { return [chapter]; } - - const index = allChapters.findIndex(({ id: chapterId }) => chapterId === chapter.id); - - const isFirstChapter = index + 1 > allChapters.length - 1; - if (isFirstChapter) { - return []; - } - - return allChapters.slice(index + 1); + return getPreviousChapters(chapter.id, allChapters); }; const chapters = getChapters(); diff --git a/src/components/chapter/ChapterList.tsx b/src/components/chapter/ChapterList.tsx index b7f0bbe718..f72673158f 100644 --- a/src/components/chapter/ChapterList.tsx +++ b/src/components/chapter/ChapterList.tsx @@ -20,7 +20,6 @@ import { ChapterCard } from '@/components/chapter/ChapterCard.tsx'; import { ResumeFab } from '@/components/manga/ResumeFAB.tsx'; import { filterAndSortChapters, useChapterOptions } from '@/components/chapter/util.tsx'; import { EmptyView } from '@/components/util/EmptyView.tsx'; -import { ChaptersToolbarMenu } from '@/components/chapter/ChaptersToolbarMenu.tsx'; import { SelectionFAB } from '@/components/collection/SelectionFAB.tsx'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx'; import { DownloadType } from '@/lib/graphql/generated/graphql.ts'; @@ -30,6 +29,7 @@ import { Chapters } from '@/lib/data/Chapters.ts'; import { ChaptersWithMeta } from '@/lib/data/ChaptersWithMeta.ts'; import { ChapterActionMenuItems } from '@/components/chapter/ChapterActionMenuItems.tsx'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; +import { ChaptersToolbarMenu } from '@/components/chapter/ChaptersToolbarMenu'; const ChapterListHeader = styled(Stack)(({ theme }) => ({ margin: 8, diff --git a/src/components/chapter/util.tsx b/src/components/chapter/util.tsx index d883baf9d4..2aceed413e 100644 --- a/src/components/chapter/util.tsx +++ b/src/components/chapter/util.tsx @@ -16,6 +16,9 @@ import { TranslationKey, } from '@/typings.ts'; import { useReducerLocalStorage } from '@/util/useStorage.tsx'; +import { getPartialList } from '@/components/util/getPartialList'; +import { Chapters } from '@/lib/data/Chapters.ts'; +import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler'; const defaultChapterOptions: ChapterListOptions = { active: false, @@ -114,3 +117,53 @@ export const isFilterActive = (options: ChapterListOptions) => { const { unread, downloaded, bookmarked } = options; return unread != null || downloaded != null || bookmarked != null; }; + +/** + * @param chapterId The id of the chapter to be use as a pivot + * @param allChapters There list of chapters + * @param includePivotChapter Whether to return the chapter with the passed chapterId in the list + * @returns The second half of the list. By the default the chapters are sorted + * in descending order, so it returns the previous chapters, not including the pivot chapter. + */ +export const getPreviousChapters = ( + chapterId: TChapter['id'], + allChapters: TChapter[], + includePivotChapter: boolean = false, +): TChapter[] => { + if (includePivotChapter) { + return getPartialList(chapterId, allChapters, 'second', 0); + } + return getPartialList(chapterId, allChapters, 'second'); +}; + +/** + * @param chapterId The id of the chapter to be use as a pivot + * @param allChapters There list of chapters + * @param includePivotChapter Whether to return the chapter with the passed chapterId in the list + * @returns The second half of the list. By the default the chapters are sorted + * in descending order, so it returns the previous chapters, not including the pivot chapter. + */ +export const getNextChapters = ( + chapterId: TChapter['id'], + allChapters: TChapter[], + includePivotChapter: boolean = false, +): TChapter[] => { + if (includePivotChapter) { + return getPartialList(chapterId, allChapters, 'first'); + } + return getPartialList(chapterId, allChapters, 'first', 0); +}; + +/** + * @description This fucntion takes a chapter Id and set all chapters with index bellow the index of the chapter to that id + * to read, and the rest of the chapters as unread. Technically setting the chapter with the passed id as the current unread chapter. + * @param chapterId Chapter Id + * @param allChapters List of chapters + */ +export const setChapterAsLastRead = (chapterId: TChapter['id'], allChapters: TChapter[]) => { + const readChapters = getPreviousChapters(chapterId, allChapters, true); + const unreadChapterId = getNextChapters(chapterId, allChapters).map((chapter) => chapter.id); + + Chapters.markAsRead(readChapters, true).catch(defaultPromiseErrorHandler('ChapterActionMenuItems::performAction')); + Chapters.markAsUnread(unreadChapterId).catch(defaultPromiseErrorHandler('ChapterActionMenuItems::performAction')); +}; diff --git a/src/components/manga/MangaDetails.tsx b/src/components/manga/MangaDetails.tsx index d5952eb18d..734a55c8cb 100644 --- a/src/components/manga/MangaDetails.tsx +++ b/src/components/manga/MangaDetails.tsx @@ -37,9 +37,6 @@ const DetailsWrapper = styled('div')(({ theme }) => ({ const TopContentWrapper = styled('div')(() => ({ padding: '10px', - // [theme.breakpoints.up('md')]: { - // minWidth: '50%', - // }, })); const ThumbnailMetadataWrapper = styled('div')(() => ({ @@ -54,9 +51,6 @@ const Thumbnail = styled('div')(() => ({ height: 'auto', }, maxWidth: '50%', - // [theme.breakpoints.up('md')]: { - // minWidth: '100px', - // }, })); const Metadata = styled('div')(({ theme }) => ({ @@ -88,7 +82,6 @@ const BottomContentWrapper = styled('div')(({ theme }) => ({ paddingRight: '10px', [theme.breakpoints.up('md')]: { fontSize: '1.2em', - // maxWidth: '50%', }, [theme.breakpoints.up('lg')]: { fontSize: '1.3em', diff --git a/src/components/manga/TrackMangaButton.tsx b/src/components/manga/TrackMangaButton.tsx index 1615aa4eef..45d89bd7c3 100644 --- a/src/components/manga/TrackMangaButton.tsx +++ b/src/components/manga/TrackMangaButton.tsx @@ -16,8 +16,9 @@ import { requestManager } from '@/lib/requests/RequestManager.ts'; import { makeToast } from '@/components/util/Toast.tsx'; import { TrackManga } from '@/components/tracker/TrackManga.tsx'; import { Trackers } from '@/lib/data/Trackers.ts'; -import { TManga } from '@/typings.ts'; +import { TChapter, TManga } from '@/typings.ts'; import { CustomIconButton } from '@/components/atoms/CustomIconButton.tsx'; +import { setChapterAsLastRead } from '@/components/chapter/util.tsx'; export const TrackMangaButton = ({ manga }: { manga: TManga }) => { const { t } = useTranslation(); @@ -28,8 +29,67 @@ export const TrackMangaButton = ({ manga }: { manga: TManga }) => { const loggedInTrackers = Trackers.getLoggedIn(trackerList.data?.trackers.nodes ?? []); const trackersInUse = Trackers.getLoggedIn(Trackers.getTrackers(mangaTrackers)); + const mangaChaptersQuery = requestManager.useGetMangaChapters(manga.id, {}); - const handleClick = (openPopup: () => void) => { + /** + * @description This function fetch the last read for the loged in trackers. + */ + const refreshTracker = () => + mangaTrackers.map( + async (trackRecord) => + (await requestManager.fetchTrackBind(trackRecord.id).response).data?.fetchTrack.trackRecord + .lastChapterRead, + ); + + /** + * @description This function update the tracker reads, and set the local read to the higher chapter read if the local source is lower. + * It will set all chapters part to read based on the tracker read, so if you have read chapter 100; parts 100.1 100.5 and 100.8 will be marked as read. + */ + const updateChapterFromTracker = async () => { + const updatedTrackerRecords = (await Promise.all(refreshTracker())) as number[]; + + const latestTrackersRead = + Math.max(...updatedTrackerRecords.map((trackData) => trackData)) ?? + manga.trackRecords.nodes.map((trackRecord) => trackRecord.lastChapterRead); + const latestLocalRead = manga.latestReadChapter?.chapterNumber ?? 0; + + // Return a list of all the chapter and chapters parts that match the last chapter in the tracker + const latestLocalChapterOrParts: number[] = + mangaChaptersQuery.data?.chapters.nodes?.reduce((acc: number[], chapter) => { + if ( + chapter.chapterNumber === latestTrackersRead || + Math.floor(chapter.chapterNumber) === latestTrackersRead + ) { + acc.push(chapter.chapterNumber); + } + return acc; + }, []) ?? []; + + // The last part of a chapter + const lastLocalChapter = Math.max(...latestLocalChapterOrParts); + + // If the last chapter fetched is lower that the tracker's last read + const localBehindTracker = lastLocalChapter < latestTrackersRead; + + // Fetch new chapters if behind tracker + if (localBehindTracker) { + await requestManager.getMangaChaptersFetch(manga.id, { awaitRefetchQueries: true }).response; + } + if (!localBehindTracker) { + const chapterToBeUpdated = + mangaChaptersQuery.data?.chapters.nodes?.find( + (chapter) => chapter.chapterNumber === lastLocalChapter, + ) ?? mangaChaptersQuery.data?.chapters.nodes[0]; + if ( + chapterToBeUpdated && + (latestLocalRead < latestTrackersRead || + (Math.floor(chapterToBeUpdated.chapterNumber) === latestTrackersRead && !chapterToBeUpdated.isRead)) + ) { + setChapterAsLastRead(chapterToBeUpdated?.id, mangaChaptersQuery.data?.chapters.nodes as TChapter[]); + } + } + }; + const handleClick = async (openPopup: () => void) => { if (trackerList.error) { makeToast(t('tracking.error.label.could_not_load_track_info'), 'error'); return; @@ -41,6 +101,12 @@ export const TrackMangaButton = ({ manga }: { manga: TManga }) => { } openPopup(); + + try { + await updateChapterFromTracker(); + } catch (error) { + makeToast(t('tracking.error.label.could_not_load_track_info'), 'error'); + } }; return ( diff --git a/src/components/tracker/TrackManga.tsx b/src/components/tracker/TrackManga.tsx index 3b748588c0..33f1c1943d 100644 --- a/src/components/tracker/TrackManga.tsx +++ b/src/components/tracker/TrackManga.tsx @@ -8,16 +8,14 @@ import { useNavigate } from 'react-router-dom'; import { Box } from '@mui/material'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import DialogContent from '@mui/material/DialogContent'; -import { useTranslation } from 'react-i18next'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { EmptyView } from '@/components/util/EmptyView.tsx'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx'; import { Trackers } from '@/lib/data/Trackers.ts'; import { TrackerCard, TrackerMode } from '@/components/tracker/TrackerCard.tsx'; import { TManga } from '@/typings.ts'; -import { makeToast } from '@/components/util/Toast.tsx'; const getTrackerMode = (id: number, trackersInUse: number[], searchModeForTracker?: number): TrackerMode => { if (id === searchModeForTracker) { @@ -32,7 +30,6 @@ const getTrackerMode = (id: number, trackersInUse: number[], searchModeForTracke }; export const TrackManga = ({ manga }: { manga: Pick }) => { - const { t } = useTranslation(); const navigate = useNavigate(); const [searchModeForTracker, setSearchModeForTracker] = useState(); @@ -47,12 +44,6 @@ export const TrackManga = ({ manga }: { manga: Pick (isSearchActive ? Box : DialogContent), [isSearchActive]); - useEffect(() => { - Promise.all(manga.trackRecords.nodes.map((trackRecord) => requestManager.fetchTrackBind(trackRecord.id))).catch( - () => makeToast(t('tracking.error.label.could_not_fetch_track_info'), 'error'), - ); - }, [manga.id]); - const trackerComponents = useMemo( () => loggedInTrackers.map((tracker) => { diff --git a/src/components/util/findElement.ts b/src/components/util/findElement.ts new file mode 100644 index 0000000000..fa03a4774d --- /dev/null +++ b/src/components/util/findElement.ts @@ -0,0 +1,24 @@ +/* + * 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 { findIndexOfElement } from '@/components/util/findIndexOfElement.ts'; + +// fieldToSearch string | any[] +export const findElement = ( + elements: T[], + fieldToSearch: string, + fieldToMatch: unknown, + isFieldToSearchArray?: boolean, +): T | undefined => { + const index = findIndexOfElement(elements, fieldToSearch, fieldToMatch, isFieldToSearchArray); + + if (!index) { + return undefined; + } + return elements[index]; +}; diff --git a/src/components/util/findIndexOfElement.ts b/src/components/util/findIndexOfElement.ts new file mode 100644 index 0000000000..feee240d87 --- /dev/null +++ b/src/components/util/findIndexOfElement.ts @@ -0,0 +1,35 @@ +/* + * 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/. + */ + +/** + * + * @param elements The array that will be search + * @param fieldToSearch The key of the elements that will be search + * @param fieldToMatch The value the fieldToSearch key needs to match + * @param isFieldToSearchArray Whether the key of the fieldToSearch is an array or not. Default to false + * @example findIndexOfElement(mangas, "id", passedManga.id) + * @returns The index of the element if found, or undefine if not found. + */ +export const findIndexOfElement = ( + elements: T[], + fieldToSearch: string, + fieldToMatch: unknown, + isFieldToSearchArray: boolean = false, +): number | undefined => { + let elementFoundIndex: number; + + if (isFieldToSearchArray) { + elementFoundIndex = elements.findIndex((element: T | any) => + element[fieldToSearch].some((field: any) => field === fieldToMatch), + ); + } else { + // do a some() logic checking for boolean, so fieldToMatch fieldToMatch + elementFoundIndex = elements.findIndex((element: T | any) => element[fieldToSearch] === fieldToMatch); + } + return elementFoundIndex; +}; diff --git a/src/components/util/getPartialList.ts b/src/components/util/getPartialList.ts new file mode 100644 index 0000000000..6d03f833de --- /dev/null +++ b/src/components/util/getPartialList.ts @@ -0,0 +1,42 @@ +/* + * 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 { findIndexOfElement } from '@/components/util/findIndexOfElement.ts'; + +/** + *@description This function takes a element's id and a list of the same element, and it return either the first + of second half of the list using the element id as the pivot point. + * @param elementId The Id of the emeent to be use as pivot. + * @param allElements The list of elements to be firtered. + * @param halfOfList There part of the list to be return. Either the first half or the second. + * @param indexOffset The offsett to set for the index. By default set to 1, so the first have will not include the pivot element + * and the second half will include the first element. + * @returns The first of the second half of a list using the elementId passed as the pivots. + */ +export const getPartialList = ( + elementId: number, + allElements: T[], + halfOfList: 'first' | 'second' = 'first', + indexOffset: number = 1, +): T[] => { + const index = findIndexOfElement(allElements, 'id', elementId); + if (index === undefined) { + return [] as T[]; + } + if (halfOfList === 'second') { + if (index + indexOffset > allElements.length - 1) { + return [] as T[]; + } + if (index === 0) { + return allElements; + } + return allElements.slice(index + indexOffset); + } + + return allElements.slice(0, index + indexOffset); +}; diff --git a/src/lib/requests/client/RestClient.ts b/src/lib/requests/client/RestClient.ts index 6547ada94b..8ae0238e3e 100644 --- a/src/lib/requests/client/RestClient.ts +++ b/src/lib/requests/client/RestClient.ts @@ -33,6 +33,7 @@ export class RestClient public readonly fetcher = async ( url: string, + { data, httpMethod = HttpMethod.GET,