Compare commits

..

No commits in common. "5a06d2d73873b7e4ce6f0596eeb35ba91d7f446d" and "1cdfc905e4748e8dd5d3f851278f3e7a1addcbf1" have entirely different histories.

6 changed files with 105 additions and 495 deletions

View File

@ -10,17 +10,17 @@ export interface DefaultResponseDTOVoid {
message?: string; message?: string;
} }
export interface ImportRequestDTO { export interface ImportMangaDexRequestDTO {
id: string; id: string;
} }
export interface DefaultResponseDTOImportMangaResponseDTO { export interface DefaultResponseDTOImportMangaDexResponseDTO {
timestamp?: string; timestamp?: string;
data?: ImportMangaResponseDTO; data?: ImportMangaDexResponseDTO;
message?: string; message?: string;
} }
export interface ImportMangaResponseDTO { export interface ImportMangaDexResponseDTO {
id: number; id: number;
} }
@ -99,9 +99,9 @@ export interface PageMangaListDTO {
export interface PageableObject { export interface PageableObject {
offset?: number; offset?: number;
paged?: boolean;
pageNumber?: number; pageNumber?: number;
pageSize?: number; pageSize?: number;
paged?: boolean;
sort?: SortObject; sort?: SortObject;
unpaged?: boolean; unpaged?: boolean;
} }

View File

@ -26,69 +26,6 @@ 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 * Queue the retrieval of the manga lists from the content providers
* @summary Queue update manga list * @summary Queue update manga list
*/ */
@ -152,69 +89,6 @@ export const useUpdateMangaList = <TError = unknown,
return useMutation(mutationOptions, queryClient); 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 * Triggers the cleanup of untracked S3 images
* @summary Cleanup unused S3 images * @summary Cleanup unused S3 images
*/ */

View File

@ -15,10 +15,10 @@ import type {
} from '@tanstack/react-query'; } from '@tanstack/react-query';
import type { import type {
DefaultResponseDTOImportMangaResponseDTO, DefaultResponseDTOImportMangaDexResponseDTO,
DefaultResponseDTOVoid, DefaultResponseDTOVoid,
ImportMultipleFilesBody, ImportMangaDexRequestDTO,
ImportRequestDTO ImportMultipleFilesBody
} from '../api.schemas'; } from '../api.schemas';
import { customInstance } from '../../api'; import { customInstance } from '../../api';
@ -101,15 +101,15 @@ export const useImportMultipleFiles = <TError = unknown,
* @summary Import manga from MangaDex * @summary Import manga from MangaDex
*/ */
export const importFromMangaDex = ( export const importFromMangaDex = (
importRequestDTO: ImportRequestDTO, importMangaDexRequestDTO: ImportMangaDexRequestDTO,
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => { ) => {
return customInstance<DefaultResponseDTOImportMangaResponseDTO>( return customInstance<DefaultResponseDTOImportMangaDexResponseDTO>(
{url: `/manga/import/manga-dex`, method: 'POST', {url: `/manga/import/manga-dex`, method: 'POST',
headers: {'Content-Type': 'application/json', }, headers: {'Content-Type': 'application/json', },
data: importRequestDTO, signal data: importMangaDexRequestDTO, signal
}, },
options); options);
} }
@ -117,8 +117,8 @@ export const importFromMangaDex = (
export const getImportFromMangaDexMutationOptions = <TError = unknown, export const getImportFromMangaDexMutationOptions = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportMangaDexRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
): UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportRequestDTO}, TContext> => { ): UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportMangaDexRequestDTO}, TContext> => {
const mutationKey = ['importFromMangaDex']; const mutationKey = ['importFromMangaDex'];
const {mutation: mutationOptions, request: requestOptions} = options ? const {mutation: mutationOptions, request: requestOptions} = options ?
@ -130,7 +130,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
const mutationFn: MutationFunction<Awaited<ReturnType<typeof importFromMangaDex>>, {data: ImportRequestDTO}> = (props) => { const mutationFn: MutationFunction<Awaited<ReturnType<typeof importFromMangaDex>>, {data: ImportMangaDexRequestDTO}> = (props) => {
const {data} = props ?? {}; const {data} = props ?? {};
return importFromMangaDex(data,requestOptions) return importFromMangaDex(data,requestOptions)
@ -142,18 +142,18 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
return { mutationFn, ...mutationOptions }} return { mutationFn, ...mutationOptions }}
export type ImportFromMangaDexMutationResult = NonNullable<Awaited<ReturnType<typeof importFromMangaDex>>> export type ImportFromMangaDexMutationResult = NonNullable<Awaited<ReturnType<typeof importFromMangaDex>>>
export type ImportFromMangaDexMutationBody = ImportRequestDTO export type ImportFromMangaDexMutationBody = ImportMangaDexRequestDTO
export type ImportFromMangaDexMutationError = unknown export type ImportFromMangaDexMutationError = unknown
/** /**
* @summary Import manga from MangaDex * @summary Import manga from MangaDex
*/ */
export const useImportFromMangaDex = <TError = unknown, export const useImportFromMangaDex = <TError = unknown,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>} TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importFromMangaDex>>, TError,{data: ImportMangaDexRequestDTO}, TContext>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient): UseMutationResult< , queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof importFromMangaDex>>, Awaited<ReturnType<typeof importFromMangaDex>>,
TError, TError,
{data: ImportRequestDTO}, {data: ImportMangaDexRequestDTO},
TContext TContext
> => { > => {
@ -161,69 +161,4 @@ export const useImportFromMangaDex = <TError = unknown,
return useMutation(mutationOptions, queryClient); 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);
}

View File

@ -1,119 +0,0 @@
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>
);
};

View File

@ -10,7 +10,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useAuth } from "@/contexts/AuthContext.tsx"; import { useAuth } from "@/contexts/AuthContext.tsx";
import { BatoImportDialog } from "@/features/home/components/BatoImportDialog.tsx";
import { MangaDexImportDialog } from "@/features/home/components/MangaDexImportDialog.tsx"; import { MangaDexImportDialog } from "@/features/home/components/MangaDexImportDialog.tsx";
import { MangaManualImportDialog } from "@/features/home/components/MangaManualImportDialog.tsx"; import { MangaManualImportDialog } from "@/features/home/components/MangaManualImportDialog.tsx";
@ -18,7 +17,6 @@ export function ImportDropdown() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const [mangaDexDialogOpen, setMangaDexDialogOpen] = useState(false); const [mangaDexDialogOpen, setMangaDexDialogOpen] = useState(false);
const [batoDialogOpen, setBatoDialogOpen] = useState(false);
const [fileImportDialogOpen, setFileImportDialogOpen] = useState(false); const [fileImportDialogOpen, setFileImportDialogOpen] = useState(false);
if (!isAuthenticated) { if (!isAuthenticated) {
@ -39,10 +37,6 @@ export function ImportDropdown() {
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Import from MangaDex Import from MangaDex
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setBatoDialogOpen(true)}>
<Download className="mr-2 h-4 w-4" />
Import from Bato
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFileImportDialogOpen(true)}> <DropdownMenuItem onClick={() => setFileImportDialogOpen(true)}>
<FileUp className="mr-2 h-4 w-4" /> <FileUp className="mr-2 h-4 w-4" />
Import from File Import from File
@ -66,11 +60,6 @@ export function ImportDropdown() {
onMangaDexDialogOpenChange={setMangaDexDialogOpen} onMangaDexDialogOpenChange={setMangaDexDialogOpen}
/> />
<BatoImportDialog
batoDialogOpen={batoDialogOpen}
onBatoDialogOpenChange={setBatoDialogOpen}
/>
<MangaManualImportDialog <MangaManualImportDialog
fileImportDialogOpen={fileImportDialogOpen} fileImportDialogOpen={fileImportDialogOpen}
onFileImportDialogOpenChange={setFileImportDialogOpen} onFileImportDialogOpenChange={setFileImportDialogOpen}

View File

@ -1,5 +1,5 @@
import { ArrowLeft, ChevronLeft, ChevronRight, Home } from "lucide-react"; import { ArrowLeft, ChevronLeft, ChevronRight, Home } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
useGetMangaChapterImages, useGetMangaChapterImages,
@ -21,95 +21,34 @@ const Chapter = () => {
getCurrentChapterPage(chapterNumber) ?? 1, getCurrentChapterPage(chapterNumber) ?? 1,
); );
const [infiniteScroll, setInfiniteScroll] = useState(true);
const { data, isLoading } = useGetMangaChapterImages(chapterNumber); const { data, isLoading } = useGetMangaChapterImages(chapterNumber);
const { mutate } = useMarkAsRead(); 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(() => { useEffect(() => {
if (!data || isLoading) return; if (!data || isLoading) {
return;
}
if (currentPage === data.data?.chapterImageKeys.length) { if (currentPage === data.data?.chapterImageKeys.length) {
mutate({ chapterId: chapterNumber }); mutate({ chapterId: chapterNumber });
} }
}, [data, mutate, currentPage]); }, [data, mutate, currentPage]);
/** Persist reading progress */
useEffect(() => { useEffect(() => {
setCurrentChapterPage(chapterNumber, currentPage); setCurrentChapterPage(chapterNumber, currentPage);
}, [chapterNumber, currentPage]); }, [chapterNumber, currentPage]);
/** Restore stored page */
useEffect(() => { useEffect(() => {
if (!isLoading && data?.data) { if (!isLoading && !data?.data) {
const stored = getCurrentChapterPage(chapterNumber); return;
if (stored) {
setCurrentPage(stored);
setVisibleCount(stored); // for infinite scroll
}
} }
}, [isLoading, data?.data]);
/** Infinite scroll observer */ const storedChapterPage = getCurrentChapterPage(chapterNumber);
useEffect(() => { if (storedChapterPage) {
if (!infiniteScroll) return; setCurrentPage(storedChapterPage);
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 });
} }
}, [infiniteScroll]); }, [getCurrentChapterPage, isLoading, data?.data]);
if (!data?.data) { if (!data?.data) {
return ( return (
@ -126,25 +65,27 @@ const Chapter = () => {
); );
} }
const images = data.data.chapterImageKeys;
/** Standard navigation (non-infinite mode) */
const goToNextPage = () => { const goToNextPage = () => {
if (currentPage < images.length) { if (!data?.data) {
setCurrentPage((p) => p + 1); return;
}
if (currentPage < data.data.chapterImageKeys.length) {
setCurrentPage(currentPage + 1);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
}; };
const goToPreviousPage = () => { const goToPreviousPage = () => {
if (currentPage > 1) { if (currentPage > 1) {
setCurrentPage((p) => p - 1); setCurrentPage(currentPage - 1);
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
}; };
return ( return (
<div className="min-h-screen bg-background"> <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"> <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">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -158,7 +99,6 @@ const Chapter = () => {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Back</span> <span className="hidden sm:inline">Back</span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -168,7 +108,6 @@ const Chapter = () => {
<Home className="h-4 w-4" /> <Home className="h-4 w-4" />
<span className="hidden sm:inline">Home</span> <span className="hidden sm:inline">Home</span>
</Button> </Button>
<div className="hidden sm:block"> <div className="hidden sm:block">
<h1 className="text-sm font-semibold text-foreground"> <h1 className="text-sm font-semibold text-foreground">
{data.data.mangaTitle} {data.data.mangaTitle}
@ -178,27 +117,19 @@ const Chapter = () => {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Page {currentPage} / {images.length} Page {currentPage} / {data.data.chapterImageKeys.length}
</span> </span>
<Button
variant="outline"
size="sm"
onClick={() => setInfiniteScroll((v) => !v)}
className="text-xs"
>
{infiniteScroll ? "Single Page Mode" : "Scroll Mode"}
</Button>
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
</div> </div>
</header> </header>
{/* MAIN */} {/* Reader Content */}
<main className="mx-auto max-w-4xl px-4 py-8"> <main className="mx-auto max-w-4xl px-4 py-8">
{/* Mobile title */}
<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}
@ -208,74 +139,74 @@ const Chapter = () => {
</p> </p>
</div> </div>
{/* ------------------------------------------------------------------ */} {/* Manga Page */}
{/* MODE 1 --- INFINITE SCROLL MODE */} <div className="relative mx-auto mb-8 overflow-hidden rounded-lg border border-border bg-muted">
{/* ------------------------------------------------------------------ */} <img
{infiniteScroll ? ( src={
<div className="flex flex-col space-y-0"> import.meta.env.VITE_OMV_BASE_URL +
{images.slice(0, visibleCount).map((key, idx) => ( "/" +
<img data.data.chapterImageKeys[currentPage - 1] ||
key={idx} "/placeholder.svg"
data-page={idx + 1} }
src={`${import.meta.env.VITE_OMV_BASE_URL}/${key}`} alt={`Page ${currentPage}`}
className="w-full h-auto block" width={1000}
alt={`Page ${idx + 1}`} height={1400}
loading="lazy" className="h-auto w-full"
/> // priority
))} />
</div>
{/* LOAD MORE SENTINEL */} {/* Navigation Controls */}
<div ref={loadMoreRef} className="h-10" /> <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>
</div> </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>
{/* NAVIGATION BUTTONS */} {/*/!* Chapter Navigation *!/*/}
<div className="space-y-4"> {/*{(currentPage === data.chapterImageKeys.length || currentPage === 1) && (*/}
<div className="flex items-center justify-center gap-4"> {/* <div className="flex items-center justify-center gap-4 border-t border-border pt-4">*/}
<Button {/* <Button*/}
onClick={goToPreviousPage} {/* onClick={goToPreviousChapter}*/}
disabled={currentPage === 1} {/* disabled={chapterNumber === 1}*/}
variant="outline" {/* variant="secondary"*/}
className="gap-2 bg-transparent" {/* className="gap-2"*/}
> {/* >*/}
<ChevronLeft className="h-4 w-4" /> {/* <ChevronLeft className="h-4 w-4" />*/}
Previous Page {/* Previous Chapter*/}
</Button> {/* </Button>*/}
{/* <Button*/}
<span className="text-sm font-medium text-foreground"> {/* onClick={goToNextChapter}*/}
{currentPage} / {images.length} {/* disabled={chapterNumber === manga.chapters}*/}
</span> {/* variant="secondary"*/}
{/* className="gap-2"*/}
<Button {/* >*/}
onClick={goToNextPage} {/* Next Chapter*/}
disabled={currentPage === images.length} {/* <ChevronRight className="h-4 w-4" />*/}
variant="outline" {/* </Button>*/}
className="gap-2 bg-transparent" {/* </div>*/}
> {/*)}*/}
Next Page </div>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</main> </main>
</div> </div>
); );