Compare commits

...

2 Commits

Author SHA1 Message Date
rov
5a06d2d738 Merge pull request 'feature(import): implement Bato manga import functionality' (#12) from feature/bato into main
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
Reviewed-on: #12
2025-11-13 23:12:09 -03:00
aca0d114fb feature(import): implement Bato manga import functionality
All checks were successful
ci/woodpecker/push/pipeline Pipeline was successful
ci/woodpecker/pr/pipeline Pipeline was successful
2025-11-13 23:04:38 -03:00
6 changed files with 495 additions and 105 deletions

View File

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

View File

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

View File

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

View 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>
);
};

View File

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

View File

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