feat: replace local reading tracker with robust server-synchronized progress hook and conflict resolution dialog #35
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
export interface RegistrationRequestDTO {
|
export interface RegistrationRequestDTO {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -89,6 +89,12 @@ export interface RefreshTokenRequestDTO {
|
|||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProgressUpdateDTO {
|
||||||
|
mangaId?: number;
|
||||||
|
chapterId?: number;
|
||||||
|
pageNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DefaultResponseDTOUserStatisticsDTO {
|
export interface DefaultResponseDTOUserStatisticsDTO {
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
data?: UserStatisticsDTO;
|
data?: UserStatisticsDTO;
|
||||||
@ -223,9 +229,9 @@ export interface PageMangaImportJobDTO {
|
|||||||
|
|
||||||
export interface PageableObject {
|
export interface PageableObject {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
paged?: boolean;
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
paged?: boolean;
|
|
||||||
unpaged?: boolean;
|
unpaged?: boolean;
|
||||||
sort?: SortObject;
|
sort?: SortObject;
|
||||||
}
|
}
|
||||||
@ -385,6 +391,13 @@ export interface GenreDTO {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReadingProgressDTO {
|
||||||
|
mangaId?: number;
|
||||||
|
chapterId?: number;
|
||||||
|
pageNumber?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type DownloadContentArchiveParams = {
|
export type DownloadContentArchiveParams = {
|
||||||
contentArchiveFileType: DownloadContentArchiveContentArchiveFileType;
|
contentArchiveFileType: DownloadContentArchiveContentArchiveFileType;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation
|
useMutation
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useQuery
|
useQuery
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation,
|
useMutation,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation,
|
useMutation,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation,
|
useMutation,
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation
|
useMutation
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation
|
useMutation
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation,
|
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 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation
|
useMutation
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useQuery
|
useQuery
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v7.17.0 🍺
|
* Generated by orval v7.17.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* OpenAPI definition
|
* MangaMochi API
|
||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: 1.0
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
useMutation
|
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 { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
|
||||||
|
|
||||||
const Chapter = () => {
|
const Chapter = () => {
|
||||||
const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const mangaId = Number(params.mangaId);
|
const mangaId = Number(params.mangaId);
|
||||||
const chapterNumber = Number(params.chapterId);
|
const chapterNumber = Number(params.chapterId);
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(
|
const {
|
||||||
getCurrentChapterPage(chapterNumber) ?? 1,
|
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);
|
const [infiniteScroll, setInfiniteScroll] = useState(true);
|
||||||
|
|
||||||
@ -29,34 +46,88 @@ const Chapter = () => {
|
|||||||
const [visibleCount, setVisibleCount] = useState(1);
|
const [visibleCount, setVisibleCount] = useState(1);
|
||||||
const loadMoreRef = useRef(null);
|
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 */
|
/** Mark chapter as read when last page reached */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || isLoading) return;
|
if (!data || isLoading || !hasInitialized || isAutoScrolling) return;
|
||||||
|
|
||||||
if (currentPage === data.data?.contentImageKeys.length) {
|
if (currentPage === data.data?.contentImageKeys.length) {
|
||||||
mutate({ mangaContentId: chapterNumber });
|
mutate({ mangaContentId: chapterNumber });
|
||||||
}
|
}
|
||||||
}, [data, mutate, currentPage]);
|
}, [data, mutate, currentPage, hasInitialized, isAutoScrolling]);
|
||||||
|
|
||||||
/** Persist reading progress */
|
/** Persist reading progress */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentChapterPage(chapterNumber, currentPage);
|
if (hasInitialized && !isAutoScrolling && initialJumpDone.current) {
|
||||||
}, [chapterNumber, currentPage]);
|
saveLocalProgress(currentPage);
|
||||||
|
|
||||||
/** Restore stored page */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading && data?.data) {
|
|
||||||
const stored = getCurrentChapterPage(chapterNumber);
|
|
||||||
if (stored) {
|
|
||||||
setCurrentPage(stored);
|
|
||||||
setVisibleCount(stored); // for infinite scroll
|
|
||||||
}
|
}
|
||||||
}
|
}, [currentPage, hasInitialized, isAutoScrolling, saveLocalProgress]);
|
||||||
}, [isLoading, data?.data]);
|
|
||||||
|
|
||||||
/** Infinite scroll observer */
|
/** Auto-scroll to restored page */
|
||||||
useEffect(() => {
|
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;
|
if (!loadMoreRef.current) return;
|
||||||
|
|
||||||
const obs = new IntersectionObserver((entries) => {
|
const obs = new IntersectionObserver((entries) => {
|
||||||
@ -69,48 +140,48 @@ const Chapter = () => {
|
|||||||
|
|
||||||
obs.observe(loadMoreRef.current);
|
obs.observe(loadMoreRef.current);
|
||||||
return () => obs.disconnect();
|
return () => obs.disconnect();
|
||||||
}, [infiniteScroll, data?.data]);
|
}, [infiniteScroll, data?.data, hasInitialized]);
|
||||||
|
|
||||||
/** Track which image is currently visible (for progress update) */
|
/** Track which image is currently visible (for progress update) */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!infiniteScroll) return;
|
if (!infiniteScroll || !hasInitialized || isAutoScrolling) return;
|
||||||
|
|
||||||
const imgs = document.querySelectorAll("[data-page]");
|
const imgs = document.querySelectorAll("[data-page]");
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
for (const entry of entries) {
|
||||||
const el = entry.target as HTMLElement; // <-- FIX
|
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
const pageNum = Number(el.dataset.page); // <-- SAFE
|
const el = entry.target as HTMLElement;
|
||||||
|
const pageNum = Number(el.dataset.page);
|
||||||
setCurrentPage(pageNum);
|
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));
|
imgs.forEach((img) => observer.observe(img));
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [infiniteScroll, visibleCount]);
|
}, [infiniteScroll, visibleCount, hasInitialized, isAutoScrolling]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data?.data) return;
|
if (!data?.data || !hasInitialized || isFirstMount.current) return;
|
||||||
|
|
||||||
// When switching modes:
|
// When manually switching modes:
|
||||||
if (infiniteScroll) {
|
if (infiniteScroll) {
|
||||||
// Scroll mode → show saved progress
|
|
||||||
setVisibleCount(currentPage);
|
setVisibleCount(currentPage);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const el = document.querySelector(`[data-page="${currentPage}"]`);
|
const el = document.querySelector(`[data-page="${currentPage}"]`);
|
||||||
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}, 50);
|
}, 50);
|
||||||
} else {
|
} else {
|
||||||
// Single page mode → scroll to top
|
|
||||||
window.scrollTo({ top: 0 });
|
window.scrollTo({ top: 0 });
|
||||||
}
|
}
|
||||||
}, [infiniteScroll]);
|
}, [infiniteScroll]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading || !hasInitialized) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||||
<MangaLoadingState />
|
<MangaLoadingState />
|
||||||
@ -170,6 +241,53 @@ const Chapter = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<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 */}
|
||||||
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<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">
|
<div className="px-4 py-4 sm:px-8">
|
||||||
@ -247,7 +365,11 @@ const Chapter = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* MAIN */}
|
{/* 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">
|
<div className="mb-4 sm:hidden">
|
||||||
<h1 className="text-lg font-semibold text-foreground">
|
<h1 className="text-lg font-semibold text-foreground">
|
||||||
{data.data.mangaTitle}
|
{data.data.mangaTitle}
|
||||||
@ -263,18 +385,27 @@ const Chapter = () => {
|
|||||||
{infiniteScroll ? (
|
{infiniteScroll ? (
|
||||||
<div className="flex flex-col space-y-0">
|
<div className="flex flex-col space-y-0">
|
||||||
{images.slice(0, visibleCount).map((key, idx) => (
|
{images.slice(0, visibleCount).map((key, idx) => (
|
||||||
<img
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
data-page={idx + 1}
|
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}`}
|
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}`}
|
alt={`Page ${idx + 1}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* LOAD MORE SENTINEL */}
|
{/* LOAD MORE SENTINEL */}
|
||||||
<div ref={loadMoreRef} className="h-10" />
|
<div ref={loadMoreRef} className="h-40" />
|
||||||
|
|
||||||
{/* CHAPTER NAVIGATION (infinite scroll mode) */}
|
{/* CHAPTER NAVIGATION (infinite scroll mode) */}
|
||||||
{(previousContentId || nextContentId) && (
|
{(previousContentId || nextContentId) && (
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
import { useGetProgress } from "@/api/generated/reading-progress/reading-progress.ts";
|
||||||
|
|
||||||
const Manga = () => {
|
const Manga = () => {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -47,6 +49,9 @@ const Manga = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: mangaData, queryKey, isLoading } = useGetManga(mangaId);
|
const { data: mangaData, queryKey, isLoading } = useGetManga(mangaId);
|
||||||
|
const { data: progressData } = useGetProgress(mangaId, {
|
||||||
|
query: { retry: false },
|
||||||
|
});
|
||||||
|
|
||||||
const { mutate, isPending: fetchPending } =
|
const { mutate, isPending: fetchPending } =
|
||||||
useFetchContentProviderContentList({
|
useFetchContentProviderContentList({
|
||||||
@ -340,6 +345,30 @@ const Manga = () => {
|
|||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
{mangaData.data?.authors.join(", ")}
|
{mangaData.data?.authors.join(", ")}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{mangaData.data?.alternativeTitles &&
|
{mangaData.data?.alternativeTitles &&
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user