From aca0d114fb6370bebef5e37697fa62cf10be41a5 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Thu, 13 Nov 2025 23:04:38 -0300 Subject: [PATCH] feature(import): implement Bato manga import functionality --- src/api/generated/api.schemas.ts | 10 +- src/api/generated/management/management.ts | 126 +++++++++ .../generated/manga-import/manga-import.ts | 89 ++++++- .../home/components/BatoImportDialog.tsx | 119 +++++++++ .../home/components/ImportDropdown.tsx | 11 + src/pages/Chapter.tsx | 245 +++++++++++------- 6 files changed, 495 insertions(+), 105 deletions(-) create mode 100644 src/features/home/components/BatoImportDialog.tsx diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index b169a68..582a29d 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -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; } diff --git a/src/api/generated/management/management.ts b/src/api/generated/management/management.ts index b89a469..53bab9e 100644 --- a/src/api/generated/management/management.ts +++ b/src/api/generated/management/management.ts @@ -26,6 +26,69 @@ type SecondParameter unknown> = Parameters[1]; /** + * Trigger user follow update + * @summary Trigger user follow update + */ +export const userFollowUpdate = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/management/user-follow`, method: 'POST', signal + }, + options); + } + + + +export const getUserFollowUpdateMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, void> = () => { + + + return userFollowUpdate(requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UserFollowUpdateMutationResult = NonNullable>> + + export type UserFollowUpdateMutationError = unknown + + /** + * @summary Trigger user follow update + */ +export const useUserFollowUpdate = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + 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 = ,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/management/test-notification`, method: 'POST', signal + }, + options); + } + + + +export const getTestNotificationMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, void> = () => { + + + return testNotification(requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type TestNotificationMutationResult = NonNullable>> + + export type TestNotificationMutationError = unknown + + /** + * @summary Test notification + */ +export const useTestNotification = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + void, + TContext + > => { + + const mutationOptions = getTestNotificationMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * Triggers the cleanup of untracked S3 images * @summary Cleanup unused S3 images */ diff --git a/src/api/generated/manga-import/manga-import.ts b/src/api/generated/manga-import/manga-import.ts index e9cf95b..b78c625 100644 --- a/src/api/generated/manga-import/manga-import.ts +++ b/src/api/generated/manga-import/manga-import.ts @@ -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 = ,signal?: AbortSignal ) => { - return customInstance( + return customInstance( {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 = (options?: { mutation?:UseMutationOptions>, TError,{data: ImportMangaDexRequestDTO}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{data: ImportMangaDexRequestDTO}, TContext> => { + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {data: ImportMangaDexRequestDTO}> = (props) => { + const mutationFn: MutationFunction>, {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>> - export type ImportFromMangaDexMutationBody = ImportMangaDexRequestDTO + export type ImportFromMangaDexMutationBody = ImportRequestDTO export type ImportFromMangaDexMutationError = unknown /** * @summary Import manga from MangaDex */ export const useImportFromMangaDex = (options?: { mutation?:UseMutationOptions>, TError,{data: ImportMangaDexRequestDTO}, TContext>, request?: SecondParameter} + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter} , queryClient?: QueryClient): UseMutationResult< Awaited>, TError, - {data: ImportMangaDexRequestDTO}, + {data: ImportRequestDTO}, TContext > => { @@ -161,4 +161,69 @@ export const useImportFromMangaDex = ,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/manga/import/bato`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: importRequestDTO, signal + }, + options); + } + + + +export const getImportFromBatoMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {data: ImportRequestDTO}> = (props) => { + const {data} = props ?? {}; + + return importFromBato(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type ImportFromBatoMutationResult = NonNullable>> + export type ImportFromBatoMutationBody = ImportRequestDTO + export type ImportFromBatoMutationError = unknown + + /** + * @summary Import manga from Bato + */ +export const useImportFromBato = (options?: { mutation?:UseMutationOptions>, TError,{data: ImportRequestDTO}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ImportRequestDTO}, + TContext + > => { + + const mutationOptions = getImportFromBatoMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } \ No newline at end of file diff --git a/src/features/home/components/BatoImportDialog.tsx b/src/features/home/components/BatoImportDialog.tsx new file mode 100644 index 0000000..de44bd5 --- /dev/null +++ b/src/features/home/components/BatoImportDialog.tsx @@ -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>({ + 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) => { + const id = data.value; + + importBato({ data: { id } }); + }, + [formSchema, importBato], + ); + + return ( + + + + Import from Bato + + Enter a Bato manga URL to import it to your library. + + +
+ + ( + + Bato URL + + + + + + )} + /> + + + + + + +
+
+ ); +}; diff --git a/src/features/home/components/ImportDropdown.tsx b/src/features/home/components/ImportDropdown.tsx index b87cd57..ca5b18f 100644 --- a/src/features/home/components/ImportDropdown.tsx +++ b/src/features/home/components/ImportDropdown.tsx @@ -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() { Import from MangaDex + setBatoDialogOpen(true)}> + + Import from Bato + setFileImportDialogOpen(true)}> Import from File @@ -60,6 +66,11 @@ export function ImportDropdown() { onMangaDexDialogOpenChange={setMangaDexDialogOpen} /> + + { 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 (
- {/* Header */} + {/* HEADER */}
@@ -99,6 +158,7 @@ const Chapter = () => { Back + +

{data.data.mangaTitle} @@ -117,19 +178,27 @@ const Chapter = () => {

+
- Page {currentPage} / {data.data.chapterImageKeys.length} + Page {currentPage} / {images.length} +
- {/* Reader Content */} + {/* MAIN */}
- {/* Mobile title */}

{data.data.mangaTitle} @@ -139,74 +208,74 @@ const Chapter = () => {

- {/* Manga Page */} -
- {`Page -
+ {/* ------------------------------------------------------------------ */} + {/* MODE 1 --- INFINITE SCROLL MODE */} + {/* ------------------------------------------------------------------ */} + {infiniteScroll ? ( +
+ {images.slice(0, visibleCount).map((key, idx) => ( + {`Page + ))} - {/* Navigation Controls */} -
- {/* Page Navigation */} -
- - - {currentPage} / {data.data.chapterImageKeys.length} - - + {/* LOAD MORE SENTINEL */} +
+ ) : ( + /* ------------------------------------------------------------------ */ + /* MODE 2 --- STANDARD SINGLE-PAGE MODE */ + /* ------------------------------------------------------------------ */ + <> +
+ {`Page +
- {/*/!* Chapter Navigation *!/*/} - {/*{(currentPage === data.chapterImageKeys.length || currentPage === 1) && (*/} - {/*
*/} - {/* */} - {/* */} - {/* Previous Chapter*/} - {/* */} - {/* */} - {/* Next Chapter*/} - {/* */} - {/* */} - {/*
*/} - {/*)}*/} -
+ {/* NAVIGATION BUTTONS */} +
+
+ + + + {currentPage} / {images.length} + + + +
+
+ + )}
);