feat: replace local reading tracker with robust server-synchronized progress hook and conflict resolution dialog
This commit is contained in:
parent
3a429648b0
commit
45c96185a3
@ -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;
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
295
src/api/generated/reading-progress/reading-progress.ts
Normal file
295
src/api/generated/reading-progress/reading-progress.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
119
src/features/chapter/hooks/useReadingProgressSync.ts
Normal file
119
src/features/chapter/hooks/useReadingProgressSync.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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 };
|
||||
};
|
||||
@ -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) && (
|
||||
|
||||
@ -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 &&
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user