From 45c96185a37b0fc59238a2f42732f128b2261c60 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Thu, 16 Apr 2026 14:05:45 -0300 Subject: [PATCH] feat: replace local reading tracker with robust server-synchronized progress hook and conflict resolution dialog --- src/api/generated/api.schemas.ts | 19 +- .../authentication/authentication.ts | 4 +- .../generated/catalog-proxy/catalog-proxy.ts | 4 +- src/api/generated/catalog/catalog.ts | 4 +- src/api/generated/content/content.ts | 4 +- src/api/generated/ingestion/ingestion.ts | 4 +- src/api/generated/management/management.ts | 4 +- .../generated/manga-import/manga-import.ts | 4 +- .../manga-ingest-review.ts | 4 +- .../reading-progress/reading-progress.ts | 295 ++++++++++++++++++ .../user-interaction/user-interaction.ts | 4 +- .../user-statistics/user-statistics.ts | 4 +- src/api/generated/user/user.ts | 4 +- .../chapter/hooks/useReadingProgressSync.ts | 119 +++++++ .../chapter/hooks/useReadingTracker.ts | 52 --- src/pages/Chapter.tsx | 217 ++++++++++--- src/pages/Manga.tsx | 111 ++++--- 17 files changed, 696 insertions(+), 161 deletions(-) create mode 100644 src/api/generated/reading-progress/reading-progress.ts create mode 100644 src/features/chapter/hooks/useReadingProgressSync.ts delete mode 100644 src/features/chapter/hooks/useReadingTracker.ts diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index bce216f..1968c7c 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ export interface RegistrationRequestDTO { name?: string; @@ -89,6 +89,12 @@ export interface RefreshTokenRequestDTO { refreshToken: string; } +export interface ProgressUpdateDTO { + mangaId?: number; + chapterId?: number; + pageNumber?: number; +} + export interface DefaultResponseDTOUserStatisticsDTO { timestamp?: string; data?: UserStatisticsDTO; @@ -223,9 +229,9 @@ export interface PageMangaImportJobDTO { export interface PageableObject { offset?: number; + paged?: boolean; pageNumber?: number; pageSize?: number; - paged?: boolean; unpaged?: boolean; sort?: SortObject; } @@ -385,6 +391,13 @@ export interface GenreDTO { name: string; } +export interface ReadingProgressDTO { + mangaId?: number; + chapterId?: number; + pageNumber?: number; + updatedAt?: string; +} + export type DownloadContentArchiveParams = { contentArchiveFileType: DownloadContentArchiveContentArchiveFileType; }; diff --git a/src/api/generated/authentication/authentication.ts b/src/api/generated/authentication/authentication.ts index cb643dc..d1872af 100644 --- a/src/api/generated/authentication/authentication.ts +++ b/src/api/generated/authentication/authentication.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation diff --git a/src/api/generated/catalog-proxy/catalog-proxy.ts b/src/api/generated/catalog-proxy/catalog-proxy.ts index c1475f7..36529cb 100644 --- a/src/api/generated/catalog-proxy/catalog-proxy.ts +++ b/src/api/generated/catalog-proxy/catalog-proxy.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useQuery diff --git a/src/api/generated/catalog/catalog.ts b/src/api/generated/catalog/catalog.ts index 3f19fc2..9d06472 100644 --- a/src/api/generated/catalog/catalog.ts +++ b/src/api/generated/catalog/catalog.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation, diff --git a/src/api/generated/content/content.ts b/src/api/generated/content/content.ts index 7683bb6..6a9a719 100644 --- a/src/api/generated/content/content.ts +++ b/src/api/generated/content/content.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation, diff --git a/src/api/generated/ingestion/ingestion.ts b/src/api/generated/ingestion/ingestion.ts index bb7711e..aedd8f2 100644 --- a/src/api/generated/ingestion/ingestion.ts +++ b/src/api/generated/ingestion/ingestion.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation, diff --git a/src/api/generated/management/management.ts b/src/api/generated/management/management.ts index 8df96a2..2cd5952 100644 --- a/src/api/generated/management/management.ts +++ b/src/api/generated/management/management.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation diff --git a/src/api/generated/manga-import/manga-import.ts b/src/api/generated/manga-import/manga-import.ts index d931e6c..0278335 100644 --- a/src/api/generated/manga-import/manga-import.ts +++ b/src/api/generated/manga-import/manga-import.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation diff --git a/src/api/generated/manga-ingest-review/manga-ingest-review.ts b/src/api/generated/manga-ingest-review/manga-ingest-review.ts index 965e8af..3e2744b 100644 --- a/src/api/generated/manga-ingest-review/manga-ingest-review.ts +++ b/src/api/generated/manga-ingest-review/manga-ingest-review.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation, diff --git a/src/api/generated/reading-progress/reading-progress.ts b/src/api/generated/reading-progress/reading-progress.ts new file mode 100644 index 0000000..f6ccb72 --- /dev/null +++ b/src/api/generated/reading-progress/reading-progress.ts @@ -0,0 +1,295 @@ +/** + * Generated by orval v7.17.0 🍺 + * Do not edit manually. + * MangaMochi API + * OpenAPI spec version: 1.0 + */ +import { + useMutation, + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + ProgressUpdateDTO, + ReadingProgressDTO +} from '../api.schemas'; + +import { customInstance } from '../../api'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * Stores current chapter and page for a manga in cache + * @summary Update reading progress + */ +export const updateProgress = ( + progressUpdateDTO: ProgressUpdateDTO, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v1/progress`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: progressUpdateDTO, signal + }, + options); + } + + + +export const getUpdateProgressMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ProgressUpdateDTO}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: ProgressUpdateDTO}, TContext> => { + +const mutationKey = ['updateProgress']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: ProgressUpdateDTO}> = (props) => { + const {data} = props ?? {}; + + return updateProgress(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateProgressMutationResult = NonNullable>> + export type UpdateProgressMutationBody = ProgressUpdateDTO + export type UpdateProgressMutationError = unknown + + /** + * @summary Update reading progress + */ +export const useUpdateProgress = (options?: { mutation?:UseMutationOptions>, TError,{data: ProgressUpdateDTO}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ProgressUpdateDTO}, + TContext + > => { + + const mutationOptions = getUpdateProgressMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * Retrieves the current reading progress for a specific manga + * @summary Get reading progress + */ +export const getProgress = ( + mangaId: number, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v1/progress/${encodeURIComponent(String(mangaId))}`, method: 'GET', signal + }, + options); + } + + + + +export const getGetProgressQueryKey = (mangaId?: number,) => { + return [ + `/api/v1/progress/${mangaId}` + ] as const; + } + + +export const getGetProgressQueryOptions = >, TError = unknown>(mangaId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetProgressQueryKey(mangaId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getProgress(mangaId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(mangaId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetProgressQueryResult = NonNullable>> +export type GetProgressQueryError = unknown + + +export function useGetProgress>, TError = unknown>( + mangaId: number, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetProgress>, TError = unknown>( + mangaId: number, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetProgress>, TError = unknown>( + mangaId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get reading progress + */ + +export function useGetProgress>, TError = unknown>( + mangaId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetProgressQueryOptions(mangaId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * Retrieves the reading progress for a specific chapter of a manga + * @summary Get reading progress for chapter + */ +export const getProgress1 = ( + mangaId: number, + chapterId: number, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/api/v1/progress/${encodeURIComponent(String(mangaId))}/${encodeURIComponent(String(chapterId))}`, method: 'GET', signal + }, + options); + } + + + + +export const getGetProgress1QueryKey = (mangaId?: number, + chapterId?: number,) => { + return [ + `/api/v1/progress/${mangaId}/${chapterId}` + ] as const; + } + + +export const getGetProgress1QueryOptions = >, TError = unknown>(mangaId: number, + chapterId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetProgress1QueryKey(mangaId,chapterId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getProgress1(mangaId,chapterId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(mangaId && chapterId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetProgress1QueryResult = NonNullable>> +export type GetProgress1QueryError = unknown + + +export function useGetProgress1>, TError = unknown>( + mangaId: number, + chapterId: number, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetProgress1>, TError = unknown>( + mangaId: number, + chapterId: number, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetProgress1>, TError = unknown>( + mangaId: number, + chapterId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get reading progress for chapter + */ + +export function useGetProgress1>, TError = unknown>( + mangaId: number, + chapterId: number, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetProgress1QueryOptions(mangaId,chapterId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/src/api/generated/user-interaction/user-interaction.ts b/src/api/generated/user-interaction/user-interaction.ts index 74ca77c..d63011e 100644 --- a/src/api/generated/user-interaction/user-interaction.ts +++ b/src/api/generated/user-interaction/user-interaction.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation diff --git a/src/api/generated/user-statistics/user-statistics.ts b/src/api/generated/user-statistics/user-statistics.ts index eba1d00..b6525e1 100644 --- a/src/api/generated/user-statistics/user-statistics.ts +++ b/src/api/generated/user-statistics/user-statistics.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useQuery diff --git a/src/api/generated/user/user.ts b/src/api/generated/user/user.ts index 614b367..089b9f0 100644 --- a/src/api/generated/user/user.ts +++ b/src/api/generated/user/user.ts @@ -1,8 +1,8 @@ /** * Generated by orval v7.17.0 🍺 * Do not edit manually. - * OpenAPI definition - * OpenAPI spec version: v0 + * MangaMochi API + * OpenAPI spec version: 1.0 */ import { useMutation diff --git a/src/features/chapter/hooks/useReadingProgressSync.ts b/src/features/chapter/hooks/useReadingProgressSync.ts new file mode 100644 index 0000000..b3ee28f --- /dev/null +++ b/src/features/chapter/hooks/useReadingProgressSync.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + useGetProgress1, + useUpdateProgress, +} from "@/api/generated/reading-progress/reading-progress"; + +interface ReadingTrackerData { + chapterPage: { [chapterId: number]: number }; + updatedAt?: { [chapterId: number]: string }; +} + +export const useReadingProgressSync = (mangaId: number, chapterId: number) => { + const [currentPage, setCurrentPage] = useState(() => { + const jsonString = localStorage.getItem("readingTrackerData"); + if (jsonString) { + try { + const data: ReadingTrackerData = JSON.parse(jsonString); + return data.chapterPage[chapterId] || 1; + } catch (e) { + console.error("Failed to parse local progress", e); + } + } + return 1; + }); + + const [serverProgress, setServerProgress] = useState<{ + pageNumber: number; + updatedAt: string; + } | null>(null); + + const lastSyncedPage = useRef(null); + const syncTimerRef = useRef | null>(null); + + const { data: progressData, isLoading: isLoadingProgress } = useGetProgress1( + mangaId, + chapterId, + { query: { retry: false } }, + ); + const { mutate: updateProgress } = useUpdateProgress(); + + // Sync local progress when chapterId changes + useEffect(() => { + const jsonString = localStorage.getItem("readingTrackerData"); + if (jsonString) { + try { + const data: ReadingTrackerData = JSON.parse(jsonString); + const localPage = data.chapterPage[chapterId] || 1; + setCurrentPage(localPage); + lastSyncedPage.current = localPage; + } catch (e) { + // ignore + } + } else { + setCurrentPage(1); + lastSyncedPage.current = 1; + } + }, [chapterId]); + + // Sync server progress when available + useEffect(() => { + if (progressData && progressData.pageNumber !== undefined && progressData.updatedAt !== undefined) { + setServerProgress({ + pageNumber: progressData.pageNumber, + updatedAt: progressData.updatedAt, + }); + } + }, [progressData]); + + const saveLocalProgress = useCallback( + (page: number) => { + setCurrentPage(page); + const jsonString = localStorage.getItem("readingTrackerData"); + let data: ReadingTrackerData = { chapterPage: {}, updatedAt: {} }; + if (jsonString) { + try { + data = JSON.parse(jsonString); + } catch (e) { + // fallback to empty + } + } + data.chapterPage[chapterId] = page; + data.updatedAt = data.updatedAt || {}; + data.updatedAt[chapterId] = new Date().toISOString(); + localStorage.setItem("readingTrackerData", JSON.stringify(data)); + + // Debounced backend sync + if (syncTimerRef.current) clearTimeout(syncTimerRef.current); + syncTimerRef.current = setTimeout(() => { + if (page !== lastSyncedPage.current) { + updateProgress({ + data: { + mangaId, + chapterId, + pageNumber: page, + }, + }); + lastSyncedPage.current = page; + } + }, 5000); + }, + [mangaId, chapterId, updateProgress], + ); + + const applyServerProgress = useCallback(() => { + if (serverProgress) { + saveLocalProgress(serverProgress.pageNumber); + return serverProgress.pageNumber; + } + return null; + }, [serverProgress, saveLocalProgress]); + + return { + currentPage, + serverProgress, + isLoadingProgress, + saveLocalProgress, + applyServerProgress, + }; +}; diff --git a/src/features/chapter/hooks/useReadingTracker.ts b/src/features/chapter/hooks/useReadingTracker.ts deleted file mode 100644 index 5f6c416..0000000 --- a/src/features/chapter/hooks/useReadingTracker.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback } from "react"; - -interface ReadingTrackerData { - chapterPage: { [chapterId: number]: number }; -} - -export const useReadingTracker = () => { - const setCurrentChapterPage = useCallback( - (id: number, pageNumber: number) => { - const jsonString = localStorage.getItem("readingTrackerData"); - - let readingTrackerData: ReadingTrackerData; - try { - readingTrackerData = jsonString - ? JSON.parse(jsonString) - : { chapterPage: {} }; - } catch (error) { - console.error("Error parsing reading tracker data:", error); - readingTrackerData = { chapterPage: {} }; - } - - const updatedData = { - ...readingTrackerData, - chapterPage: { - ...readingTrackerData.chapterPage, - [id]: pageNumber, - }, - }; - - localStorage.setItem("readingTrackerData", JSON.stringify(updatedData)); - }, - [], - ); - - const getCurrentChapterPage = useCallback( - (id: number): number | undefined => { - const jsonString = localStorage.getItem("readingTrackerData"); - if (!jsonString) return undefined; - - try { - const readingTrackerData: ReadingTrackerData = JSON.parse(jsonString); - return readingTrackerData.chapterPage[id]; - } catch (error) { - console.error("Error parsing reading tracker data:", error); - return undefined; - } - }, - [], - ); - - return { setCurrentChapterPage, getCurrentChapterPage }; -}; diff --git a/src/pages/Chapter.tsx b/src/pages/Chapter.tsx index 1bb75e4..0a0360d 100644 --- a/src/pages/Chapter.tsx +++ b/src/pages/Chapter.tsx @@ -5,20 +5,37 @@ import { useGetMangaContentImages } from "@/api/generated/content/content.ts"; import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts"; import { ThemeToggle } from "@/components/ThemeToggle.tsx"; import { Button } from "@/components/ui/button"; -import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useReadingProgressSync } from "@/features/chapter/hooks/useReadingProgressSync.ts"; import { MangaLoadingState } from "@/components/MangaLoadingState.tsx"; const Chapter = () => { - const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker(); - const navigate = useNavigate(); const params = useParams(); const mangaId = Number(params.mangaId); const chapterNumber = Number(params.chapterId); - const [currentPage, setCurrentPage] = useState( - getCurrentChapterPage(chapterNumber) ?? 1, - ); + const { + currentPage: savedPage, + serverProgress, + isLoadingProgress, + saveLocalProgress, + applyServerProgress, + } = useReadingProgressSync(mangaId, chapterNumber); + + const [currentPage, setCurrentPage] = useState(1); + const [hasInitialized, setHasInitialized] = useState(false); + const [showConflictDialog, setShowConflictDialog] = useState(false); + const [isAutoScrolling, setIsAutoScrolling] = useState(false); + const initialJumpDone = useRef(false); + const isFirstMount = useRef(true); const [infiniteScroll, setInfiniteScroll] = useState(true); @@ -29,34 +46,88 @@ const Chapter = () => { const [visibleCount, setVisibleCount] = useState(1); const loadMoreRef = useRef(null); + /** Conflict Resolution & Initial Sync */ + useEffect(() => { + if (isLoadingProgress || hasInitialized) return; + + const localPage = savedPage; + const serverPage = serverProgress?.pageNumber; + + if (!localPage && !serverPage) { + setHasInitialized(true); + return; + } + + if (localPage && serverPage && localPage !== serverPage) { + setShowConflictDialog(true); + } else { + const targetPage = localPage || serverPage || 1; + setCurrentPage(targetPage); + setVisibleCount(targetPage); + if (targetPage > 1) { + setIsAutoScrolling(true); + } + setHasInitialized(true); + } + }, [isLoadingProgress, savedPage, serverProgress, hasInitialized]); + /** Mark chapter as read when last page reached */ useEffect(() => { - if (!data || isLoading) return; + if (!data || isLoading || !hasInitialized || isAutoScrolling) return; if (currentPage === data.data?.contentImageKeys.length) { mutate({ mangaContentId: chapterNumber }); } - }, [data, mutate, currentPage]); + }, [data, mutate, currentPage, hasInitialized, isAutoScrolling]); /** Persist reading progress */ useEffect(() => { - setCurrentChapterPage(chapterNumber, currentPage); - }, [chapterNumber, currentPage]); - - /** Restore stored page */ - useEffect(() => { - if (!isLoading && data?.data) { - const stored = getCurrentChapterPage(chapterNumber); - if (stored) { - setCurrentPage(stored); - setVisibleCount(stored); // for infinite scroll - } + if (hasInitialized && !isAutoScrolling && initialJumpDone.current) { + saveLocalProgress(currentPage); } - }, [isLoading, data?.data]); + }, [currentPage, hasInitialized, isAutoScrolling, saveLocalProgress]); - /** Infinite scroll observer */ + /** Auto-scroll to restored page */ useEffect(() => { - if (!infiniteScroll) return; + if (!hasInitialized || isLoading || !data?.data || initialJumpDone.current) + return; + + if (infiniteScroll && currentPage > 1) { + setIsAutoScrolling(true); + setVisibleCount(currentPage); + + let attempts = 0; + const maxAttempts = 10; + + const attemptJump = () => { + const el = document.querySelector(`[data-page="${currentPage}"]`); + if (el && (el as HTMLElement).offsetTop > 0) { + el.scrollIntoView({ behavior: "auto", block: "start" }); + setTimeout(() => { + initialJumpDone.current = true; + setIsAutoScrolling(false); + isFirstMount.current = false; + }, 300); + } else if (attempts < maxAttempts) { + attempts++; + setTimeout(attemptJump, 50); + } else { + initialJumpDone.current = true; + setIsAutoScrolling(false); + isFirstMount.current = false; + } + }; + + attemptJump(); + } else { + initialJumpDone.current = true; + isFirstMount.current = false; + } + }, [hasInitialized, isLoading, data?.data, infiniteScroll, currentPage]); + + /** Infinite scroll observer (load more) */ + useEffect(() => { + if (!infiniteScroll || !hasInitialized) return; if (!loadMoreRef.current) return; const obs = new IntersectionObserver((entries) => { @@ -69,48 +140,48 @@ const Chapter = () => { obs.observe(loadMoreRef.current); return () => obs.disconnect(); - }, [infiniteScroll, data?.data]); + }, [infiniteScroll, data?.data, hasInitialized]); /** Track which image is currently visible (for progress update) */ useEffect(() => { - if (!infiniteScroll) return; + if (!infiniteScroll || !hasInitialized || isAutoScrolling) return; const imgs = document.querySelectorAll("[data-page]"); const observer = new IntersectionObserver( (entries) => { - entries.forEach((entry) => { - const el = entry.target as HTMLElement; // <-- FIX + for (const entry of entries) { if (entry.isIntersecting) { - const pageNum = Number(el.dataset.page); // <-- SAFE + const el = entry.target as HTMLElement; + const pageNum = Number(el.dataset.page); setCurrentPage(pageNum); + // We only need the first one that intersects the top area + break; } - }); + } }, - { threshold: 0.5 }, + { threshold: 0, rootMargin: "-20% 0px -70% 0px" }, ); imgs.forEach((img) => observer.observe(img)); return () => observer.disconnect(); - }, [infiniteScroll, visibleCount]); + }, [infiniteScroll, visibleCount, hasInitialized, isAutoScrolling]); useEffect(() => { - if (!data?.data) return; + if (!data?.data || !hasInitialized || isFirstMount.current) return; - // When switching modes: + // When manually switching modes: if (infiniteScroll) { - // Scroll mode → show saved progress setVisibleCount(currentPage); setTimeout(() => { const el = document.querySelector(`[data-page="${currentPage}"]`); el?.scrollIntoView({ behavior: "smooth", block: "start" }); }, 50); } else { - // Single page mode → scroll to top window.scrollTo({ top: 0 }); } }, [infiniteScroll]); - if (isLoading) { + if (isLoading || !hasInitialized) { return (
@@ -170,6 +241,53 @@ const Chapter = () => { return (
+ {/* Conflict Resolution Dialog */} + + + + Progress Conflict + + You have different progress saved locally and on the server for + this chapter. +
+ + Local: Page {savedPage} + + + Server: Page {serverProgress?.pageNumber} + +
+
+ + + + +
+
{/* HEADER */}
@@ -247,7 +365,11 @@ const Chapter = () => {
{/* MAIN */} -
+

{data.data.mangaTitle} @@ -263,18 +385,27 @@ const Chapter = () => { {infiniteScroll ? (
{images.slice(0, visibleCount).map((key, idx) => ( - {`Page + className="w-full m-0 p-0" + style={{ aspectRatio: "2/3" }} + onLoadCapture={(e) => { + const target = e.currentTarget as HTMLElement; + target.style.aspectRatio = "auto"; + }} + > + {`Page +
))} {/* LOAD MORE SENTINEL */} -
+
{/* CHAPTER NAVIGATION (infinite scroll mode) */} {(previousContentId || nextContentId) && ( diff --git a/src/pages/Manga.tsx b/src/pages/Manga.tsx index 609622c..6d5f801 100644 --- a/src/pages/Manga.tsx +++ b/src/pages/Manga.tsx @@ -38,6 +38,8 @@ import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; +import { useGetProgress } from "@/api/generated/reading-progress/reading-progress.ts"; + const Manga = () => { const { isAuthenticated } = useAuth(); const navigate = useNavigate(); @@ -47,6 +49,9 @@ const Manga = () => { const queryClient = useQueryClient(); const { data: mangaData, queryKey, isLoading } = useGetManga(mangaId); + const { data: progressData } = useGetProgress(mangaId, { + query: { retry: false }, + }); const { mutate, isPending: fetchPending } = useFetchContentProviderContentList({ @@ -340,6 +345,30 @@ const Manga = () => {

{mangaData.data?.authors.join(", ")}

+ + {progressData && + progressData.chapterId && + progressData.pageNumber && ( +
+ +
+ )}
{mangaData.data?.alternativeTitles && @@ -477,48 +506,48 @@ const Manga = () => { {provider.supportsChapterFetch && provider.active && ( -
- {provider.chaptersAvailable > 0 && ( - +
+ {provider.chaptersAvailable > 0 && ( + -
+ Fetch all from Provider + + )} + +
)}