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

@ -1,4 +1,4 @@
import {LogIn, LogOut, Settings, Shield, User} from "lucide-react"; import { LogIn, LogOut, Settings, Shield, User } from "lucide-react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { Avatar, AvatarFallback } from "@/components/ui/avatar.tsx"; import { Avatar, AvatarFallback } from "@/components/ui/avatar.tsx";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";

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,29 +1,29 @@
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,
value, value,
...props ...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) { }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return ( return (
<ProgressPrimitive.Root <ProgressPrimitive.Root
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}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
data-slot="progress-indicator" data-slot="progress-indicator"
className="h-full w-full flex-1 bg-primary transition-all" className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) );
} }
export { Progress } export { Progress };

View File

@ -1,114 +1,114 @@
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 (
<div <div
data-slot="table-container" data-slot="table-container"
className="relative w-full overflow-x-auto" className="relative w-full overflow-x-auto"
> >
<table <table
data-slot="table" data-slot="table"
className={cn("w-full caption-bottom text-sm", className)} className={cn("w-full caption-bottom text-sm", className)}
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return ( return (
<thead <thead
data-slot="table-header" data-slot="table-header"
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">) {
return ( return (
<tbody <tbody
data-slot="table-body" data-slot="table-body"
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">) {
return ( return (
<tfoot <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">) {
return ( return (
<tr <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">) {
return ( return (
<th <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">) {
return ( return (
<td <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({
className, className,
...props ...props
}: React.ComponentProps<"caption">) { }: React.ComponentProps<"caption">) {
return ( return (
<caption <caption
data-slot="table-caption" data-slot="table-caption"
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,
TableFooter, TableCell,
TableHead, TableFooter,
TableRow, TableHead,
TableCell, TableHeader,
TableCaption, TableRow,
} };

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,143 +1,141 @@
import { import {
type ReactNode, createContext,
createContext, type ReactNode,
useCallback, useCallback,
useContext, useContext,
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import type { SortOption } from "@/features/home/components/SortDropdown.tsx"; import type { SortOption } from "@/features/home/components/SortDropdown.tsx";
interface UIStateContextType { interface UIStateContextType {
/* Home Filter State */ /* Home Filter State */
currentPage: number; currentPage: number;
setCurrentPage: (page: number) => void; setCurrentPage: (page: number) => void;
selectedGenres: number[]; selectedGenres: number[];
setSelectedGenres: (genres: number[]) => void; setSelectedGenres: (genres: number[]) => void;
selectedStatus: string[]; selectedStatus: string[];
setSelectedStatus: (status: string[]) => void; setSelectedStatus: (status: string[]) => void;
minRating: number; minRating: number;
setMinRating: (rating: number) => void; setMinRating: (rating: number) => void;
userFavorites: boolean; userFavorites: boolean;
setUserFavorites: (favorites: boolean) => void; setUserFavorites: (favorites: boolean) => void;
showAdultContent: boolean; showAdultContent: boolean;
setShowAdultContent: (show: boolean) => void; setShowAdultContent: (show: boolean) => void;
sortOption: SortOption; sortOption: SortOption;
setSortOption: (sort: SortOption) => void; setSortOption: (sort: SortOption) => void;
searchText: string; searchText: string;
setSearchText: (text: string) => void; setSearchText: (text: string) => void;
resetFilters: () => void; resetFilters: () => void;
/* Manga Provider Card State */ /* Manga Provider Card State */
expandedProviderIds: number[]; expandedProviderIds: number[];
toggleProviderId: (id: number) => void; toggleProviderId: (id: number) => void;
/* Scroll Persistence */ /* Scroll Persistence */
scrollPositions: Record<string, number>; scrollPositions: Record<string, number>;
setScrollPosition: (key: string, position: number) => void; setScrollPosition: (key: string, position: number) => void;
} }
const UIStateContext = createContext<UIStateContextType | undefined>(undefined); const UIStateContext = createContext<UIStateContextType | undefined>(undefined);
export const UIStateProvider = ({ children }: { children: ReactNode }) => { export const UIStateProvider = ({ children }: { children: ReactNode }) => {
/* Home Filter State */ /* Home Filter State */
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [selectedGenres, setSelectedGenres] = useState<number[]>([]); const [selectedGenres, setSelectedGenres] = useState<number[]>([]);
const [selectedStatus, setSelectedStatus] = useState<string[]>([]); const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
const [minRating, setMinRating] = useState(0); const [minRating, setMinRating] = useState(0);
const [userFavorites, setUserFavorites] = useState(false); const [userFavorites, setUserFavorites] = useState(false);
const [showAdultContent, setShowAdultContent] = useState(false); const [showAdultContent, setShowAdultContent] = useState(false);
const [sortOption, setSortOption] = useState<SortOption>("title-asc"); const [sortOption, setSortOption] = useState<SortOption>("title-asc");
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
/* Manga Provider Card State */ /* Manga Provider Card State */
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]); const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
/* Scroll Persistence */ /* Scroll Persistence */
const [scrollPositions, setScrollPositions] = useState< const [scrollPositions, setScrollPositions] = useState<
Record<string, number> Record<string, number>
>({}); >({});
const resetFilters = useCallback(() => {
setCurrentPage(1);
setSelectedGenres([]);
setSelectedStatus([]);
setMinRating(0);
setUserFavorites(false);
setShowAdultContent(false);
setSortOption("title-asc");
setSearchText("");
}, []);
const toggleProviderId = useCallback((id: number) => {
setExpandedProviderIds((prev) => {
if (prev.includes(id)) {
return prev.filter((pId) => pId !== id);
} else {
return [...prev, id];
}
});
}, []);
const resetFilters = useCallback(() => { const setScrollPosition = useCallback((key: string, position: number) => {
setCurrentPage(1); setScrollPositions((prev) => ({
setSelectedGenres([]); ...prev,
setSelectedStatus([]); [key]: position,
setMinRating(0); }));
setUserFavorites(false); }, []);
setShowAdultContent(false);
setSortOption("title-asc");
setSearchText("");
}, []);
const toggleProviderId = useCallback((id: number) => { const value = useMemo(
setExpandedProviderIds((prev) => { () => ({
if (prev.includes(id)) { currentPage,
return prev.filter((pId) => pId !== id); setCurrentPage,
} else { selectedGenres,
return [...prev, id]; setSelectedGenres,
} selectedStatus,
}); setSelectedStatus,
}, []); minRating,
setMinRating,
userFavorites,
setUserFavorites,
showAdultContent,
setShowAdultContent,
sortOption,
setSortOption,
searchText,
setSearchText,
resetFilters,
expandedProviderIds,
toggleProviderId,
scrollPositions,
setScrollPosition,
}),
[
currentPage,
selectedGenres,
selectedStatus,
minRating,
userFavorites,
showAdultContent,
sortOption,
searchText,
resetFilters,
expandedProviderIds,
toggleProviderId,
scrollPositions,
setScrollPosition,
],
);
const setScrollPosition = useCallback((key: string, position: number) => { return (
setScrollPositions((prev) => ({ <UIStateContext.Provider value={value}>{children}</UIStateContext.Provider>
...prev, );
[key]: position,
}));
}, []);
const value = useMemo(
() => ({
currentPage,
setCurrentPage,
selectedGenres,
setSelectedGenres,
selectedStatus,
setSelectedStatus,
minRating,
setMinRating,
userFavorites,
setUserFavorites,
showAdultContent,
setShowAdultContent,
sortOption,
setSortOption,
searchText,
setSearchText,
resetFilters,
expandedProviderIds,
toggleProviderId,
scrollPositions,
setScrollPosition,
}),
[
currentPage,
selectedGenres,
selectedStatus,
minRating,
userFavorites,
showAdultContent,
sortOption,
searchText,
resetFilters,
expandedProviderIds,
toggleProviderId,
scrollPositions,
setScrollPosition,
],
);
return (
<UIStateContext.Provider value={value}>{children}</UIStateContext.Provider>
);
}; };
export const useUIState = () => { export const useUIState = () => {
const context = useContext(UIStateContext); const context = useContext(UIStateContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useUIState must be used within a UIStateProvider"); throw new Error("useUIState must be used within a UIStateProvider");
} }
return context; return context;
}; };

View File

@ -1,304 +1,355 @@
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 { 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 { Badge } from "@/components/ui/badge";
import {AlertTriangle, ExternalLink, FileText, RefreshCw} from "lucide-react"; import { Button } from "@/components/ui/button.tsx";
import {Card} from "@/components/ui/card.tsx"; import { Card } from "@/components/ui/card.tsx";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table.tsx"; import {
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog.tsx"; Dialog,
import {useState} from "react"; DialogContent,
import {Button} from "@/components/ui/button.tsx"; 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 importJobsData = data?.data;
// const filteredJobs = jobs.filter((job) => { const formatDate = (dateString: string) => {
// const matchesSearch = return new Date(dateString).toLocaleString("en-US", {
// job.filename.toLowerCase().includes(searchQuery.toLowerCase()) || year: "numeric",
// job.id.toLowerCase().includes(searchQuery.toLowerCase()) || month: "short",
// job.malId?.includes(searchQuery) || day: "numeric",
// job.anilistId?.includes(searchQuery) hour: "2-digit",
// const matchesStatus = statusFilter === "ALL" || job.status === statusFilter minute: "2-digit",
// return matchesSearch && matchesStatus });
// }) };
const formatDate = (dateString: string) => { const getStatusBadge = (job: MangaImportJobDTO) => {
return new Date(dateString).toLocaleString("en-US", { const variants: Record<
year: "numeric", MangaImportJobDTOStatus,
month: "short", {
day: "numeric", variant: "default" | "secondary" | "destructive" | "outline";
hour: "2-digit", className: string;
minute: "2-digit", }
}) > = {
} PENDING: {
variant: "secondary",
className: "bg-muted text-muted-foreground",
},
PROCESSING: { variant: "default", className: "bg-blue-500 text-white" },
SUCCESS: { variant: "default", className: "bg-green-500 text-white" },
FAILED: {
variant: "destructive",
className: "cursor-pointer hover:bg-destructive/80",
},
};
const getStatusBadge = (job: MangaImportJobDTO) => { if (!job.status) {
const variants: Record<MangaImportJobDTOStatus, { variant: "default" | "secondary" | "destructive" | "outline"; className: string }> = { return null;
PENDING: { variant: "secondary", className: "bg-muted text-muted-foreground" }, }
PROCESSING: { variant: "default", className: "bg-blue-500 text-white" },
SUCCESS: { variant: "default", className: "bg-green-500 text-white" },
FAILED: { variant: "destructive", className: "cursor-pointer hover:bg-destructive/80" },
}
if (!job.status) { const config = variants[job.status];
return null;
}
const config = variants[job.status] if (job.status === "FAILED") {
return (
<Badge
variant={config.variant}
className={config.className}
onClick={() => {
setSelectedJob(job);
setErrorDialogOpen(true);
}}
>
<AlertTriangle className="mr-1 h-3 w-3" />
{job.status}
</Badge>
);
}
if (job.status === "FAILED") { return (
return ( <Badge variant={config.variant} className={config.className}>
<Badge {job.status}
variant={config.variant} </Badge>
className={config.className} );
onClick={() => { };
setSelectedJob(job)
setErrorDialogOpen(true)
}}
>
<AlertTriangle className="mr-1 h-3 w-3" />
{job.status}
</Badge>
)
}
return ( return (
<Badge variant={config.variant} className={config.className}> <div className="space-y-5">
{job.status === "PROCESSING" && ( <div>
<RefreshCw className="mr-1 h-3 w-3 animate-spin" /> <h2 className="text-xl font-semibold text-foreground">
)} Manual Import Jobs
{job.status} </h2>
</Badge> <p className="text-sm text-muted-foreground">
) View and manage file import jobs from S3 storage.
} </p>
</div>
const stats = { {importJobsData && (
total: importJobs?.length, <div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
pending: importJobs?.filter((j) => j.status === "PENDING").length, <Card className="p-4">
processing: importJobs?.filter((j) => j.status === "PROCESSING").length, <p className="text-sm text-muted-foreground">Total Jobs</p>
completed: importJobs?.filter((j) => j.status === "SUCCESS").length, <p className="text-2xl font-bold text-foreground">
failed: importJobs?.filter((j) => j.status === "FAILED").length, {importJobsData.totalJobs}
} </p>
</Card>
<Card className="p-4">
<p className="text-sm text-muted-foreground">Pending</p>
<p className="text-2xl font-bold text-muted-foreground">
{importJobsData.pendingJobs}
</p>
</Card>
<Card className="p-4">
<p className="text-sm text-muted-foreground">Processing</p>
<p className="text-2xl font-bold text-blue-500">
{importJobsData.processingJobs}
</p>
</Card>
<Card className="p-4">
<p className="text-sm text-muted-foreground">Completed</p>
<p className="text-2xl font-bold text-green-500">
{importJobsData.completedJobs}
</p>
</Card>
<Card className="p-4">
<p className="text-sm text-muted-foreground">Failed</p>
<p className="text-2xl font-bold text-destructive">
{importJobsData.failedJobs}
</p>
</Card>
</div>
)}
return ( <div className="flex flex-wrap items-center gap-4">
<div className="space-y-6"> <div className="relative flex-1 min-w-[200px]">
<div> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<h2 className="text-xl font-semibold text-foreground"> <Input
Manual Import Jobs placeholder="Search by ID, filename, MAL ID, or AniList ID..."
</h2> value={searchQueryText}
<p className="text-sm text-muted-foreground"> onChange={(e) => setSearchQueryText(e.target.value)}
View and manage file import jobs from S3 storage. className="pl-10"
</p> />
</div> </div>
<Select
value={statusFilter}
onValueChange={(value) =>
setStatusFilter(
value === "ALL" ? undefined : (value as MangaImportJobDTOStatus),
)
}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="PROCESSING">Processing</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
<SelectItem value="FAILED">Failed</SelectItem>
</SelectContent>
</Select>
</div>
{/* Stats */} <Card>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5"> <Table>
<Card className="p-4"> <TableHeader>
<p className="text-sm text-muted-foreground">Total Jobs</p> <TableRow>
<p className="text-2xl font-bold text-foreground">{stats.total}</p> <TableHead className="w-[100px]">ID</TableHead>
</Card> <TableHead className="w-[120px]">Status</TableHead>
<Card className="p-4"> <TableHead className="w-[100px]">MAL ID</TableHead>
<p className="text-sm text-muted-foreground">Pending</p> <TableHead className="w-[100px]">AniList ID</TableHead>
<p className="text-2xl font-bold text-muted-foreground"> <TableHead>Filename</TableHead>
{stats.pending} <TableHead>S3 Key</TableHead>
</p> <TableHead className="w-40">Created At</TableHead>
</Card> <TableHead className="w-40">Updated At</TableHead>
<Card className="p-4"> </TableRow>
<p className="text-sm text-muted-foreground">Processing</p> </TableHeader>
<p className="text-2xl font-bold text-blue-500">{stats.processing}</p> <TableBody>
</Card> {!importJobsData?.page?.content ||
<Card className="p-4"> importJobsData.page?.content.length === 0 ? (
<p className="text-sm text-muted-foreground">Completed</p> <TableRow>
<p className="text-2xl font-bold text-green-500">{stats.completed}</p> <TableCell colSpan={8} className="h-24 text-center">
</Card> <div className="flex flex-col items-center gap-2 text-muted-foreground">
<Card className="p-4"> <FileText className="h-8 w-8" />
<p className="text-sm text-muted-foreground">Failed</p> <p>No import jobs found</p>
<p className="text-2xl font-bold text-destructive">{stats.failed}</p> </div>
</Card> </TableCell>
</div> </TableRow>
) : (
importJobsData.page.content.map((job) => (
<TableRow key={job.id}>
<TableCell className="font-mono text-xs">{job.id}</TableCell>
<TableCell>{getStatusBadge(job)}</TableCell>
<TableCell>
{job.malId ? (
<a
href={`https://myanimelist.net/manga/${job.malId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary hover:underline"
>
{job.malId}
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{job.aniListId ? (
<a
href={`https://anilist.co/manga/${job.aniListId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary hover:underline"
>
{job.aniListId}
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell
className="max-w-[200px] truncate font-medium"
title={job.filename}
>
{job.filename}
</TableCell>
<TableCell className="max-w-[200px]">
<a
href={`${import.meta.env.VITE_OMV_BASE_URL}/${job.s3Key}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:underline truncate"
title={job.s3Key}
>
{job.s3Key}
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(job.createdAt ?? "")}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(job.updatedAt ?? "")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{/* Filters */} {importJobsData?.page?.totalPages &&
{/*<div className="flex flex-wrap items-center gap-4">*/} importJobsData.page.totalPages > 1 && (
{/* <div className="relative flex-1 min-w-[200px]">*/} <Pagination
{/* <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />*/} currentPage={currentPage}
{/* <Input*/} totalPages={importJobsData.page.totalPages}
{/* placeholder="Search by ID, filename, MAL ID, or AniList ID..."*/} onPageChange={setCurrentPage}
{/* value={searchQuery}*/} />
{/* onChange={(e) => setSearchQuery(e.target.value)}*/} )}
{/* className="pl-10"*/} </Card>
{/* />*/}
{/* </div>*/}
{/* <Select*/}
{/* value={statusFilter}*/}
{/* onValueChange={(value) => setStatusFilter(value as ImportStatus | "ALL")}*/}
{/* >*/}
{/* <SelectTrigger className="w-[150px]">*/}
{/* <SelectValue placeholder="Filter by status" />*/}
{/* </SelectTrigger>*/}
{/* <SelectContent>*/}
{/* <SelectItem value="ALL">All Statuses</SelectItem>*/}
{/* <SelectItem value="PENDING">Pending</SelectItem>*/}
{/* <SelectItem value="PROCESSING">Processing</SelectItem>*/}
{/* <SelectItem value="COMPLETED">Completed</SelectItem>*/}
{/* <SelectItem value="FAILED">Failed</SelectItem>*/}
{/* </SelectContent>*/}
{/* </Select>*/}
{/*</div>*/}
{/* Table */} <Dialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<Card> <DialogContent className="max-w-2xl">
<Table> <DialogHeader>
<TableHeader> <DialogTitle className="flex items-center gap-2 text-destructive">
<TableRow> <AlertTriangle className="h-5 w-5" />
<TableHead className="w-[100px]">ID</TableHead> Import Error Details
<TableHead className="w-[120px]">Status</TableHead> </DialogTitle>
<TableHead className="w-[100px]">MAL ID</TableHead> <DialogDescription className="truncate">
<TableHead className="w-[100px]">AniList ID</TableHead> Job ID: {selectedJob?.id} | File: {selectedJob?.filename}
<TableHead>Filename</TableHead> </DialogDescription>
<TableHead>S3 Key</TableHead> </DialogHeader>
<TableHead className="w-40">Created At</TableHead> {selectedJob?.errorMessage && (
<TableHead className="w-40">Updated At</TableHead> <div className="space-y-4">
</TableRow> <div>
</TableHeader> <label className="text-sm font-medium text-foreground">
<TableBody> Message
{!importJobs || importJobs.length === 0 ? ( </label>
<TableRow> <p className="mt-1 max-h-[100px] overflow-auto wrap-break-word text-sm text-muted-foreground">
<TableCell colSpan={8} className="h-24 text-center"> {selectedJob.errorMessage}
<div className="flex flex-col items-center gap-2 text-muted-foreground"> </p>
<FileText className="h-8 w-8" /> </div>
<p>No import jobs found</p> {selectedJob.errorStackTrace && (
</div> <div>
</TableCell> <label className="text-sm font-medium text-foreground">
</TableRow> Stack Trace
) : ( </label>
importJobs.map((job) => ( <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">
<TableRow key={job.id}> {selectedJob.errorStackTrace}
<TableCell className="font-mono text-xs">{job.id}</TableCell> </pre>
<TableCell>{getStatusBadge(job)}</TableCell> </div>
<TableCell> )}
{job.malId ? ( <div className="flex justify-end gap-2 pt-2">
<a <Button
href={`https://myanimelist.net/manga/${job.malId}`} variant="outline"
target="_blank" onClick={() => setErrorDialogOpen(false)}
rel="noopener noreferrer" >
className="flex items-center gap-1 text-primary hover:underline" Close
> </Button>
{job.malId} {/*<Button*/}
<ExternalLink className="h-3 w-3" /> {/* variant="default"*/}
</a> {/* onClick={() => {*/}
) : ( {/* // Mock retry action*/}
<span className="text-muted-foreground">-</span> {/* setJobs((prev) =>*/}
)} {/* prev.map((j) =>*/}
</TableCell> {/* j.id === selectedJob.id*/}
<TableCell> {/* ? { ...j, status: "PENDING" as ImportStatus, updatedAt: new Date().toISOString() }*/}
{job.aniListId ? ( {/* : j*/}
<a {/* )*/}
href={`https://anilist.co/manga/${job.aniListId}`} {/* )*/}
target="_blank" {/* setErrorDialogOpen(false)*/}
rel="noopener noreferrer" {/* }}*/}
className="flex items-center gap-1 text-primary hover:underline" {/*>*/}
> {/* <RefreshCw className="mr-2 h-4 w-4" />*/}
{job.aniListId} {/* Retry Import*/}
<ExternalLink className="h-3 w-3" /> {/*</Button>*/}
</a> </div>
) : ( </div>
<span className="text-muted-foreground">-</span> )}
)} </DialogContent>
</TableCell> </Dialog>
<TableCell className="max-w-[200px] truncate font-medium" title={job.filename}> </div>
{job.filename} );
</TableCell> };
<TableCell className="max-w-[200px]">
<a
href={`${import.meta.env.VITE_API_BASE_URL}/${job.s3Key}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:underline truncate"
title={job.s3Key}
>
{job.s3Key}
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(job.createdAt ?? "")}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(job.updatedAt ?? "")}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
{/* Error Details Dialog */}
<Dialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Import Error Details
</DialogTitle>
<DialogDescription>
Job ID: {selectedJob?.id} | File: {selectedJob?.filename}
</DialogDescription>
</DialogHeader>
{selectedJob?.errorMessage && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground">
Message
</label>
<p className="mt-1 text-sm text-muted-foreground">
{selectedJob.errorMessage}
</p>
</div>
{selectedJob.errorStackTrace && (
<div>
<label className="text-sm font-medium text-foreground">
Stack Trace
</label>
<pre className="mt-1 max-h-[200px] overflow-auto rounded-md bg-muted p-3 text-xs font-mono text-muted-foreground">
{selectedJob.errorStackTrace}
</pre>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={() => setErrorDialogOpen(false)}
>
Close
</Button>
{/*<Button*/}
{/* variant="default"*/}
{/* onClick={() => {*/}
{/* // Mock retry action*/}
{/* setJobs((prev) =>*/}
{/* prev.map((j) =>*/}
{/* j.id === selectedJob.id*/}
{/* ? { ...j, status: "PENDING" as ImportStatus, updatedAt: new Date().toISOString() }*/}
{/* : j*/}
{/* )*/}
{/* )*/}
{/* setErrorDialogOpen(false)*/}
{/* }}*/}
{/*>*/}
{/* <RefreshCw className="mr-2 h-4 w-4" />*/}
{/* Retry Import*/}
{/*</Button>*/}
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
};

View File

@ -1,123 +1,134 @@
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 { Button } from "@/components/ui/button.tsx";
import {ExternalLink, Loader2, RefreshCw} from "lucide-react"; import { Card } from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.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">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold text-foreground"> <h2 className="text-xl font-semibold text-foreground">
Manga Providers Manga Providers
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{activeCount} of {providers?.length} providers active {activeCount} of {providers?.length} providers active
</p> </p>
</div> </div>
<Button <Button
onClick={() => mutateFetchAllContentProviderMangas()} onClick={() => mutateFetchAllContentProviderMangas()}
disabled={isPendingFetchAllContentProviderMangas || activeCount === 0} disabled={isPendingFetchAllContentProviderMangas || activeCount === 0}
className="gap-2" className="gap-2"
> >
{isPendingFetchAllContentProviderMangas ? ( {isPendingFetchAllContentProviderMangas ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
)} )}
{isPendingFetchAllContentProviderMangas ? "Updating..." : "Update All Providers"} {isPendingFetchAllContentProviderMangas
</Button> ? "Updating..."
</div> : "Update All Providers"}
</Button>
</div>
<div className="space-y-3"> <div className="space-y-3">
{providers?.map((provider) => { {providers?.map((provider) => {
return ( return (
<Card key={provider.id} className="p-4"> <Card key={provider.id} className="p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Active Toggle */} {/* Active Toggle */}
{/*<Switch*/} {/*<Switch*/}
{/* checked={provider.active}*/} {/* checked={provider.active}*/}
{/* onCheckedChange={() => handleToggleActive(provider.id)}*/} {/* onCheckedChange={() => handleToggleActive(provider.id)}*/}
{/* disabled={isUpdating}*/} {/* disabled={isUpdating}*/}
{/*/>*/} {/*/>*/}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-medium text-foreground"> <h3 className="font-medium text-foreground">
{provider.name} {provider.name}
</h3> </h3>
<Badge <Badge
variant={ variant={provider?.active ? "default" : "outline"}
provider?.active className={
? "default" provider?.active
: "outline" ? "bg-emerald-600 text-white hover:bg-emerald-600"
} : ""
className={ }
provider?.active >
? "bg-emerald-600 text-white hover:bg-emerald-600" {provider?.active ? "active" : "inactive"}
: "" </Badge>
} </div>
>
{provider?.active ? "active" : "inactive"}
</Badge>
</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}
</code> </code>
<a <a
href={provider.url} href={provider.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="shrink-0" className="shrink-0"
> >
<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:{" "}*/}
{/*{new Date(provider.lastUpdated).toLocaleString()}*/} {/*{new Date(provider.lastUpdated).toLocaleString()}*/}
</p> </p>
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => mutateFetchContentProviderMangas({providerId: provider?.id ?? -1})} onClick={() =>
disabled={!provider.active || isPendingFetchContentProviderMangas} mutateFetchContentProviderMangas({
className="gap-2 shrink-0" providerId: provider?.id ?? -1,
> })
{isPendingFetchContentProviderMangas ? ( }
<Loader2 className="h-3 w-3 animate-spin" /> disabled={
) : ( !provider.active || isPendingFetchContentProviderMangas
<RefreshCw className="h-3 w-3" /> }
)} className="gap-2 shrink-0"
Update >
</Button> {isPendingFetchContentProviderMangas ? (
</div> <Loader2 className="h-3 w-3 animate-spin" />
</Card> ) : (
) <RefreshCw className="h-3 w-3" />
})} )}
</div> Update
</div> </Button>
) </div>
} </Card>
);
})}
</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,10 +169,11 @@ 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"
}`} }`}
> >
<input <input
type="file" type="file"
@ -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 type { MangaIngestReviewDTO } from "@/api/generated/api.schemas.ts";
import {
useDeleteMangaIngestReview,
useResolveMangaIngestReview,
} from "@/api/generated/manga-ingest-review/manga-ingest-review.ts";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type {MangaIngestReviewDTO} from "@/api/generated/api.schemas.ts";
import {
useDeleteMangaIngestReview,
useResolveMangaIngestReview
} from "@/api/generated/manga-ingest-review/manga-ingest-review.ts";
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 {useFetchContentProviderContent} from "@/api/generated/ingestion/ingestion.ts"; import {
useDownloadContentArchive,
useGetMangaProviderContent,
} from "@/api/generated/content/content.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,12 +41,13 @@ export const MangaChapter = ({
}, },
}); });
const { mutate, isPending: isPendingFetchChapter } = useFetchContentProviderContent({ const { mutate, isPending: isPendingFetchChapter } =
mutation: { useFetchContentProviderContent({
onSuccess: () => queryClient.invalidateQueries({ queryKey }), mutation: {
onSettled: () => setFetchingId(null), onSuccess: () => queryClient.invalidateQueries({ queryKey }),
}, onSettled: () => setFetchingId(null),
}); },
});
const [fetchingId, setFetchingId] = useState<number | null>(null); const [fetchingId, setFetchingId] = useState<number | null>(null);
@ -77,8 +82,9 @@ 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 ? (
<Check className="h-4 w-4 text-primary" /> <Check className="h-4 w-4 text-primary" />
@ -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

@ -1,20 +1,20 @@
import useWindowDimensions from "@/hooks/useWindowDimensions.ts"; import useWindowDimensions from "@/hooks/useWindowDimensions.ts";
export const useDynamicPageSize = (rows = 4) => { export const useDynamicPageSize = (rows = 4) => {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
if (width >= 1280) { if (width >= 1280) {
// xl: 5 columns // xl: 5 columns
return 5 * rows; return 5 * rows;
} }
if (width >= 1024) { if (width >= 1024) {
// lg: 4 columns // lg: 4 columns
return 4 * rows; return 4 * rows;
} }
if (width >= 768) { if (width >= 768) {
// md: 3 columns // md: 3 columns
return 3 * rows; return 3 * rows;
} }
// default: 2 columns // default: 2 columns
return 2 * rows; return 2 * rows;
}; };

View File

@ -2,18 +2,18 @@ import { useLayoutEffect } from "react";
import { useUIState } from "@/contexts/UIStateContext.tsx"; import { useUIState } from "@/contexts/UIStateContext.tsx";
export const useScrollPersistence = (key: string) => { export const useScrollPersistence = (key: string) => {
const { scrollPositions, setScrollPosition } = useUIState(); const { scrollPositions, setScrollPosition } = useUIState();
useLayoutEffect(() => { useLayoutEffect(() => {
// Restore scroll position // Restore scroll position
const savedPosition = scrollPositions[key]; const savedPosition = scrollPositions[key];
if (savedPosition !== undefined) { if (savedPosition !== undefined) {
window.scrollTo(0, savedPosition); window.scrollTo(0, savedPosition);
} }
// Save scroll position on unmount or before key changes // Save scroll position on unmount or before key changes
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,26 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
function getWindowDimensions() { function getWindowDimensions() {
const { innerWidth: width, innerHeight: height } = window; const { innerWidth: width, innerHeight: height } = window;
return { return {
width, width,
height, height,
}; };
} }
export default function useWindowDimensions() { export default function useWindowDimensions() {
const [windowDimensions, setWindowDimensions] = useState( const [windowDimensions, setWindowDimensions] = useState(
getWindowDimensions(), getWindowDimensions(),
); );
useEffect(() => { useEffect(() => {
function handleResize() { function handleResize() {
setWindowDimensions(getWindowDimensions()); setWindowDimensions(getWindowDimensions());
} }
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, []); }, []);
return windowDimensions; return windowDimensions;
} }

View File

@ -1,241 +1,247 @@
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 { useAuth } from "@/contexts/AuthContext.tsx";
import {ProviderManager} from "@/features/admin/components/ProviderManager.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
if (!isLoading && !isAuthenticated) { if (!isLoading && !isAuthenticated) {
// navigate("/login"); // navigate("/login");
// return; // return;
} }
// TODO: add user role verification // TODO: add user role verification
}, [isAuthenticated, isLoading, navigate]); }, [isAuthenticated, isLoading]);
// const { data } = useGetMangaImportJobs(); // const { data } = useGetMangaImportJobs();
// //
// const failedImports = data?.data?.filter(dto => dto.status === MangaImportJobDTOStatus.FAILED)?.length; // const failedImports = data?.data?.filter(dto => dto.status === MangaImportJobDTOStatus.FAILED)?.length;
const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [ const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
{ {
id: "providers", id: "providers",
label: "Providers", label: "Providers",
icon: <Server className="h-4 w-4" />, icon: <Server className="h-4 w-4" />,
}, },
// { // {
// id: "manga", // id: "manga",
// label: "Manga Library", // label: "Manga Library",
// icon: <BookOpen className="h-4 w-4" />, // icon: <BookOpen className="h-4 w-4" />,
// }, // },
// { // {
// id: "import", // id: "import",
// label: "Import", // label: "Import",
// icon: <Download className="h-4 w-4" />, // icon: <Download className="h-4 w-4" />,
// }, // },
// { // {
// id: "ingest-review", // id: "ingest-review",
// label: "Ingest Review", // label: "Ingest Review",
// icon: <AlertCircle className="h-4 w-4" />, // icon: <AlertCircle className="h-4 w-4" />,
// badge: failedImports.length > 0 ? failedImports.length : undefined, // badge: failedImports.length > 0 ? failedImports.length : undefined,
// }, // },
{ {
id: "import-jobs", id: "import-jobs",
label: "Manual Import Jobs", label: "Manual Import Jobs",
icon: <FileStack className="h-4 w-4" />, icon: <FileStack className="h-4 w-4" />,
}, },
// { // {
// id: "users", // id: "users",
// 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">
<div className="flex"> <div className="flex">
{/* Sidebar */} {/* Sidebar */}
<aside className="sticky top-0 h-screen w-64 shrink-0 border-r border-border bg-card"> <aside className="sticky top-0 h-screen w-64 shrink-0 border-r border-border bg-card">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Sidebar Header */} {/* Sidebar Header */}
<div className="border-b border-border px-6 py-5"> <div className="border-b border-border px-6 py-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" /> <Shield className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold text-foreground"> <h1 className="text-lg font-bold text-foreground">
Admin Panel Admin Panel
</h1> </h1>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Manage your MangaMochi platform Manage your MangaMochi platform
</p> </p>
</div> </div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 space-y-1 px-3 py-4"> <nav className="flex-1 space-y-1 px-3 py-4">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${ className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.id activeTab === tab.id
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
}`} }`}
> >
{tab.icon} {tab.icon}
{tab.label} {tab.label}
{tab.badge && ( {tab.badge && (
<span <span
className={`ml-auto flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-bold ${ className={`ml-auto flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-bold ${
activeTab === tab.id activeTab === tab.id
? "bg-primary-foreground text-primary" ? "bg-primary-foreground text-primary"
: "bg-destructive text-destructive-foreground" : "bg-destructive text-destructive-foreground"
}`} }`}
> >
{tab.badge} {tab.badge}
</span> </span>
)} )}
</button> </button>
))} ))}
</nav> </nav>
{/* Sidebar Footer */} {/* Sidebar Footer */}
<div className="border-t border-border px-3 py-4"> <div className="border-t border-border px-3 py-4">
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground" className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
onClick={() => navigate("/")} onClick={() => navigate("/")}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to Home Back to Home
</Button> </Button>
</div> </div>
</div> </div>
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 px-8 py-8"> <div className="flex-1 px-8 py-8">
{activeTab === "providers" && <ProviderManager />} {activeTab === "providers" && <ProviderManager />}
{/*{activeTab === "manga" && <AdminMangaTable />}*/} {/*{activeTab === "manga" && <AdminMangaTable />}*/}
{/*{activeTab === "import" && (*/} {/*{activeTab === "import" && (*/}
{/* <div className="space-y-6">*/} {/* <div className="space-y-6">*/}
{/* <div>*/} {/* <div>*/}
{/* <h2 className="text-xl font-semibold text-foreground">*/} {/* <h2 className="text-xl font-semibold text-foreground">*/}
{/* Import Manga*/} {/* Import Manga*/}
{/* </h2>*/} {/* </h2>*/}
{/* <p className="text-sm text-muted-foreground">*/} {/* <p className="text-sm text-muted-foreground">*/}
{/* Import manga from external providers or upload files directly.*/} {/* Import manga from external providers or upload files directly.*/}
{/* </p>*/} {/* </p>*/}
{/* </div>*/} {/* </div>*/}
{/* <div className="grid grid-cols-1 gap-6 md:grid-cols-2">*/} {/* <div className="grid grid-cols-1 gap-6 md:grid-cols-2">*/}
{/* /!* Import from Provider *!/*/} {/* /!* Import from Provider *!/*/}
{/* <Card className="p-6">*/} {/* <Card className="p-6">*/}
{/* <div className="flex flex-col items-center gap-4 text-center">*/} {/* <div className="flex flex-col items-center gap-4 text-center">*/}
{/* <div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">*/} {/* <div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">*/}
{/* <Download className="h-6 w-6 text-primary" />*/} {/* <Download className="h-6 w-6 text-primary" />*/}
{/* </div>*/} {/* </div>*/}
{/* <div>*/} {/* <div>*/}
{/* <h3 className="font-semibold text-foreground">*/} {/* <h3 className="font-semibold text-foreground">*/}
{/* Import from Provider*/} {/* Import from Provider*/}
{/* </h3>*/} {/* </h3>*/}
{/* <p className="mt-1 text-sm text-muted-foreground">*/} {/* <p className="mt-1 text-sm text-muted-foreground">*/}
{/* Import manga from MangaDex, MangaPlus, Bato.to, or*/} {/* Import manga from MangaDex, MangaPlus, Bato.to, or*/}
{/* other supported providers.*/} {/* other supported providers.*/}
{/* </p>*/} {/* </p>*/}
{/* </div>*/} {/* </div>*/}
{/* <ImportDropdown />*/} {/* <ImportDropdown />*/}
{/* </div>*/} {/* </div>*/}
{/* </Card>*/} {/* </Card>*/}
{/* /!* Import from File *!/*/} {/* /!* Import from File *!/*/}
{/* <Card className="p-6">*/} {/* <Card className="p-6">*/}
{/* <div className="flex flex-col items-center gap-4 text-center">*/} {/* <div className="flex flex-col items-center gap-4 text-center">*/}
{/* <div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">*/} {/* <div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">*/}
{/* <FileUp className="h-6 w-6 text-primary" />*/} {/* <FileUp className="h-6 w-6 text-primary" />*/}
{/* </div>*/} {/* </div>*/}
{/* <div>*/} {/* <div>*/}
{/* <h3 className="font-semibold text-foreground">*/} {/* <h3 className="font-semibold text-foreground">*/}
{/* Bulk File Upload*/} {/* Bulk File Upload*/}
{/* </h3>*/} {/* </h3>*/}
{/* <p className="mt-1 text-sm text-muted-foreground">*/} {/* <p className="mt-1 text-sm text-muted-foreground">*/}
{/* Upload JSON, CSV, or text files to import manga data in*/} {/* Upload JSON, CSV, or text files to import manga data in*/}
{/* bulk.*/} {/* bulk.*/}
{/* </p>*/} {/* </p>*/}
{/* </div>*/} {/* </div>*/}
{/* <Button*/} {/* <Button*/}
{/* variant="outline"*/} {/* variant="outline"*/}
{/* className="gap-2"*/} {/* className="gap-2"*/}
{/* onClick={() => {*/} {/* onClick={() => {*/}
{/* const importBtn = document.querySelector(*/} {/* const importBtn = document.querySelector(*/}
{/* '[data-import-dropdown]'*/} {/* '[data-import-dropdown]'*/}
{/* ) as HTMLButtonElement*/} {/* ) as HTMLButtonElement*/}
{/* if (importBtn) importBtn.click()*/} {/* if (importBtn) importBtn.click()*/}
{/* }}*/} {/* }}*/}
{/* >*/} {/* >*/}
{/* <FileUp className="h-4 w-4" />*/} {/* <FileUp className="h-4 w-4" />*/}
{/* Upload Files*/} {/* Upload Files*/}
{/* </Button>*/} {/* </Button>*/}
{/* </div>*/} {/* </div>*/}
{/* </Card>*/} {/* </Card>*/}
{/* </div>*/} {/* </div>*/}
{/* </div>*/} {/* </div>*/}
{/*)}*/} {/*)}*/}
{/*{activeTab === "ingest-review" && (*/} {/*{activeTab === "ingest-review" && (*/}
{/* <div className="space-y-6">*/} {/* <div className="space-y-6">*/}
{/* <div>*/} {/* <div>*/}
{/* <h2 className="text-xl font-semibold text-foreground">*/} {/* <h2 className="text-xl font-semibold text-foreground">*/}
{/* Ingest Review*/} {/* Ingest Review*/}
{/* </h2>*/} {/* </h2>*/}
{/* <p className="text-sm text-muted-foreground">*/} {/* <p className="text-sm text-muted-foreground">*/}
{/* Review and resolve imports that need manual matching.*/} {/* Review and resolve imports that need manual matching.*/}
{/* </p>*/} {/* </p>*/}
{/* </div>*/} {/* </div>*/}
{/* {failedImports.length === 0 ? (*/} {/* {failedImports.length === 0 ? (*/}
{/* <Card className="p-8 text-center">*/} {/* <Card className="p-8 text-center">*/}
{/* <AlertCircle className="mx-auto h-12 w-12 text-muted-foreground" />*/} {/* <AlertCircle className="mx-auto h-12 w-12 text-muted-foreground" />*/}
{/* <h3 className="mt-4 text-lg font-semibold text-foreground">*/} {/* <h3 className="mt-4 text-lg font-semibold text-foreground">*/}
{/* No Pending Reviews*/} {/* No Pending Reviews*/}
{/* </h3>*/} {/* </h3>*/}
{/* <p className="mt-2 text-muted-foreground">*/} {/* <p className="mt-2 text-muted-foreground">*/}
{/* All imports have been processed successfully.*/} {/* All imports have been processed successfully.*/}
{/* </p>*/} {/* </p>*/}
{/* </Card>*/} {/* </Card>*/}
{/* ) : (*/} {/* ) : (*/}
{/* <div className="space-y-4">*/} {/* <div className="space-y-4">*/}
{/* <p className="text-sm text-muted-foreground">*/} {/* <p className="text-sm text-muted-foreground">*/}
{/* {failedImports.length} import*/} {/* {failedImports.length} import*/}
{/* {failedImports.length !== 1 ? "s" : ""} to review*/} {/* {failedImports.length !== 1 ? "s" : ""} to review*/}
{/* </p>*/} {/* </p>*/}
{/* {failedImports.map((fi) => (*/} {/* {failedImports.map((fi) => (*/}
{/* <FailedImportCard key={fi.id} failedImport={fi} />*/} {/* <FailedImportCard key={fi.id} failedImport={fi} />*/}
{/* ))}*/} {/* ))}*/}
{/* </div>*/} {/* </div>*/}
{/* )}*/} {/* )}*/}
{/* </div>*/} {/* </div>*/}
{/*)}*/} {/*)}*/}
{activeTab === "import-jobs" && <FailedImportJobs />} {activeTab === "import-jobs" && <FailedImportJobs />}
{/*{activeTab === "users" && <AdminUserManager />}*/} {/*{activeTab === "users" && <AdminUserManager />}*/}
</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,11 +46,12 @@ const Manga = () => {
const { data: mangaData, queryKey } = useGetManga(mangaId); const { data: mangaData, queryKey } = useGetManga(mangaId);
const { mutate, isPending: fetchPending } = useFetchContentProviderContentList({ const { mutate, isPending: fetchPending } =
mutation: { useFetchContentProviderContentList({
onSuccess: () => queryClient.invalidateQueries({ queryKey }), mutation: {
}, onSuccess: () => queryClient.invalidateQueries({ queryKey }),
}); },
});
// const { mutate: fetchAllMutate, isPending: fetchAllPending } = // const { mutate: fetchAllMutate, isPending: fetchAllPending } =
// useFetchAllChapters({ // useFetchAllChapters({
@ -173,8 +174,8 @@ const Manga = () => {
src={ src={
(mangaData.data?.coverImageKey && (mangaData.data?.coverImageKey &&
import.meta.env.VITE_OMV_BASE_URL + import.meta.env.VITE_OMV_BASE_URL +
"/" + "/" +
mangaData.data?.coverImageKey) || mangaData.data?.coverImageKey) ||
"/placeholder.svg" "/placeholder.svg"
} }
alt={mangaData.data?.title ?? ""} alt={mangaData.data?.title ?? ""}
@ -226,10 +227,11 @@ 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 ${
? "fill-red-500 text-red-500" mangaData?.data?.favorite
: "text-muted-foreground hover:text-red-500" ? "fill-red-500 text-red-500"
}`} : "text-muted-foreground hover:text-red-500"
}`}
/> />
</Button> </Button>
</> </>
@ -283,7 +285,7 @@ const Manga = () => {
<p className="text-sm text-muted-foreground">Chapters</p> <p className="text-sm text-muted-foreground">Chapters</p>
<p className="text-lg font-semibold text-foreground"> <p className="text-lg font-semibold text-foreground">
{mangaData.data?.chapterCount && {mangaData.data?.chapterCount &&
mangaData.data?.chapterCount > 0 mangaData.data?.chapterCount > 0
? mangaData.data?.chapterCount ? mangaData.data?.chapterCount
: "-"} : "-"}
</p> </p>
@ -364,41 +366,45 @@ 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"*/} {/* variant="outline"*/}
{/* variant="outline"*/} {/* disabled={isPending}*/}
{/* disabled={isPending}*/} {/* onClick={() =>*/}
{/* onClick={() =>*/} {/* fetchAllMutate({*/}
{/* fetchAllMutate({*/} {/* mangaProviderId: provider.id?? -1,*/}
{/* mangaProviderId: provider.id?? -1,*/} {/* })*/}
{/* })*/} {/* }*/}
{/* }*/} {/* className="gap-2"*/}
{/* className="gap-2"*/} {/*>*/}
{/*>*/} {/* <Database className="h-4 w-4" />*/}
{/* <Database className="h-4 w-4" />*/} {/* Fetch all from Provider*/}
{/* Fetch all from Provider*/} {/*</Button>*/}
{/*</Button>*/} <Button
<Button size="sm"
size="sm" variant="outline"
variant="outline" disabled={isPending}
disabled={isPending} onClick={() =>
onClick={() => mutate({
mutate({ mangaContentProviderId: provider.id ?? -1 }) mangaContentProviderId: provider.id ?? -1,
} })
className="gap-2" }
> className="gap-2"
<Database className="h-4 w-4" /> >
Fetch from Provider <Database className="h-4 w-4" />
</Button> Fetch from Provider
</div> </Button>
)} </div>
)}
</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>
</CollapsibleTrigger> </CollapsibleTrigger>

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",