feat: replace local reading tracker with robust server-synchronized progress hook and conflict resolution dialog

This commit is contained in:
Rodrigo Verdiani 2026-04-16 14:05:45 -03:00
parent 3a429648b0
commit 45c96185a3
17 changed files with 696 additions and 161 deletions

View File

@ -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;
};

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Stores current chapter and page for a manga in cache
* @summary Update reading progress
*/
export const updateProgress = (
progressUpdateDTO: ProgressUpdateDTO,
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<void>(
{url: `/api/v1/progress`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: progressUpdateDTO, signal
},
options);
}
export const getUpdateProgressMutationOptions = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateProgress>>, TError,{data: ProgressUpdateDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
): UseMutationOptions<Awaited<ReturnType<typeof updateProgress>>, 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<Awaited<ReturnType<typeof updateProgress>>, {data: ProgressUpdateDTO}> = (props) => {
const {data} = props ?? {};
return updateProgress(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UpdateProgressMutationResult = NonNullable<Awaited<ReturnType<typeof updateProgress>>>
export type UpdateProgressMutationBody = ProgressUpdateDTO
export type UpdateProgressMutationError = unknown
/**
* @summary Update reading progress
*/
export const useUpdateProgress = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateProgress>>, TError,{data: ProgressUpdateDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof updateProgress>>,
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<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<ReadingProgressDTO>(
{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 = <TData = Awaited<ReturnType<typeof getProgress>>, TError = unknown>(mangaId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetProgressQueryKey(mangaId);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getProgress>>> = ({ signal }) => getProgress(mangaId, requestOptions, signal);
return { queryKey, queryFn, enabled: !!(mangaId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getProgress>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetProgressQueryResult = NonNullable<Awaited<ReturnType<typeof getProgress>>>
export type GetProgressQueryError = unknown
export function useGetProgress<TData = Awaited<ReturnType<typeof getProgress>>, TError = unknown>(
mangaId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getProgress>>,
TError,
Awaited<ReturnType<typeof getProgress>>
> , 'initialData'
>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetProgress<TData = Awaited<ReturnType<typeof getProgress>>, TError = unknown>(
mangaId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getProgress>>,
TError,
Awaited<ReturnType<typeof getProgress>>
> , 'initialData'
>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetProgress<TData = Awaited<ReturnType<typeof getProgress>>, TError = unknown>(
mangaId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get reading progress
*/
export function useGetProgress<TData = Awaited<ReturnType<typeof getProgress>>, TError = unknown>(
mangaId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetProgressQueryOptions(mangaId,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
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<typeof customInstance>,signal?: AbortSignal
) => {
return customInstance<ReadingProgressDTO>(
{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 = <TData = Awaited<ReturnType<typeof getProgress1>>, TError = unknown>(mangaId: number,
chapterId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress1>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetProgress1QueryKey(mangaId,chapterId);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getProgress1>>> = ({ signal }) => getProgress1(mangaId,chapterId, requestOptions, signal);
return { queryKey, queryFn, enabled: !!(mangaId && chapterId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getProgress1>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetProgress1QueryResult = NonNullable<Awaited<ReturnType<typeof getProgress1>>>
export type GetProgress1QueryError = unknown
export function useGetProgress1<TData = Awaited<ReturnType<typeof getProgress1>>, TError = unknown>(
mangaId: number,
chapterId: number, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress1>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getProgress1>>,
TError,
Awaited<ReturnType<typeof getProgress1>>
> , 'initialData'
>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetProgress1<TData = Awaited<ReturnType<typeof getProgress1>>, TError = unknown>(
mangaId: number,
chapterId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress1>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getProgress1>>,
TError,
Awaited<ReturnType<typeof getProgress1>>
> , 'initialData'
>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetProgress1<TData = Awaited<ReturnType<typeof getProgress1>>, TError = unknown>(
mangaId: number,
chapterId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress1>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get reading progress for chapter
*/
export function useGetProgress1<TData = Awaited<ReturnType<typeof getProgress1>>, TError = unknown>(
mangaId: number,
chapterId: number, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getProgress1>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetProgress1QueryOptions(mangaId,chapterId,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<number | null>(() => {
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<number | null>(null);
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
};
};

View File

@ -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 };
};

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<MangaLoadingState />
@ -170,6 +241,53 @@ const Chapter = () => {
return (
<div className="min-h-screen bg-background">
{/* Conflict Resolution Dialog */}
<Dialog open={showConflictDialog} onOpenChange={setShowConflictDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Progress Conflict</DialogTitle>
<DialogDescription>
You have different progress saved locally and on the server for
this chapter.
<br />
<span className="mt-2 block">
Local: <strong>Page {savedPage}</strong>
</span>
<span className="block">
Server: <strong>Page {serverProgress?.pageNumber}</strong>
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setHasInitialized(true);
setShowConflictDialog(false);
}}
>
Use Local
</Button>
<Button
onClick={() => {
const target = applyServerProgress();
if (target) {
setCurrentPage(target);
setVisibleCount(target);
if (target > 1) {
setIsAutoScrolling(true);
initialJumpDone.current = false;
}
}
setHasInitialized(true);
setShowConflictDialog(false);
}}
>
Use Server
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* HEADER */}
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="px-4 py-4 sm:px-8">
@ -247,7 +365,11 @@ const Chapter = () => {
</header>
{/* MAIN */}
<main className="mx-auto max-w-4xl px-4 py-8">
<main
className={`mx-auto max-w-4xl ${
infiniteScroll ? "px-0 py-0" : "px-4 py-8"
}`}
>
<div className="mb-4 sm:hidden">
<h1 className="text-lg font-semibold text-foreground">
{data.data.mangaTitle}
@ -263,18 +385,27 @@ const Chapter = () => {
{infiniteScroll ? (
<div className="flex flex-col space-y-0">
{images.slice(0, visibleCount).map((key, idx) => (
<img
<div
key={idx}
data-page={idx + 1}
className="w-full m-0 p-0"
style={{ aspectRatio: "2/3" }}
onLoadCapture={(e) => {
const target = e.currentTarget as HTMLElement;
target.style.aspectRatio = "auto";
}}
>
<img
src={`${import.meta.env.VITE_OMV_BASE_URL}/${key}`}
className="w-full h-auto block"
className="w-full h-auto block m-0 p-0 border-none"
alt={`Page ${idx + 1}`}
loading="lazy"
/>
</div>
))}
{/* LOAD MORE SENTINEL */}
<div ref={loadMoreRef} className="h-10" />
<div ref={loadMoreRef} className="h-40" />
{/* CHAPTER NAVIGATION (infinite scroll mode) */}
{(previousContentId || nextContentId) && (

View File

@ -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 = () => {
<p className="text-lg text-muted-foreground">
{mangaData.data?.authors.join(", ")}
</p>
{progressData &&
progressData.chapterId &&
progressData.pageNumber && (
<div className="pt-2">
<Button
size="lg"
variant="default"
className="group relative w-full sm:w-auto bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg transition-all hover:scale-[1.02] active:scale-[0.98] gap-2"
onClick={() =>
navigate(
`/manga/${mangaId}/chapter/${progressData.chapterId}`,
)
}
>
<BookOpen className="h-5 w-5 transition-transform group-hover:rotate-12" />
<div className="flex flex-col items-start leading-tight">
<span className="text-xs font-bold uppercase tracking-wider opacity-80">
Continue Reading
</span>
</div>
</Button>
</div>
)}
</div>
{mangaData.data?.alternativeTitles &&