diff --git a/packages/api/src/media.ts b/packages/api/src/media.ts index 55bf864a7..0238ae9bf 100644 --- a/packages/api/src/media.ts +++ b/packages/api/src/media.ts @@ -1,4 +1,10 @@ -import type { Media, PatchMediaThumbnail, ReadProgress } from '@stump/types' +import type { + Media, + MediaIsComplete, + PatchMediaThumbnail, + PutMediaCompletionStatus, + ReadProgress, +} from '@stump/types' import { API } from './axios' import { ApiResult, CursorQueryParams, PageableApiResult } from './types' @@ -71,6 +77,13 @@ export function patchMediaThumbnail(id: string, params: PatchMediaThumbnail) { return API.patch(`/media/${id}/thumbnail`, params) } +export function putMediaCompletion( + id: string, + payload: PutMediaCompletionStatus, +): Promise> { + return API.put(`/media/${id}/complete`, payload) +} + export const mediaApi = { getInProgressMedia, getMedia, @@ -82,6 +95,7 @@ export const mediaApi = { getPaginatedMedia, getRecentlyAddedMedia, patchMediaThumbnail, + putMediaCompletion, updateMediaProgress, } @@ -96,5 +110,6 @@ export const mediaQueryKeys: Record = { getPaginatedMedia: 'media.getPaginated', getRecentlyAddedMedia: 'media.getRecentlyAdded', patchMediaThumbnail: 'media.patchThumbnail', + putMediaCompletion: 'media.putCompletion', updateMediaProgress: 'media.updateProgress', } diff --git a/packages/interface/src/scenes/book/BookReaderLink.tsx b/packages/interface/src/scenes/book/BookReaderLink.tsx index 76b174ffa..96646d905 100644 --- a/packages/interface/src/scenes/book/BookReaderLink.tsx +++ b/packages/interface/src/scenes/book/BookReaderLink.tsx @@ -1,5 +1,6 @@ import { ButtonOrLink } from '@stump/components' import { Media } from '@stump/types' +import { useMemo } from 'react' import paths from '../../paths' import { EBOOK_EXTENSION } from '../../utils/patterns' @@ -8,38 +9,66 @@ type Props = { book?: Media } export default function BookReaderLink({ book }: Props) { - if (!book) { - return null - } + const currentPage = book?.current_page ?? -1 + + /** + * A boolean used to control the redering of the 'Read again' prompt. A book + * is considered to be read again if: + * + * - It has been completed AND the current page is the last page + * - It has been completed AND is an epub AND there is no current epubcfi + */ + const isReadAgain = useMemo(() => { + if (!book) return false - const currentPage = book.current_page || -1 + const { is_completed, current_page, pages, current_epubcfi, extension } = book - const getTitle = () => { - if (book.is_completed || currentPage === book.pages) { + const isEpub = extension.match(EBOOK_EXTENSION) + const epubCompleted = isEpub && !current_epubcfi && is_completed + const otherCompleted = !isEpub && current_page === pages && is_completed + + return epubCompleted || otherCompleted + }, [book]) + + const epubcfi = book?.current_epubcfi + const title = useMemo(() => { + if (isReadAgain) { return 'Read again' - } else if (currentPage > -1 || !!book.current_epubcfi) { + } else if (currentPage > -1 || !!epubcfi) { return 'Continue reading' } else { return 'Start reading' } - } + }, [isReadAgain, currentPage, epubcfi]) - const getHref = () => { - if (book.current_epubcfi || book.extension?.match(EBOOK_EXTENSION)) { - return paths.bookReader(book.id, { - epubcfi: book.current_epubcfi, + /** + * The URL to use for the read link. If the book is an epub, the epubcfi is used + * to open the book at the correct location. Otherwise, the page number is used. + * + * If the book is completed, the read link will omit the epubcfi or page number + */ + const readUrl = useMemo(() => { + if (!book) return undefined + + const { current_epubcfi, extension, id, current_page } = book + + if (current_epubcfi || extension.match(EBOOK_EXTENSION)) { + return paths.bookReader(id, { + epubcfi: isReadAgain ? undefined : current_epubcfi, isEpub: true, }) } else { - return paths.bookReader(book?.id || '', { page: book?.current_page || 1 }) + return paths.bookReader(id, { page: isReadAgain ? undefined : current_page || 1 }) } - } + }, [book, isReadAgain]) - const title = getTitle() + if (!book) { + return null + } return (
- + {title}
diff --git a/packages/interface/src/scenes/book/BookReaderScene.tsx b/packages/interface/src/scenes/book/BookReaderScene.tsx index 078d1b2c7..ef424883e 100644 --- a/packages/interface/src/scenes/book/BookReaderScene.tsx +++ b/packages/interface/src/scenes/book/BookReaderScene.tsx @@ -39,8 +39,20 @@ export default function BookReaderScene() { } }, []) + /** + * An effect to update the read progress whenever the page changes in the URL + */ + useEffect(() => { + const parsedPage = parseInt(page || '', 10) + if (!parsedPage || isNaN(parsedPage) || !media) return + + const maxPage = media.pages + if (parsedPage <= 0 || parsedPage > maxPage) return + + updateReadProgress(parsedPage) + }, [page, updateReadProgress, media]) + function handleChangePage(newPage: number) { - updateReadProgress(newPage) navigate(paths.bookReader(id!, { isAnimated, page: newPage })) } diff --git a/packages/types/generated.ts b/packages/types/generated.ts index 8b47fa3ea..6686cffa8 100644 --- a/packages/types/generated.ts +++ b/packages/types/generated.ts @@ -172,8 +172,14 @@ export type UpdateLibrary = { id: string; name: string; path: string; descriptio export type CleanLibraryResponse = { deleted_media_count: number; deleted_series_count: number; is_empty: boolean } +export type PutMediaCompletionStatus = { is_complete: boolean } + +export type MediaIsComplete = { is_completed: boolean; completed_at: string | null } + export type MediaMetadataOverview = { genres: string[]; writers: string[]; pencillers: string[]; inkers: string[]; colorists: string[]; letterers: string[]; editors: string[]; publishers: string[]; characters: string[]; teams: string[] } +export type SeriesIsComplete = { is_complete: boolean; completed_at: string | null } + export type UpdateSchedulerConfig = { interval_secs: number | null; excluded_library_ids: string[] | null } export type GetBookClubsParams = { all?: boolean }