Compare commits
2 Commits
1cdfc905e4
...
5a06d2d738
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a06d2d738 | |||
| aca0d114fb |
@ -10,17 +10,17 @@ export interface DefaultResponseDTOVoid {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ImportMangaDexRequestDTO {
|
||||
export interface ImportRequestDTO {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DefaultResponseDTOImportMangaDexResponseDTO {
|
||||
export interface DefaultResponseDTOImportMangaResponseDTO {
|
||||
timestamp?: string;
|
||||
data?: ImportMangaDexResponseDTO;
|
||||
data?: ImportMangaResponseDTO;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ImportMangaDexResponseDTO {
|
||||
export interface ImportMangaResponseDTO {
|
||||
id: number;
|
||||
}
|
||||
|
||||
@ -99,9 +99,9 @@ export interface PageMangaListDTO {
|
||||
|
||||
export interface PageableObject {
|
||||
offset?: number;
|
||||
paged?: boolean;
|
||||
pageNumber?: number;
|
||||
pageSize?: number;
|
||||
paged?: boolean;
|
||||
sort?: SortObject;
|
||||
unpaged?: boolean;
|
||||
}
|
||||
|
||||
@ -26,6 +26,69 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
/**
|
||||
* Trigger user follow update
|
||||
* @summary Trigger user follow update
|
||||
*/
|
||||
export const userFollowUpdate = (
|
||||
|
||||
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customInstance<DefaultResponseDTOVoid>(
|
||||
{url: `/management/user-follow`, method: 'POST', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getUserFollowUpdateMutationOptions = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof userFollowUpdate>>, TError,void, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof userFollowUpdate>>, TError,void, TContext> => {
|
||||
|
||||
const mutationKey = ['userFollowUpdate'];
|
||||
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 userFollowUpdate>>, void> = () => {
|
||||
|
||||
|
||||
return userFollowUpdate(requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type UserFollowUpdateMutationResult = NonNullable<Awaited<ReturnType<typeof userFollowUpdate>>>
|
||||
|
||||
export type UserFollowUpdateMutationError = unknown
|
||||
|
||||
/**
|
||||
* @summary Trigger user follow update
|
||||
*/
|
||||
export const useUserFollowUpdate = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof userFollowUpdate>>, TError,void, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof userFollowUpdate>>,
|
||||
TError,
|
||||
void,
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getUserFollowUpdateMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* Queue the retrieval of the manga lists from the content providers
|
||||
* @summary Queue update manga list
|
||||
*/
|
||||
@ -89,6 +152,69 @@ export const useUpdateMangaList = <TError = unknown,
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* Sends a test notification to all users
|
||||
* @summary Test notification
|
||||
*/
|
||||
export const testNotification = (
|
||||
|
||||
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customInstance<DefaultResponseDTOVoid>(
|
||||
{url: `/management/test-notification`, method: 'POST', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getTestNotificationMutationOptions = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof testNotification>>, TError,void, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof testNotification>>, TError,void, TContext> => {
|
||||
|
||||
const mutationKey = ['testNotification'];
|
||||
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 testNotification>>, void> = () => {
|
||||
|
||||
|
||||
return testNotification(requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type TestNotificationMutationResult = NonNullable<Awaited<ReturnType<typeof testNotification>>>
|
||||
|
||||
export type TestNotificationMutationError = unknown
|
||||
|
||||
/**
|
||||
* @summary Test notification
|
||||
*/
|
||||
export const useTestNotification = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof testNotification>>, TError,void, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testNotification>>,
|
||||
TError,
|
||||
void,
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getTestNotificationMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* Triggers the cleanup of untracked S3 images
|
||||
* @summary Cleanup unused S3 images
|
||||
*/
|
||||
|
||||
@ -15,10 +15,10 @@ import type {
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
DefaultResponseDTOImportMangaDexResponseDTO,
|
||||
DefaultResponseDTOImportMangaResponseDTO,
|
||||
DefaultResponseDTOVoid,
|
||||
ImportMangaDexRequestDTO,
|
||||
ImportMultipleFilesBody
|
||||
ImportMultipleFilesBody,
|
||||
ImportRequestDTO
|
||||
} from '../api.schemas';
|
||||
|
||||
import { customInstance } from '../../api';
|
||||
@ -101,15 +101,15 @@ export const useImportMultipleFiles = <TError = unknown,
|
||||
* @summary Import manga from MangaDex
|
||||
*/
|
||||
export const importFromMangaDex = (
|
||||
importMangaDexRequestDTO: ImportMangaDexRequestDTO,
|
||||
importRequestDTO: ImportRequestDTO,
|
||||
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customInstance<DefaultResponseDTOImportMangaDexResponseDTO>(
|
||||
return customInstance<DefaultResponseDTOImportMangaResponseDTO>(
|
||||
{url: `/manga/import/manga-dex`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: importMangaDexRequestDTO, signal
|
||||
data: importRequestDTO, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
@ -117,8 +117,8 @@ export const importFromMangaDex = (
|
||||
|
||||
|
||||
export const getImportFromMangaDexMutationOptions = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportMangaDexRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportMangaDexRequestDTO}, TContext> => {
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportRequestDTO}, TContext> => {
|
||||
|
||||
const mutationKey = ['importFromMangaDex'];
|
||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
@ -130,7 +130,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof importFromMangaDex>>, {data: ImportMangaDexRequestDTO}> = (props) => {
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof importFromMangaDex>>, {data: ImportRequestDTO}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return importFromMangaDex(data,requestOptions)
|
||||
@ -142,18 +142,18 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type ImportFromMangaDexMutationResult = NonNullable<Awaited<ReturnType<typeof importFromMangaDex>>>
|
||||
export type ImportFromMangaDexMutationBody = ImportMangaDexRequestDTO
|
||||
export type ImportFromMangaDexMutationBody = ImportRequestDTO
|
||||
export type ImportFromMangaDexMutationError = unknown
|
||||
|
||||
/**
|
||||
* @summary Import manga from MangaDex
|
||||
*/
|
||||
export const useImportFromMangaDex = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportMangaDexRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof importFromMangaDex>>,
|
||||
TError,
|
||||
{data: ImportMangaDexRequestDTO},
|
||||
{data: ImportRequestDTO},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
@ -161,4 +161,69 @@ export const useImportFromMangaDex = <TError = unknown,
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* Imports manga data from Bato into the local database.
|
||||
* @summary Import manga from Bato
|
||||
*/
|
||||
export const importFromBato = (
|
||||
importRequestDTO: ImportRequestDTO,
|
||||
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customInstance<DefaultResponseDTOImportMangaResponseDTO>(
|
||||
{url: `/manga/import/bato`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: importRequestDTO, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getImportFromBatoMutationOptions = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromBato>>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof importFromBato>>, TError,{data: ImportRequestDTO}, TContext> => {
|
||||
|
||||
const mutationKey = ['importFromBato'];
|
||||
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 importFromBato>>, {data: ImportRequestDTO}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return importFromBato(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type ImportFromBatoMutationResult = NonNullable<Awaited<ReturnType<typeof importFromBato>>>
|
||||
export type ImportFromBatoMutationBody = ImportRequestDTO
|
||||
export type ImportFromBatoMutationError = unknown
|
||||
|
||||
/**
|
||||
* @summary Import manga from Bato
|
||||
*/
|
||||
export const useImportFromBato = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromBato>>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof importFromBato>>,
|
||||
TError,
|
||||
{data: ImportRequestDTO},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getImportFromBatoMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
|
||||
119
src/features/home/components/BatoImportDialog.tsx
Normal file
119
src/features/home/components/BatoImportDialog.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useImportFromBato } from "@/api/generated/manga-import/manga-import.ts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface BatoImportDialogProps {
|
||||
batoDialogOpen: boolean;
|
||||
onBatoDialogOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const BatoImportDialog = ({
|
||||
batoDialogOpen,
|
||||
onBatoDialogOpenChange,
|
||||
}: BatoImportDialogProps) => {
|
||||
const formSchema = z.object({
|
||||
value: z.string().min(1, "Please enter a Bato URL."),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
value: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: importBato, isPending: isPendingImportBato } =
|
||||
useImportFromBato({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onBatoDialogOpenChange(false);
|
||||
toast.success("Manga imported successfully!");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(data: z.infer<typeof formSchema>) => {
|
||||
const id = data.value;
|
||||
|
||||
importBato({ data: { id } });
|
||||
},
|
||||
[formSchema, importBato],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={batoDialogOpen} onOpenChange={onBatoDialogOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import from Bato</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a Bato manga URL to import it to your library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="importForm"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bato URL </FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., https://bato.two/title/..."
|
||||
disabled={isPendingImportBato}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onBatoDialogOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPendingImportBato}
|
||||
form="importForm"
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -10,6 +10,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||
import { BatoImportDialog } from "@/features/home/components/BatoImportDialog.tsx";
|
||||
import { MangaDexImportDialog } from "@/features/home/components/MangaDexImportDialog.tsx";
|
||||
import { MangaManualImportDialog } from "@/features/home/components/MangaManualImportDialog.tsx";
|
||||
|
||||
@ -17,6 +18,7 @@ export function ImportDropdown() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const [mangaDexDialogOpen, setMangaDexDialogOpen] = useState(false);
|
||||
const [batoDialogOpen, setBatoDialogOpen] = useState(false);
|
||||
const [fileImportDialogOpen, setFileImportDialogOpen] = useState(false);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
@ -37,6 +39,10 @@ export function ImportDropdown() {
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Import from MangaDex
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setBatoDialogOpen(true)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Import from Bato
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setFileImportDialogOpen(true)}>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Import from File
|
||||
@ -60,6 +66,11 @@ export function ImportDropdown() {
|
||||
onMangaDexDialogOpenChange={setMangaDexDialogOpen}
|
||||
/>
|
||||
|
||||
<BatoImportDialog
|
||||
batoDialogOpen={batoDialogOpen}
|
||||
onBatoDialogOpenChange={setBatoDialogOpen}
|
||||
/>
|
||||
|
||||
<MangaManualImportDialog
|
||||
fileImportDialogOpen={fileImportDialogOpen}
|
||||
onFileImportDialogOpenChange={setFileImportDialogOpen}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Home } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
useGetMangaChapterImages,
|
||||
@ -21,34 +21,95 @@ const Chapter = () => {
|
||||
getCurrentChapterPage(chapterNumber) ?? 1,
|
||||
);
|
||||
|
||||
const { data, isLoading } = useGetMangaChapterImages(chapterNumber);
|
||||
const [infiniteScroll, setInfiniteScroll] = useState(true);
|
||||
|
||||
const { data, isLoading } = useGetMangaChapterImages(chapterNumber);
|
||||
const { mutate } = useMarkAsRead();
|
||||
|
||||
// For infinite scroll mode
|
||||
const [visibleCount, setVisibleCount] = useState(1);
|
||||
const loadMoreRef = useRef(null);
|
||||
|
||||
/** Mark chapter as read when last page reached */
|
||||
useEffect(() => {
|
||||
if (!data || isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!data || isLoading) return;
|
||||
|
||||
if (currentPage === data.data?.chapterImageKeys.length) {
|
||||
mutate({ chapterId: chapterNumber });
|
||||
}
|
||||
}, [data, mutate, currentPage]);
|
||||
|
||||
/** Persist reading progress */
|
||||
useEffect(() => {
|
||||
setCurrentChapterPage(chapterNumber, currentPage);
|
||||
}, [chapterNumber, currentPage]);
|
||||
|
||||
/** Restore stored page */
|
||||
useEffect(() => {
|
||||
if (!isLoading && !data?.data) {
|
||||
return;
|
||||
if (!isLoading && data?.data) {
|
||||
const stored = getCurrentChapterPage(chapterNumber);
|
||||
if (stored) {
|
||||
setCurrentPage(stored);
|
||||
setVisibleCount(stored); // for infinite scroll
|
||||
}
|
||||
}
|
||||
}, [isLoading, data?.data]);
|
||||
|
||||
const storedChapterPage = getCurrentChapterPage(chapterNumber);
|
||||
if (storedChapterPage) {
|
||||
setCurrentPage(storedChapterPage);
|
||||
/** Infinite scroll observer */
|
||||
useEffect(() => {
|
||||
if (!infiniteScroll) return;
|
||||
if (!loadMoreRef.current) return;
|
||||
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setVisibleCount((count) =>
|
||||
Math.min(count + 2, data?.data?.chapterImageKeys.length ?? 0),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
obs.observe(loadMoreRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, [infiniteScroll, data?.data]);
|
||||
|
||||
/** Track which image is currently visible (for progress update) */
|
||||
useEffect(() => {
|
||||
if (!infiniteScroll) return;
|
||||
|
||||
const imgs = document.querySelectorAll("[data-page]");
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const el = entry.target as HTMLElement; // <-- FIX
|
||||
if (entry.isIntersecting) {
|
||||
const pageNum = Number(el.dataset.page); // <-- SAFE
|
||||
setCurrentPage(pageNum);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.5 },
|
||||
);
|
||||
|
||||
imgs.forEach((img) => observer.observe(img));
|
||||
return () => observer.disconnect();
|
||||
}, [infiniteScroll, visibleCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.data) return;
|
||||
|
||||
// When 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 });
|
||||
}
|
||||
}, [getCurrentChapterPage, isLoading, data?.data]);
|
||||
}, [infiniteScroll]);
|
||||
|
||||
if (!data?.data) {
|
||||
return (
|
||||
@ -65,27 +126,25 @@ const Chapter = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const goToNextPage = () => {
|
||||
if (!data?.data) {
|
||||
return;
|
||||
}
|
||||
const images = data.data.chapterImageKeys;
|
||||
|
||||
if (currentPage < data.data.chapterImageKeys.length) {
|
||||
setCurrentPage(currentPage + 1);
|
||||
/** Standard navigation (non-infinite mode) */
|
||||
const goToNextPage = () => {
|
||||
if (currentPage < images.length) {
|
||||
setCurrentPage((p) => p + 1);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage(currentPage - 1);
|
||||
setCurrentPage((p) => p - 1);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
{/* 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">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -99,6 +158,7 @@ const Chapter = () => {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@ -108,6 +168,7 @@ const Chapter = () => {
|
||||
<Home className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Home</span>
|
||||
</Button>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-sm font-semibold text-foreground">
|
||||
{data.data.mangaTitle}
|
||||
@ -117,19 +178,27 @@ const Chapter = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} / {data.data.chapterImageKeys.length}
|
||||
Page {currentPage} / {images.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setInfiniteScroll((v) => !v)}
|
||||
className="text-xs"
|
||||
>
|
||||
{infiniteScroll ? "Single Page Mode" : "Scroll Mode"}
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Reader Content */}
|
||||
{/* MAIN */}
|
||||
<main className="mx-auto max-w-4xl px-4 py-8">
|
||||
{/* Mobile title */}
|
||||
<div className="mb-4 sm:hidden">
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
{data.data.mangaTitle}
|
||||
@ -139,74 +208,74 @@ const Chapter = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Manga Page */}
|
||||
<div className="relative mx-auto mb-8 overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img
|
||||
src={
|
||||
import.meta.env.VITE_OMV_BASE_URL +
|
||||
"/" +
|
||||
data.data.chapterImageKeys[currentPage - 1] ||
|
||||
"/placeholder.svg"
|
||||
}
|
||||
alt={`Page ${currentPage}`}
|
||||
width={1000}
|
||||
height={1400}
|
||||
className="h-auto w-full"
|
||||
// priority
|
||||
/>
|
||||
</div>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* MODE 1 --- INFINITE SCROLL MODE */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{infiniteScroll ? (
|
||||
<div className="flex flex-col space-y-0">
|
||||
{images.slice(0, visibleCount).map((key, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
data-page={idx + 1}
|
||||
src={`${import.meta.env.VITE_OMV_BASE_URL}/${key}`}
|
||||
className="w-full h-auto block"
|
||||
alt={`Page ${idx + 1}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Navigation Controls */}
|
||||
<div className="space-y-4">
|
||||
{/* Page Navigation */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
variant="outline"
|
||||
className="gap-2 bg-transparent"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous Page
|
||||
</Button>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{currentPage} / {data.data.chapterImageKeys.length}
|
||||
</span>
|
||||
<Button
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage === data.data.chapterImageKeys.length}
|
||||
variant="outline"
|
||||
className="gap-2 bg-transparent"
|
||||
>
|
||||
Next Page
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* LOAD MORE SENTINEL */}
|
||||
<div ref={loadMoreRef} className="h-10" />
|
||||
</div>
|
||||
) : (
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MODE 2 --- STANDARD SINGLE-PAGE MODE */
|
||||
/* ------------------------------------------------------------------ */
|
||||
<>
|
||||
<div className="relative mx-auto mb-8 overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img
|
||||
src={
|
||||
import.meta.env.VITE_OMV_BASE_URL +
|
||||
"/" +
|
||||
images[currentPage - 1] || "/placeholder.svg"
|
||||
}
|
||||
alt={`Page ${currentPage}`}
|
||||
width={1000}
|
||||
height={1400}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*/!* Chapter Navigation *!/*/}
|
||||
{/*{(currentPage === data.chapterImageKeys.length || currentPage === 1) && (*/}
|
||||
{/* <div className="flex items-center justify-center gap-4 border-t border-border pt-4">*/}
|
||||
{/* <Button*/}
|
||||
{/* onClick={goToPreviousChapter}*/}
|
||||
{/* disabled={chapterNumber === 1}*/}
|
||||
{/* variant="secondary"*/}
|
||||
{/* className="gap-2"*/}
|
||||
{/* >*/}
|
||||
{/* <ChevronLeft className="h-4 w-4" />*/}
|
||||
{/* Previous Chapter*/}
|
||||
{/* </Button>*/}
|
||||
{/* <Button*/}
|
||||
{/* onClick={goToNextChapter}*/}
|
||||
{/* disabled={chapterNumber === manga.chapters}*/}
|
||||
{/* variant="secondary"*/}
|
||||
{/* className="gap-2"*/}
|
||||
{/* >*/}
|
||||
{/* Next Chapter*/}
|
||||
{/* <ChevronRight className="h-4 w-4" />*/}
|
||||
{/* </Button>*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
</div>
|
||||
{/* NAVIGATION BUTTONS */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
variant="outline"
|
||||
className="gap-2 bg-transparent"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous Page
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{currentPage} / {images.length}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage === images.length}
|
||||
variant="outline"
|
||||
className="gap-2 bg-transparent"
|
||||
>
|
||||
Next Page
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user