Merge pull request 'feat/image-exclusion-list' (#1) from feat/image-exclusion-list into main
Reviewed-on: #1
This commit is contained in:
commit
b621ac64f2
@ -54,11 +54,16 @@ Api.interceptors.response.use(
|
||||
}
|
||||
|
||||
const originalRequest = error.config as CustomAxiosRequestConfig;
|
||||
const errorDTO = error.response?.data as ErrorResponseDTO;
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
originalRequest.url !== "/authentication"
|
||||
) {
|
||||
const userDataStr = localStorage.getItem("userData");
|
||||
if (!userDataStr) {
|
||||
handleLogout();
|
||||
handleLogout(errorDTO?.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@ -119,7 +124,6 @@ Api.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
const errorDTO = error.response?.data as ErrorResponseDTO;
|
||||
if (errorDTO?.message) {
|
||||
toast.error(errorDTO.message);
|
||||
}
|
||||
@ -135,7 +139,9 @@ const handleLogout = (message?: string) => {
|
||||
toast.error(message);
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
};
|
||||
|
||||
export const customInstance = <T>(
|
||||
|
||||
@ -165,13 +165,18 @@ export interface DefaultResponseDTOMangaContentImagesDTO {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface MangaContentImageDTO {
|
||||
uuid?: string;
|
||||
objectKey?: string;
|
||||
}
|
||||
|
||||
export interface MangaContentImagesDTO {
|
||||
id: number;
|
||||
/** @minLength 1 */
|
||||
mangaTitle: string;
|
||||
previousContentId?: number;
|
||||
nextContentId?: number;
|
||||
contentImageKeys: string[];
|
||||
contentImages: MangaContentImageDTO[];
|
||||
}
|
||||
|
||||
export interface DefaultResponseDTOMangaImportJobPageResponseDTO {
|
||||
@ -221,9 +226,9 @@ export interface PageMangaImportJobDTO {
|
||||
number?: number;
|
||||
pageable?: PageableObject;
|
||||
numberOfElements?: number;
|
||||
sort?: SortObject;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
sort?: SortObject;
|
||||
empty?: boolean;
|
||||
}
|
||||
|
||||
@ -306,9 +311,9 @@ export interface PageMangaListDTO {
|
||||
number?: number;
|
||||
pageable?: PageableObject;
|
||||
numberOfElements?: number;
|
||||
sort?: SortObject;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
sort?: SortObject;
|
||||
empty?: boolean;
|
||||
}
|
||||
|
||||
|
||||
91
src/api/generated/image-exclusion/image-exclusion.ts
Normal file
91
src/api/generated/image-exclusion/image-exclusion.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* MangaMochi API
|
||||
* OpenAPI spec version: 1.0
|
||||
*/
|
||||
import {
|
||||
useMutation
|
||||
} from '@tanstack/react-query';
|
||||
import type {
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
UseMutationOptions,
|
||||
UseMutationResult
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
DefaultResponseDTOVoid
|
||||
} from '../api.schemas';
|
||||
|
||||
import { customInstance } from '../../api';
|
||||
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Gets the hash of the image with the given UUID and adds it to the exclusion list.
|
||||
* @summary Exclude image by UUID
|
||||
*/
|
||||
export const excludeImage = (
|
||||
uuid: string,
|
||||
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customInstance<DefaultResponseDTOVoid>(
|
||||
{url: `/management/image-exclusion/${encodeURIComponent(String(uuid))}`, method: 'POST', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getExcludeImageMutationOptions = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof excludeImage>>, TError,{uuid: string}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof excludeImage>>, TError,{uuid: string}, TContext> => {
|
||||
|
||||
const mutationKey = ['excludeImage'];
|
||||
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 excludeImage>>, {uuid: string}> = (props) => {
|
||||
const {uuid} = props ?? {};
|
||||
|
||||
return excludeImage(uuid,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type ExcludeImageMutationResult = NonNullable<Awaited<ReturnType<typeof excludeImage>>>
|
||||
|
||||
export type ExcludeImageMutationError = unknown
|
||||
|
||||
/**
|
||||
* @summary Exclude image by UUID
|
||||
*/
|
||||
export const useExcludeImage = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof excludeImage>>, TError,{uuid: string}, TContext>, request?: SecondParameter<typeof customInstance>}
|
||||
, queryClient?: QueryClient): UseMutationResult<
|
||||
Awaited<ReturnType<typeof excludeImage>>,
|
||||
TError,
|
||||
{uuid: string},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getExcludeImageMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
|
||||
250
src/components/ui/context-menu.tsx
Normal file
250
src/components/ui/context-menu.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@ -2,37 +2,124 @@ import { buildImgproxyUrl, generateSrcSet } from "@/utils/imgproxy.ts";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useExcludeImage } from "@/api/generated/image-exclusion/image-exclusion";
|
||||
|
||||
interface ChapterImageProps {
|
||||
imageKey: string;
|
||||
pageNumber: number;
|
||||
infiniteScroll?: boolean;
|
||||
priority?: boolean;
|
||||
imageKey: string;
|
||||
uuid: string;
|
||||
pageNumber: number;
|
||||
infiniteScroll?: boolean;
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
export const ChapterImage = ({ imageKey, pageNumber, infiniteScroll, priority }: ChapterImageProps) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
export const ChapterImage = ({
|
||||
imageKey,
|
||||
uuid,
|
||||
pageNumber,
|
||||
infiniteScroll,
|
||||
priority,
|
||||
}: ChapterImageProps) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const excludeMutation = useExcludeImage();
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"relative w-full overflow-hidden",
|
||||
isLoading && (infiniteScroll ? "min-h-[600px] aspect-[2/3]" : "aspect-[2/3]")
|
||||
)}>
|
||||
{isLoading && (
|
||||
<Skeleton className="absolute inset-0 z-10 h-full w-full" />
|
||||
)}
|
||||
<img
|
||||
src={buildImgproxyUrl(imageKey, 1200)}
|
||||
srcSet={generateSrcSet(imageKey)}
|
||||
sizes="(max-width: 896px) 100vw, 896px"
|
||||
className={cn(
|
||||
"w-full h-auto block m-0 p-0 border-none transition-opacity duration-300",
|
||||
isLoading ? "opacity-0" : "opacity-100"
|
||||
)}
|
||||
alt={`Page ${pageNumber}`}
|
||||
loading={priority ? "eager" : "lazy"}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const handleCopyLink = () => {
|
||||
const url = buildImgproxyUrl(imageKey, 1800);
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
toast.success("Image link copied to clipboard");
|
||||
});
|
||||
};
|
||||
|
||||
const handleExclude = async () => {
|
||||
try {
|
||||
await excludeMutation.mutateAsync({ uuid });
|
||||
toast.success("Image added to exclusion list");
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
toast.error("Failed to exclude image");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger disabled={isLoading}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden",
|
||||
isLoading && (infiniteScroll ? "min-h-[600px] aspect-[2/3]" : "aspect-[2/3]"),
|
||||
)}
|
||||
>
|
||||
{isLoading && (
|
||||
<Skeleton className="absolute inset-0 z-10 h-full w-full" />
|
||||
)}
|
||||
<img
|
||||
src={buildImgproxyUrl(imageKey, 1200)}
|
||||
srcSet={generateSrcSet(imageKey)}
|
||||
sizes="(max-width: 896px) 100vw, 896px"
|
||||
className={cn(
|
||||
"w-full h-auto block m-0 p-0 border-none transition-opacity duration-300",
|
||||
isLoading ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
alt={`Page ${pageNumber}`}
|
||||
loading={priority ? "eager" : "lazy"}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-64">
|
||||
<ContextMenuItem onClick={handleCopyLink}>
|
||||
Copy image link
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Add image to exclusion list
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Exclude Image</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to add this image to the exclusion list? This
|
||||
will prevent it from appearing in the reader for all users.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleExclude}
|
||||
disabled={excludeMutation.isPending}
|
||||
>
|
||||
{excludeMutation.isPending ? "Excluding..." : "Exclude Image"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -3,6 +3,7 @@ import {
|
||||
useGetProgress1,
|
||||
useUpdateProgress,
|
||||
} from "@/api/generated/reading-progress/reading-progress";
|
||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||
|
||||
interface ReadingTrackerData {
|
||||
chapterPage: { [chapterId: number]: number };
|
||||
@ -10,6 +11,7 @@ interface ReadingTrackerData {
|
||||
}
|
||||
|
||||
export const useReadingProgressSync = (mangaId: number, chapterId: number) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [currentPage, setCurrentPage] = useState<number | null>(() => {
|
||||
const jsonString = localStorage.getItem("readingTrackerData");
|
||||
if (jsonString) {
|
||||
@ -29,7 +31,7 @@ export const useReadingProgressSync = (mangaId: number, chapterId: number) => {
|
||||
const { data: progressData, isLoading: isLoadingProgress } = useGetProgress1(
|
||||
mangaId,
|
||||
chapterId,
|
||||
{ query: { retry: false } },
|
||||
{ query: { retry: false, enabled: isAuthenticated } },
|
||||
);
|
||||
const { mutate: updateProgress } = useUpdateProgress();
|
||||
|
||||
@ -82,6 +84,7 @@ export const useReadingProgressSync = (mangaId: number, chapterId: number) => {
|
||||
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
|
||||
|
||||
const performSync = () => {
|
||||
if (!isAuthenticated) return;
|
||||
if (forceSync || page !== lastSyncedPage.current) {
|
||||
updateProgress({
|
||||
data: {
|
||||
|
||||
@ -48,10 +48,15 @@ const Chapter = () => {
|
||||
|
||||
/** Conflict Resolution & Initial Sync */
|
||||
useEffect(() => {
|
||||
if (isLoadingProgress || hasInitialized) return;
|
||||
if (isLoadingProgress || !data?.data || hasInitialized) return;
|
||||
|
||||
const localPage = savedPage;
|
||||
const serverPage = serverProgress?.pageNumber;
|
||||
const images = data.data.contentImages;
|
||||
const maxPages = images.length || 1;
|
||||
|
||||
const localPage = savedPage ? Math.min(savedPage, maxPages) : null;
|
||||
const serverPage = serverProgress?.pageNumber
|
||||
? Math.min(serverProgress.pageNumber, maxPages)
|
||||
: null;
|
||||
|
||||
if (localPage === null) {
|
||||
// No local history for this chapter, use server or default to 1
|
||||
@ -87,13 +92,13 @@ const Chapter = () => {
|
||||
}
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}, [isLoadingProgress, savedPage, serverProgress, hasInitialized]);
|
||||
}, [isLoadingProgress, data, savedPage, serverProgress, hasInitialized]);
|
||||
|
||||
/** Mark chapter as read when last page reached */
|
||||
useEffect(() => {
|
||||
if (!data || isLoading || !hasInitialized || isAutoScrolling) return;
|
||||
|
||||
if (currentPage === data.data?.contentImageKeys.length) {
|
||||
if (currentPage === data.data?.contentImages.length) {
|
||||
mutate({ mangaContentId: chapterNumber });
|
||||
}
|
||||
}, [data, mutate, currentPage, hasInitialized, isAutoScrolling]);
|
||||
@ -151,7 +156,7 @@ const Chapter = () => {
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setVisibleCount((count) =>
|
||||
Math.min(count + 2, data?.data?.contentImageKeys.length ?? 0),
|
||||
Math.min(count + 2, data?.data?.contentImages.length ?? 0),
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -222,7 +227,7 @@ const Chapter = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const images = data.data.contentImageKeys;
|
||||
const images = data.data.contentImages;
|
||||
const previousContentId = data.data.previousContentId;
|
||||
const nextContentId = data.data.nextContentId;
|
||||
|
||||
@ -269,10 +274,10 @@ const Chapter = () => {
|
||||
this chapter.
|
||||
<br />
|
||||
<span className="mt-2 block">
|
||||
Local: <strong>Page {savedPage}</strong>
|
||||
Local: <strong>Page {savedPage ? Math.min(savedPage, images.length) : 1}</strong>
|
||||
</span>
|
||||
<span className="block">
|
||||
Server: <strong>Page {serverProgress?.pageNumber}</strong>
|
||||
Server: <strong>Page {serverProgress?.pageNumber ? Math.min(serverProgress.pageNumber, images.length) : 1}</strong>
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@ -281,11 +286,12 @@ const Chapter = () => {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (savedPage && savedPage > 1) {
|
||||
setCurrentPage(savedPage);
|
||||
setVisibleCount(savedPage);
|
||||
setIsAutoScrolling(true);
|
||||
const clamped = Math.min(savedPage, images.length);
|
||||
setCurrentPage(clamped);
|
||||
setVisibleCount(clamped);
|
||||
setIsAutoScrolling(clamped > 1);
|
||||
initialJumpDone.current = false;
|
||||
saveLocalProgress(savedPage, true);
|
||||
saveLocalProgress(clamped, true);
|
||||
}
|
||||
setHasInitialized(true);
|
||||
setShowConflictDialog(false);
|
||||
@ -297,9 +303,10 @@ const Chapter = () => {
|
||||
onClick={() => {
|
||||
const target = applyServerProgress();
|
||||
if (target) {
|
||||
setCurrentPage(target);
|
||||
setVisibleCount(target);
|
||||
if (target > 1) {
|
||||
const clamped = Math.min(target, images.length);
|
||||
setCurrentPage(clamped);
|
||||
setVisibleCount(clamped);
|
||||
if (clamped > 1) {
|
||||
setIsAutoScrolling(true);
|
||||
initialJumpDone.current = false;
|
||||
}
|
||||
@ -410,7 +417,7 @@ const Chapter = () => {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{infiniteScroll ? (
|
||||
<div className="flex flex-col space-y-0">
|
||||
{images.slice(0, visibleCount).map((key, idx) => (
|
||||
{images.slice(0, visibleCount).map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
data-page={idx + 1}
|
||||
@ -422,7 +429,8 @@ const Chapter = () => {
|
||||
}}
|
||||
>
|
||||
<ChapterImage
|
||||
imageKey={key}
|
||||
imageKey={img.objectKey || ""}
|
||||
uuid={img.uuid || ""}
|
||||
pageNumber={idx + 1}
|
||||
infiniteScroll
|
||||
/>
|
||||
@ -433,7 +441,8 @@ const Chapter = () => {
|
||||
{visibleCount < images.length && (
|
||||
<div style={{ display: "none" }} aria-hidden="true">
|
||||
<ChapterImage
|
||||
imageKey={images[visibleCount]}
|
||||
imageKey={images[visibleCount].objectKey || ""}
|
||||
uuid={images[visibleCount].uuid || ""}
|
||||
pageNumber={visibleCount + 1}
|
||||
priority
|
||||
/>
|
||||
@ -478,19 +487,27 @@ const Chapter = () => {
|
||||
/* MODE 2 --- STANDARD SINGLE-PAGE MODE */
|
||||
/* ------------------------------------------------------------------ */
|
||||
<>
|
||||
<div className="relative mx-auto mb-8 overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<ChapterImage
|
||||
imageKey={images[currentPage - 1]}
|
||||
pageNumber={currentPage}
|
||||
priority
|
||||
/>
|
||||
<div className="relative mx-auto mb-8 overflow-hidden rounded-lg border border-border bg-muted min-h-[300px]">
|
||||
{images[currentPage - 1] ? (
|
||||
<ChapterImage
|
||||
imageKey={images[currentPage - 1].objectKey || ""}
|
||||
uuid={images[currentPage - 1].uuid || ""}
|
||||
pageNumber={currentPage}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-muted-foreground">Page not found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preload next image */}
|
||||
{currentPage < images.length && (
|
||||
<div style={{ display: "none" }} aria-hidden="true">
|
||||
<ChapterImage
|
||||
imageKey={images[currentPage]}
|
||||
imageKey={images[currentPage].objectKey || ""}
|
||||
uuid={images[currentPage].uuid || ""}
|
||||
pageNumber={currentPage + 1}
|
||||
priority
|
||||
/>
|
||||
|
||||
@ -59,7 +59,7 @@ const Manga = () => {
|
||||
|
||||
const { data: mangaData, queryKey, isLoading } = useGetManga(mangaId);
|
||||
const { data: progressData } = useGetProgress(mangaId, {
|
||||
query: { retry: false },
|
||||
query: { retry: false, enabled: isAuthenticated },
|
||||
});
|
||||
|
||||
const { mutate, isPending: fetchPending } =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user