refactor: Enhance FailedImportJobs component with improved state management and UI updates

This commit is contained in:
Rodrigo Verdiani 2026-03-28 14:30:52 -03:00
parent 6ea9eaf2ee
commit c544657720
36 changed files with 1358 additions and 1057 deletions

164
package-lock.json generated
View File

@ -40,6 +40,7 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.9",
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@types/node": "^24.9.2", "@types/node": "^24.9.2",
"@types/react": "^19.1.16", "@types/react": "^19.1.16",
@ -432,6 +433,169 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@biomejs/biome": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.9.tgz",
"integrity": "sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.4.9",
"@biomejs/cli-darwin-x64": "2.4.9",
"@biomejs/cli-linux-arm64": "2.4.9",
"@biomejs/cli-linux-arm64-musl": "2.4.9",
"@biomejs/cli-linux-x64": "2.4.9",
"@biomejs/cli-linux-x64-musl": "2.4.9",
"@biomejs/cli-win32-arm64": "2.4.9",
"@biomejs/cli-win32-x64": "2.4.9"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.9.tgz",
"integrity": "sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.9.tgz",
"integrity": "sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.9.tgz",
"integrity": "sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.9.tgz",
"integrity": "sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.9.tgz",
"integrity": "sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.9.tgz",
"integrity": "sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.9.tgz",
"integrity": "sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.9.tgz",
"integrity": "sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@commander-js/extra-typings": { "node_modules/@commander-js/extra-typings": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz",

View File

@ -42,6 +42,7 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.9",
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@types/node": "^24.9.2", "@types/node": "^24.9.2",
"@types/react": "^19.1.16", "@types/react": "^19.1.16",

View File

@ -145,9 +145,9 @@ export interface MangaContentImagesDTO {
contentImageKeys: string[]; contentImageKeys: string[];
} }
export interface DefaultResponseDTOListMangaImportJobDTO { export interface DefaultResponseDTOMangaImportJobPageResponseDTO {
timestamp?: string; timestamp?: string;
data?: MangaImportJobDTO[]; data?: MangaImportJobPageResponseDTO;
message?: string; message?: string;
} }
@ -175,6 +175,44 @@ export interface MangaImportJobDTO {
errorStackTrace?: string; errorStackTrace?: string;
} }
export interface MangaImportJobPageResponseDTO {
page?: PageMangaImportJobDTO;
totalJobs?: number;
pendingJobs?: number;
processingJobs?: number;
completedJobs?: number;
failedJobs?: number;
}
export interface PageMangaImportJobDTO {
totalPages?: number;
totalElements?: number;
size?: number;
content?: MangaImportJobDTO[];
number?: number;
pageable?: PageableObject;
numberOfElements?: number;
sort?: SortObject;
first?: boolean;
last?: boolean;
empty?: boolean;
}
export interface PageableObject {
offset?: number;
pageNumber?: number;
pageSize?: number;
paged?: boolean;
unpaged?: boolean;
sort?: SortObject;
}
export interface SortObject {
empty?: boolean;
sorted?: boolean;
unsorted?: boolean;
}
export interface DefaultResponseDTOPageMangaListDTO { export interface DefaultResponseDTOPageMangaListDTO {
timestamp?: string; timestamp?: string;
data?: PageMangaListDTO; data?: PageMangaListDTO;
@ -222,21 +260,6 @@ export interface PageMangaListDTO {
empty?: boolean; empty?: boolean;
} }
export interface PageableObject {
offset?: number;
pageNumber?: number;
pageSize?: number;
paged?: boolean;
unpaged?: boolean;
sort?: SortObject;
}
export interface SortObject {
empty?: boolean;
sorted?: boolean;
unsorted?: boolean;
}
export interface DefaultResponseDTOMangaDTO { export interface DefaultResponseDTOMangaDTO {
timestamp?: string; timestamp?: string;
data?: MangaDTO; data?: MangaDTO;
@ -337,6 +360,36 @@ export type GetContentProvidersParams = {
manualImport?: boolean; manualImport?: boolean;
}; };
export type GetMangaImportJobsParams = {
searchQuery?: string;
status?: GetMangaImportJobsStatus;
/**
* Zero-based page index (0..N)
* @minimum 0
*/
page?: number;
/**
* The size of the page to be returned
* @minimum 1
*/
size?: number;
/**
* Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.
*/
sort?: string[];
};
export type GetMangaImportJobsStatus = typeof GetMangaImportJobsStatus[keyof typeof GetMangaImportJobsStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetMangaImportJobsStatus = {
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
SUCCESS: 'SUCCESS',
FAILED: 'FAILED',
} as const;
export type GetMangasParams = { export type GetMangasParams = {
searchQuery?: string; searchQuery?: string;
genreIds?: number[]; genreIds?: number[];

View File

@ -25,12 +25,13 @@ import type {
import type { import type {
DefaultResponseDTOListMangaContentDTO, DefaultResponseDTOListMangaContentDTO,
DefaultResponseDTOListMangaImportJobDTO,
DefaultResponseDTOMangaContentImagesDTO, DefaultResponseDTOMangaContentImagesDTO,
DefaultResponseDTOMangaImportJobPageResponseDTO,
DefaultResponseDTOPresignedImportResponseDTO, DefaultResponseDTOPresignedImportResponseDTO,
DefaultResponseDTOVoid, DefaultResponseDTOVoid,
DownloadContentArchiveParams, DownloadContentArchiveParams,
FileImportRequestDTO, FileImportRequestDTO,
GetMangaImportJobsParams,
PresignedImportRequestDTO PresignedImportRequestDTO
} from '../api.schemas'; } from '../api.schemas';
@ -434,17 +435,18 @@ export function useGetMangaContentImages<TData = Awaited<ReturnType<typeof getMa
/** /**
* Returns a list of manga import jobs. * Returns a paginated list of manga import jobs with optional filters and global status counts.
* @summary Get a list of manga import jobs * @summary Get a paginated list of manga import jobs
*/ */
export const getMangaImportJobs = ( export const getMangaImportJobs = (
params?: GetMangaImportJobsParams,
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
) => { ) => {
return customInstance<DefaultResponseDTOListMangaImportJobDTO>( return customInstance<DefaultResponseDTOMangaImportJobPageResponseDTO>(
{url: `/content/import/jobs`, method: 'GET', signal {url: `/content/import/jobs`, method: 'GET',
params, signal
}, },
options); options);
} }
@ -452,23 +454,23 @@ export const getMangaImportJobs = (
export const getGetMangaImportJobsQueryKey = () => { export const getGetMangaImportJobsQueryKey = (params?: GetMangaImportJobsParams,) => {
return [ return [
`/content/import/jobs` `/content/import/jobs`, ...(params ? [params]: [])
] as const; ] as const;
} }
export const getGetMangaImportJobsQueryOptions = <TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>>, request?: SecondParameter<typeof customInstance>} export const getGetMangaImportJobsQueryOptions = <TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>(params?: GetMangaImportJobsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
) => { ) => {
const {query: queryOptions, request: requestOptions} = options ?? {}; const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetMangaImportJobsQueryKey(); const queryKey = queryOptions?.queryKey ?? getGetMangaImportJobsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMangaImportJobs>>> = ({ signal }) => getMangaImportJobs(requestOptions, signal); const queryFn: QueryFunction<Awaited<ReturnType<typeof getMangaImportJobs>>> = ({ signal }) => getMangaImportJobs(params, requestOptions, signal);
@ -482,7 +484,7 @@ export type GetMangaImportJobsQueryError = unknown
export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>( export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>(
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>> & Pick< params: undefined | GetMangaImportJobsParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>> & Pick<
DefinedInitialDataOptions< DefinedInitialDataOptions<
Awaited<ReturnType<typeof getMangaImportJobs>>, Awaited<ReturnType<typeof getMangaImportJobs>>,
TError, TError,
@ -492,7 +494,7 @@ export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getManga
, queryClient?: QueryClient , queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } ): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>( export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>> & Pick< params?: GetMangaImportJobsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>> & Pick<
UndefinedInitialDataOptions< UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getMangaImportJobs>>, Awaited<ReturnType<typeof getMangaImportJobs>>,
TError, TError,
@ -502,19 +504,19 @@ export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getManga
, queryClient?: QueryClient , queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>( export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>>, request?: SecondParameter<typeof customInstance>} params?: GetMangaImportJobsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient , queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/** /**
* @summary Get a list of manga import jobs * @summary Get a paginated list of manga import jobs
*/ */
export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>( export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getMangaImportJobs>>, TError = unknown>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>>, request?: SecondParameter<typeof customInstance>} params?: GetMangaImportJobsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData>>, request?: SecondParameter<typeof customInstance>}
, queryClient?: QueryClient , queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } { ): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetMangaImportJobsQueryOptions(options) const queryOptions = getGetMangaImportJobsQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }; const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };

View File

@ -63,4 +63,4 @@ function AlertDescription({
); );
} }
export { Alert, AlertTitle, AlertDescription }; export { Alert, AlertDescription, AlertTitle };

View File

@ -48,4 +48,4 @@ function AvatarFallback({
); );
} }
export { Avatar, AvatarImage, AvatarFallback }; export { Avatar, AvatarFallback, AvatarImage };

View File

@ -83,10 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
export { export {
Card, Card,
CardHeader,
CardFooter,
CardTitle,
CardAction, CardAction,
CardDescription,
CardContent, CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
}; };

View File

@ -28,4 +28,4 @@ function CollapsibleContent({
); );
} }
export { Collapsible, CollapsibleTrigger, CollapsibleContent }; export { Collapsible, CollapsibleContent, CollapsibleTrigger };

View File

@ -238,18 +238,18 @@ function DropdownMenuSubContent({
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal, DropdownMenuCheckboxItem,
DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}; };

View File

@ -156,12 +156,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
} }
export { export {
useFormField,
Form, Form,
FormItem,
FormLabel,
FormControl, FormControl,
FormDescription, FormDescription,
FormMessage,
FormField, FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
}; };

View File

@ -1,7 +1,7 @@
import * as React from "react" import { Progress as ProgressPrimitive } from "radix-ui";
import { Progress as ProgressPrimitive } from "radix-ui" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Progress({ function Progress({
className, className,
@ -13,7 +13,7 @@ function Progress({
data-slot="progress" data-slot="progress"
className={cn( className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20", "relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className className,
)} )}
{...props} {...props}
> >
@ -23,7 +23,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) );
} }
export { Progress } export { Progress };

View File

@ -1,6 +1,6 @@
import * as React from "react" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@ -99,16 +99,16 @@ function TableCaption({
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
export { export {
Table, Table,
TableHeader,
TableBody, TableBody,
TableCaption,
TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader,
TableRow, TableRow,
TableCell, };
TableCaption,
}

View File

@ -61,4 +61,4 @@ function TabsContent({
); );
} }
export { Tabs, TabsList, TabsTrigger, TabsContent }; export { Tabs, TabsContent, TabsList, TabsTrigger };

View File

@ -6,8 +6,8 @@ import {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import {useRegisterUser} from "@/api/generated/user/user.ts";
import { useLogin } from "@/api/generated/authentication/authentication.ts"; import { useLogin } from "@/api/generated/authentication/authentication.ts";
import { useRegisterUser } from "@/api/generated/user/user.ts";
export interface UserPreferences { export interface UserPreferences {
theme: "light" | "dark"; theme: "light" | "dark";

View File

@ -1,6 +1,6 @@
import { import {
type ReactNode,
createContext, createContext,
type ReactNode,
useCallback, useCallback,
useContext, useContext,
useMemo, useMemo,
@ -58,8 +58,6 @@ export const UIStateProvider = ({ children }: { children: ReactNode }) => {
Record<string, number> Record<string, number>
>({}); >({});
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
setCurrentPage(1); setCurrentPage(1);
setSelectedGenres([]); setSelectedGenres([]);

View File

@ -1,31 +1,58 @@
import {useGetMangaImportJobs} from "@/api/generated/content/content.ts"; import { AlertTriangle, ExternalLink, FileText, Search } from "lucide-react";
import type {MangaImportJobDTO, MangaImportJobDTOStatus} from "@/api/generated/api.schemas.ts";
import { Badge } from "@/components/ui/badge";
import {AlertTriangle, ExternalLink, FileText, RefreshCw} from "lucide-react";
import {Card} from "@/components/ui/card.tsx";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table.tsx";
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog.tsx";
import { useState } from "react"; import { useState } from "react";
import { useDebounce } from "use-debounce";
import type {
MangaImportJobDTO,
MangaImportJobDTOStatus,
} from "@/api/generated/api.schemas.ts";
import { useGetMangaImportJobs } from "@/api/generated/content/content.ts";
import { Pagination } from "@/components/Pagination.tsx";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import { Input } from "@/components/ui/input.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
export const FailedImportJobs = () => { export const FailedImportJobs = () => {
const {data} = useGetMangaImportJobs(); const [searchQueryText, setSearchQueryText] = useState("");
const importJobs = data?.data; const [searchQuery] = useDebounce(searchQueryText, 500);
const [statusFilter, setStatusFilter] = useState<
MangaImportJobDTOStatus | undefined
>(undefined);
const [selectedJob, setSelectedJob] = useState<MangaImportJobDTO | null>(
null,
);
const [errorDialogOpen, setErrorDialogOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
// const [searchQuery, setSearchQuery] = useState("") const { data } = useGetMangaImportJobs({
// const [statusFilter, setStatusFilter] = useState<ImportStatus | "ALL">("ALL") status: statusFilter,
const [selectedJob, setSelectedJob] = useState<MangaImportJobDTO | null>(null) searchQuery: searchQuery,
const [errorDialogOpen, setErrorDialogOpen] = useState(false) page: currentPage - 1,
size: 12,
// const filteredJobs = jobs.filter((job) => { });
// const matchesSearch = const importJobsData = data?.data;
// job.filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
// job.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
// job.malId?.includes(searchQuery) ||
// job.anilistId?.includes(searchQuery)
// const matchesStatus = statusFilter === "ALL" || job.status === statusFilter
// return matchesSearch && matchesStatus
// })
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString("en-US", { return new Date(dateString).toLocaleString("en-US", {
@ -34,22 +61,34 @@ export const FailedImportJobs = () => {
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}) });
} };
const getStatusBadge = (job: MangaImportJobDTO) => { const getStatusBadge = (job: MangaImportJobDTO) => {
const variants: Record<MangaImportJobDTOStatus, { variant: "default" | "secondary" | "destructive" | "outline"; className: string }> = { const variants: Record<
PENDING: { variant: "secondary", className: "bg-muted text-muted-foreground" }, MangaImportJobDTOStatus,
{
variant: "default" | "secondary" | "destructive" | "outline";
className: string;
}
> = {
PENDING: {
variant: "secondary",
className: "bg-muted text-muted-foreground",
},
PROCESSING: { variant: "default", className: "bg-blue-500 text-white" }, PROCESSING: { variant: "default", className: "bg-blue-500 text-white" },
SUCCESS: { variant: "default", className: "bg-green-500 text-white" }, SUCCESS: { variant: "default", className: "bg-green-500 text-white" },
FAILED: { variant: "destructive", className: "cursor-pointer hover:bg-destructive/80" }, FAILED: {
} variant: "destructive",
className: "cursor-pointer hover:bg-destructive/80",
},
};
if (!job.status) { if (!job.status) {
return null; return null;
} }
const config = variants[job.status] const config = variants[job.status];
if (job.status === "FAILED") { if (job.status === "FAILED") {
return ( return (
@ -57,36 +96,25 @@ export const FailedImportJobs = () => {
variant={config.variant} variant={config.variant}
className={config.className} className={config.className}
onClick={() => { onClick={() => {
setSelectedJob(job) setSelectedJob(job);
setErrorDialogOpen(true) setErrorDialogOpen(true);
}} }}
> >
<AlertTriangle className="mr-1 h-3 w-3" /> <AlertTriangle className="mr-1 h-3 w-3" />
{job.status} {job.status}
</Badge> </Badge>
) );
} }
return ( return (
<Badge variant={config.variant} className={config.className}> <Badge variant={config.variant} className={config.className}>
{job.status === "PROCESSING" && (
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
)}
{job.status} {job.status}
</Badge> </Badge>
) );
} };
const stats = {
total: importJobs?.length,
pending: importJobs?.filter((j) => j.status === "PENDING").length,
processing: importJobs?.filter((j) => j.status === "PROCESSING").length,
completed: importJobs?.filter((j) => j.status === "SUCCESS").length,
failed: importJobs?.filter((j) => j.status === "FAILED").length,
}
return ( return (
<div className="space-y-6"> <div className="space-y-5">
<div> <div>
<h2 className="text-xl font-semibold text-foreground"> <h2 className="text-xl font-semibold text-foreground">
Manual Import Jobs Manual Import Jobs
@ -96,61 +124,72 @@ export const FailedImportJobs = () => {
</p> </p>
</div> </div>
{/* Stats */} {importJobsData && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<Card className="p-4"> <Card className="p-4">
<p className="text-sm text-muted-foreground">Total Jobs</p> <p className="text-sm text-muted-foreground">Total Jobs</p>
<p className="text-2xl font-bold text-foreground">{stats.total}</p> <p className="text-2xl font-bold text-foreground">
{importJobsData.totalJobs}
</p>
</Card> </Card>
<Card className="p-4"> <Card className="p-4">
<p className="text-sm text-muted-foreground">Pending</p> <p className="text-sm text-muted-foreground">Pending</p>
<p className="text-2xl font-bold text-muted-foreground"> <p className="text-2xl font-bold text-muted-foreground">
{stats.pending} {importJobsData.pendingJobs}
</p> </p>
</Card> </Card>
<Card className="p-4"> <Card className="p-4">
<p className="text-sm text-muted-foreground">Processing</p> <p className="text-sm text-muted-foreground">Processing</p>
<p className="text-2xl font-bold text-blue-500">{stats.processing}</p> <p className="text-2xl font-bold text-blue-500">
{importJobsData.processingJobs}
</p>
</Card> </Card>
<Card className="p-4"> <Card className="p-4">
<p className="text-sm text-muted-foreground">Completed</p> <p className="text-sm text-muted-foreground">Completed</p>
<p className="text-2xl font-bold text-green-500">{stats.completed}</p> <p className="text-2xl font-bold text-green-500">
{importJobsData.completedJobs}
</p>
</Card> </Card>
<Card className="p-4"> <Card className="p-4">
<p className="text-sm text-muted-foreground">Failed</p> <p className="text-sm text-muted-foreground">Failed</p>
<p className="text-2xl font-bold text-destructive">{stats.failed}</p> <p className="text-2xl font-bold text-destructive">
{importJobsData.failedJobs}
</p>
</Card> </Card>
</div> </div>
)}
{/* Filters */} <div className="flex flex-wrap items-center gap-4">
{/*<div className="flex flex-wrap items-center gap-4">*/} <div className="relative flex-1 min-w-[200px]">
{/* <div className="relative flex-1 min-w-[200px]">*/} <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
{/* <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />*/} <Input
{/* <Input*/} placeholder="Search by ID, filename, MAL ID, or AniList ID..."
{/* placeholder="Search by ID, filename, MAL ID, or AniList ID..."*/} value={searchQueryText}
{/* value={searchQuery}*/} onChange={(e) => setSearchQueryText(e.target.value)}
{/* onChange={(e) => setSearchQuery(e.target.value)}*/} className="pl-10"
{/* className="pl-10"*/} />
{/* />*/} </div>
{/* </div>*/} <Select
{/* <Select*/} value={statusFilter}
{/* value={statusFilter}*/} onValueChange={(value) =>
{/* onValueChange={(value) => setStatusFilter(value as ImportStatus | "ALL")}*/} setStatusFilter(
{/* >*/} value === "ALL" ? undefined : (value as MangaImportJobDTOStatus),
{/* <SelectTrigger className="w-[150px]">*/} )
{/* <SelectValue placeholder="Filter by status" />*/} }
{/* </SelectTrigger>*/} >
{/* <SelectContent>*/} <SelectTrigger className="w-[150px]">
{/* <SelectItem value="ALL">All Statuses</SelectItem>*/} <SelectValue placeholder="Filter by status" />
{/* <SelectItem value="PENDING">Pending</SelectItem>*/} </SelectTrigger>
{/* <SelectItem value="PROCESSING">Processing</SelectItem>*/} <SelectContent>
{/* <SelectItem value="COMPLETED">Completed</SelectItem>*/} <SelectItem value="ALL">All Statuses</SelectItem>
{/* <SelectItem value="FAILED">Failed</SelectItem>*/} <SelectItem value="PENDING">Pending</SelectItem>
{/* </SelectContent>*/} <SelectItem value="PROCESSING">Processing</SelectItem>
{/* </Select>*/} <SelectItem value="COMPLETED">Completed</SelectItem>
{/*</div>*/} <SelectItem value="FAILED">Failed</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<Card> <Card>
<Table> <Table>
<TableHeader> <TableHeader>
@ -166,7 +205,8 @@ export const FailedImportJobs = () => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!importJobs || importJobs.length === 0 ? ( {!importJobsData?.page?.content ||
importJobsData.page?.content.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="h-24 text-center"> <TableCell colSpan={8} className="h-24 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground"> <div className="flex flex-col items-center gap-2 text-muted-foreground">
@ -176,7 +216,7 @@ export const FailedImportJobs = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
importJobs.map((job) => ( importJobsData.page.content.map((job) => (
<TableRow key={job.id}> <TableRow key={job.id}>
<TableCell className="font-mono text-xs">{job.id}</TableCell> <TableCell className="font-mono text-xs">{job.id}</TableCell>
<TableCell>{getStatusBadge(job)}</TableCell> <TableCell>{getStatusBadge(job)}</TableCell>
@ -210,12 +250,15 @@ export const FailedImportJobs = () => {
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="max-w-[200px] truncate font-medium" title={job.filename}> <TableCell
className="max-w-[200px] truncate font-medium"
title={job.filename}
>
{job.filename} {job.filename}
</TableCell> </TableCell>
<TableCell className="max-w-[200px]"> <TableCell className="max-w-[200px]">
<a <a
href={`${import.meta.env.VITE_API_BASE_URL}/${job.s3Key}`} href={`${import.meta.env.VITE_OMV_BASE_URL}/${job.s3Key}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:underline truncate" className="flex items-center gap-1 text-xs text-primary hover:underline truncate"
@ -236,9 +279,17 @@ export const FailedImportJobs = () => {
)} )}
</TableBody> </TableBody>
</Table> </Table>
{importJobsData?.page?.totalPages &&
importJobsData.page.totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={importJobsData.page.totalPages}
onPageChange={setCurrentPage}
/>
)}
</Card> </Card>
{/* Error Details Dialog */}
<Dialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}> <Dialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
@ -246,7 +297,7 @@ export const FailedImportJobs = () => {
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="h-5 w-5" />
Import Error Details Import Error Details
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription className="truncate">
Job ID: {selectedJob?.id} | File: {selectedJob?.filename} Job ID: {selectedJob?.id} | File: {selectedJob?.filename}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -256,7 +307,7 @@ export const FailedImportJobs = () => {
<label className="text-sm font-medium text-foreground"> <label className="text-sm font-medium text-foreground">
Message Message
</label> </label>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 max-h-[100px] overflow-auto wrap-break-word text-sm text-muted-foreground">
{selectedJob.errorMessage} {selectedJob.errorMessage}
</p> </p>
</div> </div>
@ -265,7 +316,7 @@ export const FailedImportJobs = () => {
<label className="text-sm font-medium text-foreground"> <label className="text-sm font-medium text-foreground">
Stack Trace Stack Trace
</label> </label>
<pre className="mt-1 max-h-[200px] overflow-auto rounded-md bg-muted p-3 text-xs font-mono text-muted-foreground"> <pre className="mt-1 max-h-[200px] overflow-auto whitespace-pre-wrap wrap-break-word rounded-md bg-muted p-3 text-xs font-mono text-muted-foreground">
{selectedJob.errorStackTrace} {selectedJob.errorStackTrace}
</pre> </pre>
</div> </div>
@ -300,5 +351,5 @@ export const FailedImportJobs = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
}; };

View File

@ -1,22 +1,28 @@
import { ExternalLink, Loader2, RefreshCw } from "lucide-react";
import { import {
useFetchAllContentProviderMangas, useFetchAllContentProviderMangas,
useFetchContentProviderMangas, useFetchContentProviderMangas,
useGetContentProviders useGetContentProviders,
} from "@/api/generated/ingestion/ingestion.ts"; } from "@/api/generated/ingestion/ingestion.ts";
import {Card} from "@/components/ui/card.tsx";
import { Badge } from "@/components/ui/badge.tsx"; import { Badge } from "@/components/ui/badge.tsx";
import {ExternalLink, Loader2, RefreshCw} from "lucide-react";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx";
export const ProviderManager = () => { export const ProviderManager = () => {
const { data } = useGetContentProviders(); const { data } = useGetContentProviders();
const providers = data?.data?.providers; const providers = data?.data?.providers;
const { mutate: mutateFetchContentProviderMangas, isPending: isPendingFetchContentProviderMangas } = useFetchContentProviderMangas(); const {
mutate: mutateFetchContentProviderMangas,
isPending: isPendingFetchContentProviderMangas,
} = useFetchContentProviderMangas();
const { mutate: mutateFetchAllContentProviderMangas, isPending: isPendingFetchAllContentProviderMangas } = useFetchAllContentProviderMangas(); const {
mutate: mutateFetchAllContentProviderMangas,
isPending: isPendingFetchAllContentProviderMangas,
} = useFetchAllContentProviderMangas();
const activeCount = providers?.filter((p) => p.active).length const activeCount = providers?.filter((p) => p.active).length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -39,7 +45,9 @@ export const ProviderManager = () => {
) : ( ) : (
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
)} )}
{isPendingFetchAllContentProviderMangas ? "Updating..." : "Update All Providers"} {isPendingFetchAllContentProviderMangas
? "Updating..."
: "Update All Providers"}
</Button> </Button>
</div> </div>
@ -61,11 +69,7 @@ export const ProviderManager = () => {
{provider.name} {provider.name}
</h3> </h3>
<Badge <Badge
variant={ variant={provider?.active ? "default" : "outline"}
provider?.active
? "default"
: "outline"
}
className={ className={
provider?.active provider?.active
? "bg-emerald-600 text-white hover:bg-emerald-600" ? "bg-emerald-600 text-white hover:bg-emerald-600"
@ -76,8 +80,8 @@ export const ProviderManager = () => {
</Badge> </Badge>
</div> </div>
{provider?.url && {provider?.url && (
(<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<code className="text-xs text-muted-foreground truncate max-w-[300px]"> <code className="text-xs text-muted-foreground truncate max-w-[300px]">
{provider.url} {provider.url}
@ -91,7 +95,8 @@ export const ProviderManager = () => {
<ExternalLink className="h-3 w-3 text-muted-foreground hover:text-foreground" /> <ExternalLink className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</a> </a>
</div> </div>
</div>)} </div>
)}
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{/*{provider.mangaCount} manga indexed | Last updated:{" "}*/} {/*{provider.mangaCount} manga indexed | Last updated:{" "}*/}
@ -102,8 +107,14 @@ export const ProviderManager = () => {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => mutateFetchContentProviderMangas({providerId: provider?.id ?? -1})} onClick={() =>
disabled={!provider.active || isPendingFetchContentProviderMangas} mutateFetchContentProviderMangas({
providerId: provider?.id ?? -1,
})
}
disabled={
!provider.active || isPendingFetchContentProviderMangas
}
className="gap-2 shrink-0" className="gap-2 shrink-0"
> >
{isPendingFetchContentProviderMangas ? ( {isPendingFetchContentProviderMangas ? (
@ -115,9 +126,9 @@ export const ProviderManager = () => {
</Button> </Button>
</div> </div>
</Card> </Card>
) );
})} })}
</div> </div>
</div> </div>
) );
} };

View File

@ -1,11 +1,11 @@
import { Star, X } from "lucide-react"; import { Star, X } from "lucide-react";
import { useGetGenres } from "@/api/generated/catalog/catalog.ts";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { Skeleton } from "@/components/ui/skeleton.tsx"; import { Skeleton } from "@/components/ui/skeleton.tsx";
import { Switch } from "@/components/ui/switch.tsx"; import { Switch } from "@/components/ui/switch.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx"; import { useAuth } from "@/contexts/AuthContext.tsx";
import {useGetGenres} from "@/api/generated/catalog/catalog.ts";
interface FilterSidebarProps { interface FilterSidebarProps {
selectedGenres: number[]; selectedGenres: number[];

View File

@ -6,12 +6,15 @@ import type {
MangaListDTO, MangaListDTO,
PageMangaListDTO, PageMangaListDTO,
} from "@/api/generated/api.schemas.ts"; } from "@/api/generated/api.schemas.ts";
import {
useSetFavorite,
useSetUnfavorite,
} from "@/api/generated/user-interaction/user-interaction.ts";
import { Badge } from "@/components/ui/badge.tsx"; import { Badge } from "@/components/ui/badge.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Card, CardContent } from "@/components/ui/card.tsx"; import { Card, CardContent } from "@/components/ui/card.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx"; import { useAuth } from "@/contexts/AuthContext.tsx";
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts"; import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
import {useSetFavorite, useSetUnfavorite} from "@/api/generated/user-interaction/user-interaction.ts";
interface MangaCardProps { interface MangaCardProps {
manga: MangaListDTO; manga: MangaListDTO;
@ -63,8 +66,7 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
(isFavorite: boolean) => (isFavorite: boolean) =>
isFavorite isFavorite
? mutateUnfavorite({ mangaId: manga.id }) ? mutateUnfavorite({ mangaId: manga.id })
: mutateFavorite({ mangaId: manga.id }) : mutateFavorite({ mangaId: manga.id }),
,
[mutateUnfavorite, manga.id, mutateFavorite], [mutateUnfavorite, manga.id, mutateFavorite],
); );

View File

@ -1,7 +1,9 @@
import axios from "axios";
import { FileUp } from "lucide-react"; import { FileUp } from "lucide-react";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRequestPresignedImport } from "@/api/generated/content/content.ts";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -13,8 +15,6 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import axios from "axios";
import { useRequestPresignedImport } from "@/api/generated/content/content.ts";
interface MangaManualImportDialogProps { interface MangaManualImportDialogProps {
fileImportDialogOpen: boolean; fileImportDialogOpen: boolean;
@ -30,7 +30,9 @@ export const MangaManualImportDialog = ({
const [dragActive, setDragActive] = useState(false); const [dragActive, setDragActive] = useState(false);
const [files, setFiles] = useState<File[] | null>(null); const [files, setFiles] = useState<File[] | null>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({}); const [uploadProgress, setUploadProgress] = useState<Record<string, number>>(
{},
);
const { mutateAsync: requestPresignedUrl } = useRequestPresignedImport(); const { mutateAsync: requestPresignedUrl } = useRequestPresignedImport();
@ -140,9 +142,7 @@ export const MangaManualImportDialog = ({
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-sm font-medium"> <label className="text-sm font-medium">AniList ID</label>
AniList ID
</label>
<Input <Input
placeholder="17" placeholder="17"
value={aniListId} value={aniListId}
@ -152,9 +152,7 @@ export const MangaManualImportDialog = ({
/> />
</div> </div>
<div> <div>
<label className="text-sm font-medium"> <label className="text-sm font-medium">MyAnimeList ID</label>
MyAnimeList ID
</label>
<Input <Input
placeholder="20" placeholder="20"
value={malId} value={malId}
@ -171,7 +169,8 @@ export const MangaManualImportDialog = ({
onDragLeave={handleDrag} onDragLeave={handleDrag}
onDragOver={handleDrag} onDragOver={handleDrag}
onDrop={handleDrop} onDrop={handleDrop}
className={`mt-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors ${dragActive className={`mt-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
dragActive
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "border-muted-foreground/25" : "border-muted-foreground/25"
}`} }`}
@ -230,7 +229,10 @@ export const MangaManualImportDialog = ({
{files.map((file) => ( {files.map((file) => (
<div key={file.name} className="space-y-1"> <div key={file.name} className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span className="truncate pr-4 max-w-[200px]" title={file.name}> <span
className="truncate pr-4 max-w-[200px]"
title={file.name}
>
{file.name} {file.name}
</span> </span>
<span>{uploadProgress[file.name] || 0}%</span> <span>{uploadProgress[file.name] || 0}%</span>

View File

@ -3,6 +3,7 @@ import { useCallback } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { useGetContentProviders } from "@/api/generated/ingestion/ingestion.ts";
import { useImportFromProvider } from "@/api/generated/manga-import/manga-import.ts"; import { useImportFromProvider } from "@/api/generated/manga-import/manga-import.ts";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -30,7 +31,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select.tsx"; } from "@/components/ui/select.tsx";
import {useGetContentProviders} from "@/api/generated/ingestion/ingestion.ts";
interface ProviderImportDialogProps { interface ProviderImportDialogProps {
dialogOpen: boolean; dialogOpen: boolean;

View File

@ -2,14 +2,14 @@ import { useQueryClient } from "@tanstack/react-query";
import { ExternalLink, Trash2 } from "lucide-react"; import { ExternalLink, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import type { MangaIngestReviewDTO } from "@/api/generated/api.schemas.ts"; import type { MangaIngestReviewDTO } from "@/api/generated/api.schemas.ts";
import { import {
useDeleteMangaIngestReview, useDeleteMangaIngestReview,
useResolveMangaIngestReview useResolveMangaIngestReview,
} from "@/api/generated/manga-ingest-review/manga-ingest-review.ts"; } from "@/api/generated/manga-ingest-review/manga-ingest-review.ts";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
interface ImportReviewCardProps { interface ImportReviewCardProps {
importReview: MangaIngestReviewDTO; importReview: MangaIngestReviewDTO;
@ -73,8 +73,10 @@ export function ImportReviewCard({
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Provider:{" "} Provider:{" "}
<span className="capitalize">{importReview.contentProviderName}</span> {" "} <span className="capitalize">
{importDate} {importReview.contentProviderName}
</span>{" "}
{importDate}
</p> </p>
</div> </div>
{importReview.externalUrl && ( {importReview.externalUrl && (

View File

@ -1,11 +1,14 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check, Database, Download, Eye, Loader2 } from "lucide-react"; import { Check, Database, Download, Eye, Loader2 } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
import ReactCountryFlag from "react-country-flag"; import ReactCountryFlag from "react-country-flag";
import {useDownloadContentArchive, useGetMangaProviderContent} from "@/api/generated/content/content.ts"; import { useNavigate } from "react-router";
import {
useDownloadContentArchive,
useGetMangaProviderContent,
} from "@/api/generated/content/content.ts";
import { useFetchContentProviderContent } from "@/api/generated/ingestion/ingestion.ts"; import { useFetchContentProviderContent } from "@/api/generated/ingestion/ingestion.ts";
import { Button } from "@/components/ui/button";
interface MangaChapterProps { interface MangaChapterProps {
mangaId: number; mangaId: number;
@ -18,7 +21,8 @@ export const MangaChapter = ({
}: MangaChapterProps) => { }: MangaChapterProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { isPending, data, queryKey } = useGetMangaProviderContent(mangaProviderId); const { isPending, data, queryKey } =
useGetMangaProviderContent(mangaProviderId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -28,7 +32,7 @@ export const MangaChapter = ({
const url = window.URL.createObjectURL(data); const url = window.URL.createObjectURL(data);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.setAttribute("download", mangaContentId + ".cbz"); link.setAttribute("download", `${mangaContentId}.cbz`);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
@ -37,7 +41,8 @@ export const MangaChapter = ({
}, },
}); });
const { mutate, isPending: isPendingFetchChapter } = useFetchContentProviderContent({ const { mutate, isPending: isPendingFetchChapter } =
useFetchContentProviderContent({
mutation: { mutation: {
onSuccess: () => queryClient.invalidateQueries({ queryKey }), onSuccess: () => queryClient.invalidateQueries({ queryKey }),
onSettled: () => setFetchingId(null), onSettled: () => setFetchingId(null),
@ -77,7 +82,8 @@ export const MangaChapter = ({
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className={`flex h-8 w-8 items-center justify-center rounded-full ${chapter.isRead ? "bg-primary/20" : "bg-muted" className={`flex h-8 w-8 items-center justify-center rounded-full ${
chapter.isRead ? "bg-primary/20" : "bg-muted"
}`} }`}
> >
{chapter.isRead ? ( {chapter.isRead ? (
@ -145,7 +151,7 @@ export const MangaChapter = ({
className="gap-2 cursor-pointer" className="gap-2 cursor-pointer"
> >
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
{isPendingFetchChapter && fetchingId == chapter.id {isPendingFetchChapter && fetchingId === chapter.id
? "Fetching..." ? "Fetching..."
: "Fetch from Provider"} : "Fetch from Provider"}
</Button> </Button>

View File

@ -15,5 +15,5 @@ export const useScrollPersistence = (key: string) => {
return () => { return () => {
setScrollPosition(key, window.scrollY); setScrollPosition(key, window.scrollY);
}; };
}, [key, setScrollPosition]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, setScrollPosition, scrollPositions]); // eslint-disable-next-line react-hooks/exhaustive-deps
}; };

View File

@ -1,17 +1,23 @@
import {useAuth} from "@/contexts/AuthContext.tsx"; import { ArrowLeft, FileStack, Server, Shield } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import {ArrowLeft, FileStack, Server, Shield} from "lucide-react";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {ProviderManager} from "@/features/admin/components/ProviderManager.tsx"; import { useAuth } from "@/contexts/AuthContext.tsx";
import { FailedImportJobs } from "@/features/admin/components/FailedImportJobs.tsx"; import { FailedImportJobs } from "@/features/admin/components/FailedImportJobs.tsx";
import { ProviderManager } from "@/features/admin/components/ProviderManager.tsx";
type Tab = "import" | "providers" | "manga" | "ingest-review" | "import-jobs" | "users" type Tab =
| "import"
| "providers"
| "manga"
| "ingest-review"
| "import-jobs"
| "users";
const Admin = () => { const Admin = () => {
const { isLoading, isAuthenticated } = useAuth(); const { isLoading, isAuthenticated } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>("providers") const [activeTab, setActiveTab] = useState<Tab>("providers");
useEffect(() => { useEffect(() => {
// TODO: fix this // TODO: fix this
@ -21,7 +27,7 @@ const Admin = () => {
} }
// TODO: add user role verification // TODO: add user role verification
}, [isAuthenticated, isLoading, navigate]); }, [isAuthenticated, isLoading]);
// const { data } = useGetMangaImportJobs(); // const { data } = useGetMangaImportJobs();
// //
@ -59,7 +65,7 @@ const Admin = () => {
// label: "User Management", // label: "User Management",
// icon: <Users className="h-4 w-4" />, // icon: <Users className="h-4 w-4" />,
// }, // },
] ];
return ( return (
<main className="min-h-screen bg-background"> <main className="min-h-screen bg-background">
@ -235,7 +241,7 @@ const Admin = () => {
</div> </div>
</div> </div>
</main> </main>
) );
} };
export default Admin; export default Admin;

View File

@ -1,11 +1,11 @@
import { ArrowLeft, ChevronLeft, ChevronRight, Home } from "lucide-react"; import { ArrowLeft, ChevronLeft, ChevronRight, Home } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { useGetMangaContentImages } from "@/api/generated/content/content.ts";
import { useMarkContentAsRead } from "@/api/generated/user-interaction/user-interaction.ts";
import { ThemeToggle } from "@/components/ThemeToggle.tsx"; import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts"; import { useReadingTracker } from "@/features/chapter/hooks/useReadingTracker.ts";
import {useGetMangaContentImages} from "@/api/generated/content/content.ts";
import {useMarkContentAsRead} from "@/api/generated/user-interaction/user-interaction.ts";
const Chapter = () => { const Chapter = () => {
const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker(); const { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
@ -35,12 +35,12 @@ const Chapter = () => {
if (currentPage === data.data?.contentImageKeys.length) { if (currentPage === data.data?.contentImageKeys.length) {
mutate({ mangaContentId: chapterNumber }); mutate({ mangaContentId: chapterNumber });
} }
}, [data, mutate, currentPage]); }, [data, mutate, currentPage, chapterNumber, isLoading]);
/** Persist reading progress */ /** Persist reading progress */
useEffect(() => { useEffect(() => {
setCurrentChapterPage(chapterNumber, currentPage); setCurrentChapterPage(chapterNumber, currentPage);
}, [chapterNumber, currentPage]); }, [chapterNumber, currentPage, setCurrentChapterPage]);
/** Restore stored page */ /** Restore stored page */
useEffect(() => { useEffect(() => {
@ -51,7 +51,7 @@ const Chapter = () => {
setVisibleCount(stored); // for infinite scroll setVisibleCount(stored); // for infinite scroll
} }
} }
}, [isLoading, data?.data]); }, [isLoading, data?.data, chapterNumber, getCurrentChapterPage]);
/** Infinite scroll observer */ /** Infinite scroll observer */
useEffect(() => { useEffect(() => {
@ -90,7 +90,7 @@ const Chapter = () => {
imgs.forEach((img) => observer.observe(img)); imgs.forEach((img) => observer.observe(img));
return () => observer.disconnect(); return () => observer.disconnect();
}, [infiniteScroll, visibleCount]); }, [infiniteScroll]);
useEffect(() => { useEffect(() => {
if (!data?.data) return; if (!data?.data) return;
@ -107,7 +107,7 @@ const Chapter = () => {
// Single page mode → scroll to top // Single page mode → scroll to top
window.scrollTo({ top: 0 }); window.scrollTo({ top: 0 });
} }
}, [infiniteScroll]); }, [infiniteScroll, currentPage, data?.data]);
if (!data?.data) { if (!data?.data) {
return ( return (

View File

@ -1,23 +1,20 @@
import { BookOpen, Search } from "lucide-react"; import { BookOpen, Search } from "lucide-react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
import { AuthHeader } from "@/components/AuthHeader.tsx"; import { AuthHeader } from "@/components/AuthHeader.tsx";
import { Pagination } from "@/components/Pagination.tsx"; import { Pagination } from "@/components/Pagination.tsx";
import { ThemeToggle } from "@/components/ThemeToggle.tsx"; import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx";
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx"; import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
import { ImportDropdown } from "@/features/home/components/ImportDropdown.tsx"; import { ImportDropdown } from "@/features/home/components/ImportDropdown.tsx";
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx"; import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
import { import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
SortDropdown,
} from "@/features/home/components/SortDropdown.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx";
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts"; import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
import {useGetMangas} from "@/api/generated/catalog/catalog.ts";
const ROWS_PER_PAGE = 4; const ROWS_PER_PAGE = 4;
const Home = () => { const Home = () => {
const itemsPerPage = useDynamicPageSize(ROWS_PER_PAGE); const itemsPerPage = useDynamicPageSize(ROWS_PER_PAGE);
const { const {
@ -60,7 +57,7 @@ const Home = () => {
setCurrentPage(1); setCurrentPage(1);
startSearchRef.current = debouncedSearchText; startSearchRef.current = debouncedSearchText;
} }
}, [debouncedSearchText]); }, [debouncedSearchText, setCurrentPage]);
const totalPages = mangasData?.data?.totalPages; const totalPages = mangasData?.data?.totalPages;

View File

@ -3,10 +3,10 @@
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useGetMangaIngestReviews } from "@/api/generated/manga-ingest-review/manga-ingest-review.ts";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useAuth } from "@/contexts/AuthContext.tsx"; import { useAuth } from "@/contexts/AuthContext.tsx";
import { ImportReviewCard } from "@/features/import-review/ImportReviewCard.tsx"; import { ImportReviewCard } from "@/features/import-review/ImportReviewCard.tsx";
import {useGetMangaIngestReviews} from "@/api/generated/manga-ingest-review/manga-ingest-review.ts";
export default function ImportReviewPage() { export default function ImportReviewPage() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -44,7 +44,7 @@ const Login = () => {
async (values: z.infer<typeof formSchema>) => { async (values: z.infer<typeof formSchema>) => {
await login(values.email, values.password); await login(values.email, values.password);
}, },
[formSchema, login], [login],
); );
return ( return (

View File

@ -13,6 +13,14 @@ import {
import { useCallback } from "react"; import { useCallback } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { useGetManga } from "@/api/generated/catalog/catalog.ts";
import { useFetchContentProviderContentList } from "@/api/generated/ingestion/ingestion.ts";
import {
useFollowManga,
useSetFavorite,
useSetUnfavorite,
useUnfollowManga,
} from "@/api/generated/user-interaction/user-interaction.ts";
import { ThemeToggle } from "@/components/ThemeToggle.tsx"; import { ThemeToggle } from "@/components/ThemeToggle.tsx";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -24,17 +32,9 @@ import {
} from "@/components/ui/collapsible.tsx"; } from "@/components/ui/collapsible.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx"; import { useAuth } from "@/contexts/AuthContext.tsx";
import { useUIState } from "@/contexts/UIStateContext.tsx"; import { useUIState } from "@/contexts/UIStateContext.tsx";
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
import { MangaChapter } from "@/features/manga/MangaChapter.tsx"; import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts"; import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
import {useGetManga} from "@/api/generated/catalog/catalog.ts";
import {useFetchContentProviderContentList} from "@/api/generated/ingestion/ingestion.ts";
import {
useFollowManga,
useSetFavorite,
useSetUnfavorite,
useUnfollowManga
} from "@/api/generated/user-interaction/user-interaction.ts";
const Manga = () => { const Manga = () => {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
@ -46,7 +46,8 @@ const Manga = () => {
const { data: mangaData, queryKey } = useGetManga(mangaId); const { data: mangaData, queryKey } = useGetManga(mangaId);
const { mutate, isPending: fetchPending } = useFetchContentProviderContentList({ const { mutate, isPending: fetchPending } =
useFetchContentProviderContentList({
mutation: { mutation: {
onSuccess: () => queryClient.invalidateQueries({ queryKey }), onSuccess: () => queryClient.invalidateQueries({ queryKey }),
}, },
@ -226,7 +227,8 @@ const Manga = () => {
disabled={isPendingFavoriteChange} disabled={isPendingFavoriteChange}
> >
<Heart <Heart
className={`h-4 w-4 transition-colors ${mangaData?.data?.favorite className={`h-4 w-4 transition-colors ${
mangaData?.data?.favorite
? "fill-red-500 text-red-500" ? "fill-red-500 text-red-500"
: "text-muted-foreground hover:text-red-500" : "text-muted-foreground hover:text-red-500"
}`} }`}
@ -364,8 +366,7 @@ const Manga = () => {
</p> </p>
</div> </div>
</div> </div>
{provider.supportsChapterFetch && {provider.supportsChapterFetch && provider.active && (
provider.active && (
<div className={"flex gap-4 pr-4"}> <div className={"flex gap-4 pr-4"}>
{/*<Button*/} {/*<Button*/}
{/* size="sm"*/} {/* size="sm"*/}
@ -386,7 +387,9 @@ const Manga = () => {
variant="outline" variant="outline"
disabled={isPending} disabled={isPending}
onClick={() => onClick={() =>
mutate({ mangaContentProviderId: provider.id ?? -1 }) mutate({
mangaContentProviderId: provider.id ?? -1,
})
} }
className="gap-2" className="gap-2"
> >
@ -397,7 +400,10 @@ const Manga = () => {
)} )}
</div> </div>
<ChevronDown <ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform ${expandedProviderIds.includes(provider.id ?? -1) ? "rotate-180" : "" className={`h-5 w-5 text-muted-foreground transition-transform ${
expandedProviderIds.includes(provider.id ?? -1)
? "rotate-180"
: ""
}`} }`}
/> />
</CardContent> </CardContent>

View File

@ -118,7 +118,7 @@ const Profile = () => {
step="6" step="6"
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => onChange={(e) =>
setItemsPerPage(Number.parseInt(e.target.value)) setItemsPerPage(Number.parseInt(e.target.value, 10))
} }
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@ -55,7 +55,7 @@ const Register = () => {
async (data: z.infer<typeof formSchema>) => { async (data: z.infer<typeof formSchema>) => {
await register(data.email, data.password, data.name); await register(data.email, data.password, data.name);
}, },
[register, formSchema], [register],
); );
return ( return (

View File

@ -24,7 +24,7 @@ export const Router = createBrowserRouter([
}, },
{ {
path: "/admin", path: "/admin",
element: <Admin /> element: <Admin />,
}, },
{ {
path: "/login", path: "/login",