refactor: Enhance FailedImportJobs component with improved state management and UI updates
This commit is contained in:
parent
6ea9eaf2ee
commit
c544657720
164
package-lock.json
generated
164
package-lock.json
generated
@ -40,6 +40,7 @@
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.9",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/react": "^19.1.16",
|
||||
@ -432,6 +433,169 @@
|
||||
"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": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz",
|
||||
|
||||
@ -42,6 +42,7 @@
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.9",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@types/node": "^24.9.2",
|
||||
"@types/react": "^19.1.16",
|
||||
|
||||
@ -145,9 +145,9 @@ export interface MangaContentImagesDTO {
|
||||
contentImageKeys: string[];
|
||||
}
|
||||
|
||||
export interface DefaultResponseDTOListMangaImportJobDTO {
|
||||
export interface DefaultResponseDTOMangaImportJobPageResponseDTO {
|
||||
timestamp?: string;
|
||||
data?: MangaImportJobDTO[];
|
||||
data?: MangaImportJobPageResponseDTO;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@ -175,6 +175,44 @@ export interface MangaImportJobDTO {
|
||||
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 {
|
||||
timestamp?: string;
|
||||
data?: PageMangaListDTO;
|
||||
@ -222,21 +260,6 @@ export interface PageMangaListDTO {
|
||||
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 {
|
||||
timestamp?: string;
|
||||
data?: MangaDTO;
|
||||
@ -337,6 +360,36 @@ export type GetContentProvidersParams = {
|
||||
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 = {
|
||||
searchQuery?: string;
|
||||
genreIds?: number[];
|
||||
|
||||
@ -25,12 +25,13 @@ import type {
|
||||
|
||||
import type {
|
||||
DefaultResponseDTOListMangaContentDTO,
|
||||
DefaultResponseDTOListMangaImportJobDTO,
|
||||
DefaultResponseDTOMangaContentImagesDTO,
|
||||
DefaultResponseDTOMangaImportJobPageResponseDTO,
|
||||
DefaultResponseDTOPresignedImportResponseDTO,
|
||||
DefaultResponseDTOVoid,
|
||||
DownloadContentArchiveParams,
|
||||
FileImportRequestDTO,
|
||||
GetMangaImportJobsParams,
|
||||
PresignedImportRequestDTO
|
||||
} from '../api.schemas';
|
||||
|
||||
@ -434,17 +435,18 @@ export function useGetMangaContentImages<TData = Awaited<ReturnType<typeof getMa
|
||||
|
||||
|
||||
/**
|
||||
* Returns a list of manga import jobs.
|
||||
* @summary Get a list of manga import jobs
|
||||
* Returns a paginated list of manga import jobs with optional filters and global status counts.
|
||||
* @summary Get a paginated list of manga import jobs
|
||||
*/
|
||||
export const getMangaImportJobs = (
|
||||
|
||||
params?: GetMangaImportJobsParams,
|
||||
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customInstance<DefaultResponseDTOListMangaImportJobDTO>(
|
||||
{url: `/content/import/jobs`, method: 'GET', signal
|
||||
return customInstance<DefaultResponseDTOMangaImportJobPageResponseDTO>(
|
||||
{url: `/content/import/jobs`, method: 'GET',
|
||||
params, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
@ -452,23 +454,23 @@ export const getMangaImportJobs = (
|
||||
|
||||
|
||||
|
||||
export const getGetMangaImportJobsQueryKey = () => {
|
||||
export const getGetMangaImportJobsQueryKey = (params?: GetMangaImportJobsParams,) => {
|
||||
return [
|
||||
`/content/import/jobs`
|
||||
`/content/import/jobs`, ...(params ? [params]: [])
|
||||
] 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 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>(
|
||||
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<
|
||||
Awaited<ReturnType<typeof getMangaImportJobs>>,
|
||||
TError,
|
||||
@ -492,7 +494,7 @@ export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getManga
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
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<
|
||||
Awaited<ReturnType<typeof getMangaImportJobs>>,
|
||||
TError,
|
||||
@ -502,19 +504,19 @@ export function useGetMangaImportJobs<TData = Awaited<ReturnType<typeof getManga
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
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
|
||||
): 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>(
|
||||
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
|
||||
): 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> };
|
||||
|
||||
|
||||
@ -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 { Avatar, AvatarFallback } from "@/components/ui/avatar.tsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@ -63,4 +63,4 @@ function AlertDescription({
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
|
||||
@ -48,4 +48,4 @@ function AvatarFallback({
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
export { Avatar, AvatarFallback, AvatarImage };
|
||||
|
||||
@ -83,10 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
|
||||
@ -28,4 +28,4 @@ function CollapsibleContent({
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
|
||||
|
||||
@ -238,18 +238,18 @@ function DropdownMenuSubContent({
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@ -156,12 +156,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
};
|
||||
|
||||
@ -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({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@ -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">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
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]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
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]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
};
|
||||
|
||||
@ -61,4 +61,4 @@ function TabsContent({
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
|
||||
@ -6,8 +6,8 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
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 {
|
||||
theme: "light" | "dark";
|
||||
|
||||
@ -1,143 +1,141 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { SortOption } from "@/features/home/components/SortDropdown.tsx";
|
||||
|
||||
interface UIStateContextType {
|
||||
/* Home Filter State */
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
selectedGenres: number[];
|
||||
setSelectedGenres: (genres: number[]) => void;
|
||||
selectedStatus: string[];
|
||||
setSelectedStatus: (status: string[]) => void;
|
||||
minRating: number;
|
||||
setMinRating: (rating: number) => void;
|
||||
userFavorites: boolean;
|
||||
setUserFavorites: (favorites: boolean) => void;
|
||||
showAdultContent: boolean;
|
||||
setShowAdultContent: (show: boolean) => void;
|
||||
sortOption: SortOption;
|
||||
setSortOption: (sort: SortOption) => void;
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
resetFilters: () => void;
|
||||
/* Home Filter State */
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
selectedGenres: number[];
|
||||
setSelectedGenres: (genres: number[]) => void;
|
||||
selectedStatus: string[];
|
||||
setSelectedStatus: (status: string[]) => void;
|
||||
minRating: number;
|
||||
setMinRating: (rating: number) => void;
|
||||
userFavorites: boolean;
|
||||
setUserFavorites: (favorites: boolean) => void;
|
||||
showAdultContent: boolean;
|
||||
setShowAdultContent: (show: boolean) => void;
|
||||
sortOption: SortOption;
|
||||
setSortOption: (sort: SortOption) => void;
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
resetFilters: () => void;
|
||||
|
||||
/* Manga Provider Card State */
|
||||
expandedProviderIds: number[];
|
||||
toggleProviderId: (id: number) => void;
|
||||
/* Manga Provider Card State */
|
||||
expandedProviderIds: number[];
|
||||
toggleProviderId: (id: number) => void;
|
||||
|
||||
/* Scroll Persistence */
|
||||
scrollPositions: Record<string, number>;
|
||||
setScrollPosition: (key: string, position: number) => void;
|
||||
/* Scroll Persistence */
|
||||
scrollPositions: Record<string, number>;
|
||||
setScrollPosition: (key: string, position: number) => void;
|
||||
}
|
||||
|
||||
const UIStateContext = createContext<UIStateContextType | undefined>(undefined);
|
||||
|
||||
export const UIStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
/* Home Filter State */
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedGenres, setSelectedGenres] = useState<number[]>([]);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
||||
const [minRating, setMinRating] = useState(0);
|
||||
const [userFavorites, setUserFavorites] = useState(false);
|
||||
const [showAdultContent, setShowAdultContent] = useState(false);
|
||||
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
/* Home Filter State */
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedGenres, setSelectedGenres] = useState<number[]>([]);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string[]>([]);
|
||||
const [minRating, setMinRating] = useState(0);
|
||||
const [userFavorites, setUserFavorites] = useState(false);
|
||||
const [showAdultContent, setShowAdultContent] = useState(false);
|
||||
const [sortOption, setSortOption] = useState<SortOption>("title-asc");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
/* Manga Provider Card State */
|
||||
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
|
||||
/* Manga Provider Card State */
|
||||
const [expandedProviderIds, setExpandedProviderIds] = useState<number[]>([]);
|
||||
|
||||
/* Scroll Persistence */
|
||||
const [scrollPositions, setScrollPositions] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
/* Scroll Persistence */
|
||||
const [scrollPositions, setScrollPositions] = useState<
|
||||
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(() => {
|
||||
setCurrentPage(1);
|
||||
setSelectedGenres([]);
|
||||
setSelectedStatus([]);
|
||||
setMinRating(0);
|
||||
setUserFavorites(false);
|
||||
setShowAdultContent(false);
|
||||
setSortOption("title-asc");
|
||||
setSearchText("");
|
||||
}, []);
|
||||
const setScrollPosition = useCallback((key: string, position: number) => {
|
||||
setScrollPositions((prev) => ({
|
||||
...prev,
|
||||
[key]: position,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const toggleProviderId = useCallback((id: number) => {
|
||||
setExpandedProviderIds((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter((pId) => pId !== id);
|
||||
} else {
|
||||
return [...prev, id];
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
const setScrollPosition = useCallback((key: string, position: number) => {
|
||||
setScrollPositions((prev) => ({
|
||||
...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>
|
||||
);
|
||||
return (
|
||||
<UIStateContext.Provider value={value}>{children}</UIStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUIState = () => {
|
||||
const context = useContext(UIStateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useUIState must be used within a UIStateProvider");
|
||||
}
|
||||
return context;
|
||||
const context = useContext(UIStateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useUIState must be used within a UIStateProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@ -1,304 +1,355 @@
|
||||
import {useGetMangaImportJobs} from "@/api/generated/content/content.ts";
|
||||
import type {MangaImportJobDTO, MangaImportJobDTOStatus} from "@/api/generated/api.schemas.ts";
|
||||
import { AlertTriangle, ExternalLink, FileText, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type {
|
||||
MangaImportJobDTO,
|
||||
MangaImportJobDTOStatus,
|
||||
} from "@/api/generated/api.schemas.ts";
|
||||
import { useGetMangaImportJobs } from "@/api/generated/content/content.ts";
|
||||
import { Pagination } from "@/components/Pagination.tsx";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {AlertTriangle, ExternalLink, FileText, RefreshCw} from "lucide-react";
|
||||
import {Card} from "@/components/ui/card.tsx";
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table.tsx";
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog.tsx";
|
||||
import {useState} from "react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
|
||||
export const FailedImportJobs = () => {
|
||||
const {data} = useGetMangaImportJobs();
|
||||
const importJobs = data?.data;
|
||||
const [searchQueryText, setSearchQueryText] = useState("");
|
||||
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 [statusFilter, setStatusFilter] = useState<ImportStatus | "ALL">("ALL")
|
||||
const [selectedJob, setSelectedJob] = useState<MangaImportJobDTO | null>(null)
|
||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||
const { data } = useGetMangaImportJobs({
|
||||
status: statusFilter,
|
||||
searchQuery: searchQuery,
|
||||
page: currentPage - 1,
|
||||
size: 12,
|
||||
});
|
||||
const importJobsData = data?.data;
|
||||
|
||||
// const filteredJobs = jobs.filter((job) => {
|
||||
// const matchesSearch =
|
||||
// job.filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
// job.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
// job.malId?.includes(searchQuery) ||
|
||||
// job.anilistId?.includes(searchQuery)
|
||||
// const matchesStatus = statusFilter === "ALL" || job.status === statusFilter
|
||||
// return matchesSearch && matchesStatus
|
||||
// })
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
const getStatusBadge = (job: MangaImportJobDTO) => {
|
||||
const variants: Record<
|
||||
MangaImportJobDTOStatus,
|
||||
{
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
className: string;
|
||||
}
|
||||
> = {
|
||||
PENDING: {
|
||||
variant: "secondary",
|
||||
className: "bg-muted text-muted-foreground",
|
||||
},
|
||||
PROCESSING: { variant: "default", className: "bg-blue-500 text-white" },
|
||||
SUCCESS: { variant: "default", className: "bg-green-500 text-white" },
|
||||
FAILED: {
|
||||
variant: "destructive",
|
||||
className: "cursor-pointer hover:bg-destructive/80",
|
||||
},
|
||||
};
|
||||
|
||||
const getStatusBadge = (job: MangaImportJobDTO) => {
|
||||
const variants: Record<MangaImportJobDTOStatus, { variant: "default" | "secondary" | "destructive" | "outline"; className: string }> = {
|
||||
PENDING: { variant: "secondary", className: "bg-muted text-muted-foreground" },
|
||||
PROCESSING: { variant: "default", className: "bg-blue-500 text-white" },
|
||||
SUCCESS: { variant: "default", className: "bg-green-500 text-white" },
|
||||
FAILED: { variant: "destructive", className: "cursor-pointer hover:bg-destructive/80" },
|
||||
}
|
||||
if (!job.status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!job.status) {
|
||||
return null;
|
||||
}
|
||||
const config = variants[job.status];
|
||||
|
||||
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 (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
className={config.className}
|
||||
onClick={() => {
|
||||
setSelectedJob(job)
|
||||
setErrorDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
{job.status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant={config.variant} className={config.className}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className={config.className}>
|
||||
{job.status === "PROCESSING" && (
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{job.status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
Manual Import Jobs
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View and manage file import jobs from S3 storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
const stats = {
|
||||
total: importJobs?.length,
|
||||
pending: importJobs?.filter((j) => j.status === "PENDING").length,
|
||||
processing: importJobs?.filter((j) => j.status === "PROCESSING").length,
|
||||
completed: importJobs?.filter((j) => j.status === "SUCCESS").length,
|
||||
failed: importJobs?.filter((j) => j.status === "FAILED").length,
|
||||
}
|
||||
{importJobsData && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Jobs</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{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="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
Manual Import Jobs
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View and manage file import jobs from S3 storage.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by ID, filename, MAL ID, or AniList ID..."
|
||||
value={searchQueryText}
|
||||
onChange={(e) => setSearchQueryText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</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 */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Jobs</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.total}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Pending</p>
|
||||
<p className="text-2xl font-bold text-muted-foreground">
|
||||
{stats.pending}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Processing</p>
|
||||
<p className="text-2xl font-bold text-blue-500">{stats.processing}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Completed</p>
|
||||
<p className="text-2xl font-bold text-green-500">{stats.completed}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-sm text-muted-foreground">Failed</p>
|
||||
<p className="text-2xl font-bold text-destructive">{stats.failed}</p>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead className="w-[120px]">Status</TableHead>
|
||||
<TableHead className="w-[100px]">MAL ID</TableHead>
|
||||
<TableHead className="w-[100px]">AniList ID</TableHead>
|
||||
<TableHead>Filename</TableHead>
|
||||
<TableHead>S3 Key</TableHead>
|
||||
<TableHead className="w-40">Created At</TableHead>
|
||||
<TableHead className="w-40">Updated At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!importJobsData?.page?.content ||
|
||||
importJobsData.page?.content.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<FileText className="h-8 w-8" />
|
||||
<p>No import jobs found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</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 */}
|
||||
{/*<div className="flex flex-wrap items-center gap-4">*/}
|
||||
{/* <div className="relative flex-1 min-w-[200px]">*/}
|
||||
{/* <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />*/}
|
||||
{/* <Input*/}
|
||||
{/* placeholder="Search by ID, filename, MAL ID, or AniList ID..."*/}
|
||||
{/* value={searchQuery}*/}
|
||||
{/* onChange={(e) => setSearchQuery(e.target.value)}*/}
|
||||
{/* className="pl-10"*/}
|
||||
{/* />*/}
|
||||
{/* </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>*/}
|
||||
{importJobsData?.page?.totalPages &&
|
||||
importJobsData.page.totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={importJobsData.page.totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead className="w-[120px]">Status</TableHead>
|
||||
<TableHead className="w-[100px]">MAL ID</TableHead>
|
||||
<TableHead className="w-[100px]">AniList ID</TableHead>
|
||||
<TableHead>Filename</TableHead>
|
||||
<TableHead>S3 Key</TableHead>
|
||||
<TableHead className="w-40">Created At</TableHead>
|
||||
<TableHead className="w-40">Updated At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!importJobs || importJobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<FileText className="h-8 w-8" />
|
||||
<p>No import jobs found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
importJobs.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_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>
|
||||
)
|
||||
};
|
||||
<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 className="truncate">
|
||||
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 max-h-[100px] overflow-auto wrap-break-word 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 whitespace-pre-wrap wrap-break-word 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,123 +1,134 @@
|
||||
import { ExternalLink, Loader2, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
useFetchAllContentProviderMangas,
|
||||
useFetchContentProviderMangas,
|
||||
useGetContentProviders
|
||||
useFetchAllContentProviderMangas,
|
||||
useFetchContentProviderMangas,
|
||||
useGetContentProviders,
|
||||
} from "@/api/generated/ingestion/ingestion.ts";
|
||||
import {Card} from "@/components/ui/card.tsx";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {ExternalLink, Loader2, RefreshCw} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
|
||||
export const ProviderManager = () => {
|
||||
const { data } = useGetContentProviders();
|
||||
const providers = data?.data?.providers;
|
||||
const { data } = useGetContentProviders();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
Manga Providers
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeCount} of {providers?.length} providers active
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => mutateFetchAllContentProviderMangas()}
|
||||
disabled={isPendingFetchAllContentProviderMangas || activeCount === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPendingFetchAllContentProviderMangas ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{isPendingFetchAllContentProviderMangas ? "Updating..." : "Update All Providers"}
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
Manga Providers
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeCount} of {providers?.length} providers active
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => mutateFetchAllContentProviderMangas()}
|
||||
disabled={isPendingFetchAllContentProviderMangas || activeCount === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPendingFetchAllContentProviderMangas ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{isPendingFetchAllContentProviderMangas
|
||||
? "Updating..."
|
||||
: "Update All Providers"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{providers?.map((provider) => {
|
||||
return (
|
||||
<Card key={provider.id} className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Active Toggle */}
|
||||
{/*<Switch*/}
|
||||
{/* checked={provider.active}*/}
|
||||
{/* onCheckedChange={() => handleToggleActive(provider.id)}*/}
|
||||
{/* disabled={isUpdating}*/}
|
||||
{/*/>*/}
|
||||
<div className="space-y-3">
|
||||
{providers?.map((provider) => {
|
||||
return (
|
||||
<Card key={provider.id} className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Active Toggle */}
|
||||
{/*<Switch*/}
|
||||
{/* checked={provider.active}*/}
|
||||
{/* onCheckedChange={() => handleToggleActive(provider.id)}*/}
|
||||
{/* disabled={isUpdating}*/}
|
||||
{/*/>*/}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-foreground">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<Badge
|
||||
variant={
|
||||
provider?.active
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className={
|
||||
provider?.active
|
||||
? "bg-emerald-600 text-white hover:bg-emerald-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{provider?.active ? "active" : "inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-foreground">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<Badge
|
||||
variant={provider?.active ? "default" : "outline"}
|
||||
className={
|
||||
provider?.active
|
||||
? "bg-emerald-600 text-white hover:bg-emerald-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{provider?.active ? "active" : "inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{provider?.url &&
|
||||
(<div className="mt-1 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<code className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{provider.url}
|
||||
</code>
|
||||
<a
|
||||
href={provider.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</div>)}
|
||||
{provider?.url && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<code className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{provider.url}
|
||||
</code>
|
||||
<a
|
||||
href={provider.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{/*{provider.mangaCount} manga indexed | Last updated:{" "}*/}
|
||||
{/*{new Date(provider.lastUpdated).toLocaleString()}*/}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{/*{provider.mangaCount} manga indexed | Last updated:{" "}*/}
|
||||
{/*{new Date(provider.lastUpdated).toLocaleString()}*/}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => mutateFetchContentProviderMangas({providerId: provider?.id ?? -1})}
|
||||
disabled={!provider.active || isPendingFetchContentProviderMangas}
|
||||
className="gap-2 shrink-0"
|
||||
>
|
||||
{isPendingFetchContentProviderMangas ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
mutateFetchContentProviderMangas({
|
||||
providerId: provider?.id ?? -1,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
!provider.active || isPendingFetchContentProviderMangas
|
||||
}
|
||||
className="gap-2 shrink-0"
|
||||
>
|
||||
{isPendingFetchContentProviderMangas ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Star, X } from "lucide-react";
|
||||
import { useGetGenres } from "@/api/generated/catalog/catalog.ts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Skeleton } from "@/components/ui/skeleton.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||
import {useGetGenres} from "@/api/generated/catalog/catalog.ts";
|
||||
|
||||
interface FilterSidebarProps {
|
||||
selectedGenres: number[];
|
||||
|
||||
@ -6,12 +6,15 @@ import type {
|
||||
MangaListDTO,
|
||||
PageMangaListDTO,
|
||||
} 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 { Button } from "@/components/ui/button.tsx";
|
||||
import { Card, CardContent } from "@/components/ui/card.tsx";
|
||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
|
||||
import {useSetFavorite, useSetUnfavorite} from "@/api/generated/user-interaction/user-interaction.ts";
|
||||
|
||||
interface MangaCardProps {
|
||||
manga: MangaListDTO;
|
||||
@ -63,8 +66,7 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
|
||||
(isFavorite: boolean) =>
|
||||
isFavorite
|
||||
? mutateUnfavorite({ mangaId: manga.id })
|
||||
: mutateFavorite({ mangaId: manga.id })
|
||||
,
|
||||
: mutateFavorite({ mangaId: manga.id }),
|
||||
[mutateUnfavorite, manga.id, mutateFavorite],
|
||||
);
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import axios from "axios";
|
||||
import { FileUp } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useRequestPresignedImport } from "@/api/generated/content/content.ts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -13,8 +15,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import axios from "axios";
|
||||
import { useRequestPresignedImport } from "@/api/generated/content/content.ts";
|
||||
|
||||
interface MangaManualImportDialogProps {
|
||||
fileImportDialogOpen: boolean;
|
||||
@ -30,7 +30,9 @@ export const MangaManualImportDialog = ({
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [files, setFiles] = useState<File[] | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
||||
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const { mutateAsync: requestPresignedUrl } = useRequestPresignedImport();
|
||||
|
||||
@ -140,9 +142,7 @@ export const MangaManualImportDialog = ({
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
AniList ID
|
||||
</label>
|
||||
<label className="text-sm font-medium">AniList ID</label>
|
||||
<Input
|
||||
placeholder="17"
|
||||
value={aniListId}
|
||||
@ -152,9 +152,7 @@ export const MangaManualImportDialog = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
MyAnimeList ID
|
||||
</label>
|
||||
<label className="text-sm font-medium">MyAnimeList ID</label>
|
||||
<Input
|
||||
placeholder="20"
|
||||
value={malId}
|
||||
@ -171,10 +169,11 @@ export const MangaManualImportDialog = ({
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
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-muted-foreground/25"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@ -230,7 +229,10 @@ export const MangaManualImportDialog = ({
|
||||
{files.map((file) => (
|
||||
<div key={file.name} className="space-y-1">
|
||||
<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}
|
||||
</span>
|
||||
<span>{uploadProgress[file.name] || 0}%</span>
|
||||
|
||||
@ -3,6 +3,7 @@ import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useGetContentProviders } from "@/api/generated/ingestion/ingestion.ts";
|
||||
import { useImportFromProvider } from "@/api/generated/manga-import/manga-import.ts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -30,7 +31,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import {useGetContentProviders} from "@/api/generated/ingestion/ingestion.ts";
|
||||
|
||||
interface ProviderImportDialogProps {
|
||||
dialogOpen: boolean;
|
||||
|
||||
@ -2,14 +2,14 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ExternalLink, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
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 { Card } from "@/components/ui/card";
|
||||
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 {
|
||||
importReview: MangaIngestReviewDTO;
|
||||
@ -73,8 +73,10 @@ export function ImportReviewCard({
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Provider:{" "}
|
||||
<span className="capitalize">{importReview.contentProviderName}</span> •{" "}
|
||||
{importDate}
|
||||
<span className="capitalize">
|
||||
{importReview.contentProviderName}
|
||||
</span>{" "}
|
||||
• {importDate}
|
||||
</p>
|
||||
</div>
|
||||
{importReview.externalUrl && (
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Database, Download, Eye, Loader2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ReactCountryFlag from "react-country-flag";
|
||||
import {useDownloadContentArchive, useGetMangaProviderContent} from "@/api/generated/content/content.ts";
|
||||
import {useFetchContentProviderContent} from "@/api/generated/ingestion/ingestion.ts";
|
||||
import { useNavigate } from "react-router";
|
||||
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 {
|
||||
mangaId: number;
|
||||
@ -18,7 +21,8 @@ export const MangaChapter = ({
|
||||
}: MangaChapterProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { isPending, data, queryKey } = useGetMangaProviderContent(mangaProviderId);
|
||||
const { isPending, data, queryKey } =
|
||||
useGetMangaProviderContent(mangaProviderId);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@ -28,7 +32,7 @@ export const MangaChapter = ({
|
||||
const url = window.URL.createObjectURL(data);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", mangaContentId + ".cbz");
|
||||
link.setAttribute("download", `${mangaContentId}.cbz`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@ -37,12 +41,13 @@ export const MangaChapter = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate, isPending: isPendingFetchChapter } = useFetchContentProviderContent({
|
||||
mutation: {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
onSettled: () => setFetchingId(null),
|
||||
},
|
||||
});
|
||||
const { mutate, isPending: isPendingFetchChapter } =
|
||||
useFetchContentProviderContent({
|
||||
mutation: {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
onSettled: () => setFetchingId(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 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 ? (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
@ -145,7 +151,7 @@ export const MangaChapter = ({
|
||||
className="gap-2 cursor-pointer"
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
{isPendingFetchChapter && fetchingId == chapter.id
|
||||
{isPendingFetchChapter && fetchingId === chapter.id
|
||||
? "Fetching..."
|
||||
: "Fetch from Provider"}
|
||||
</Button>
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import useWindowDimensions from "@/hooks/useWindowDimensions.ts";
|
||||
|
||||
export const useDynamicPageSize = (rows = 4) => {
|
||||
const { width } = useWindowDimensions();
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
if (width >= 1280) {
|
||||
// xl: 5 columns
|
||||
return 5 * rows;
|
||||
}
|
||||
if (width >= 1024) {
|
||||
// lg: 4 columns
|
||||
return 4 * rows;
|
||||
}
|
||||
if (width >= 768) {
|
||||
// md: 3 columns
|
||||
return 3 * rows;
|
||||
}
|
||||
// default: 2 columns
|
||||
return 2 * rows;
|
||||
if (width >= 1280) {
|
||||
// xl: 5 columns
|
||||
return 5 * rows;
|
||||
}
|
||||
if (width >= 1024) {
|
||||
// lg: 4 columns
|
||||
return 4 * rows;
|
||||
}
|
||||
if (width >= 768) {
|
||||
// md: 3 columns
|
||||
return 3 * rows;
|
||||
}
|
||||
// default: 2 columns
|
||||
return 2 * rows;
|
||||
};
|
||||
|
||||
@ -2,18 +2,18 @@ import { useLayoutEffect } from "react";
|
||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
|
||||
export const useScrollPersistence = (key: string) => {
|
||||
const { scrollPositions, setScrollPosition } = useUIState();
|
||||
const { scrollPositions, setScrollPosition } = useUIState();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Restore scroll position
|
||||
const savedPosition = scrollPositions[key];
|
||||
if (savedPosition !== undefined) {
|
||||
window.scrollTo(0, savedPosition);
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
// Restore scroll position
|
||||
const savedPosition = scrollPositions[key];
|
||||
if (savedPosition !== undefined) {
|
||||
window.scrollTo(0, savedPosition);
|
||||
}
|
||||
|
||||
// Save scroll position on unmount or before key changes
|
||||
return () => {
|
||||
setScrollPosition(key, window.scrollY);
|
||||
};
|
||||
}, [key, setScrollPosition]); // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// Save scroll position on unmount or before key changes
|
||||
return () => {
|
||||
setScrollPosition(key, window.scrollY);
|
||||
};
|
||||
}, [key, setScrollPosition, scrollPositions]); // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
};
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function getWindowDimensions() {
|
||||
const { innerWidth: width, innerHeight: height } = window;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const { innerWidth: width, innerHeight: height } = window;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export default function useWindowDimensions() {
|
||||
const [windowDimensions, setWindowDimensions] = useState(
|
||||
getWindowDimensions(),
|
||||
);
|
||||
const [windowDimensions, setWindowDimensions] = useState(
|
||||
getWindowDimensions(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowDimensions(getWindowDimensions());
|
||||
}
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setWindowDimensions(getWindowDimensions());
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return windowDimensions;
|
||||
return windowDimensions;
|
||||
}
|
||||
|
||||
@ -1,241 +1,247 @@
|
||||
import {useAuth} from "@/contexts/AuthContext.tsx";
|
||||
import {type ReactNode, useEffect, useState} from "react";
|
||||
import {useNavigate} from "react-router";
|
||||
import {ArrowLeft, FileStack, Server, Shield} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {ProviderManager} from "@/features/admin/components/ProviderManager.tsx";
|
||||
import {FailedImportJobs} from "@/features/admin/components/FailedImportJobs.tsx";
|
||||
import { ArrowLeft, FileStack, Server, Shield } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { useAuth } from "@/contexts/AuthContext.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 { isLoading, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("providers")
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("providers");
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: fix this
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
// navigate("/login");
|
||||
// return;
|
||||
}
|
||||
useEffect(() => {
|
||||
// TODO: fix this
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
// navigate("/login");
|
||||
// return;
|
||||
}
|
||||
|
||||
// TODO: add user role verification
|
||||
}, [isAuthenticated, isLoading, navigate]);
|
||||
// TODO: add user role verification
|
||||
}, [isAuthenticated, isLoading]);
|
||||
|
||||
// const { data } = useGetMangaImportJobs();
|
||||
//
|
||||
// const failedImports = data?.data?.filter(dto => dto.status === MangaImportJobDTOStatus.FAILED)?.length;
|
||||
// const { data } = useGetMangaImportJobs();
|
||||
//
|
||||
// const failedImports = data?.data?.filter(dto => dto.status === MangaImportJobDTOStatus.FAILED)?.length;
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
|
||||
{
|
||||
id: "providers",
|
||||
label: "Providers",
|
||||
icon: <Server className="h-4 w-4" />,
|
||||
},
|
||||
// {
|
||||
// id: "manga",
|
||||
// label: "Manga Library",
|
||||
// icon: <BookOpen className="h-4 w-4" />,
|
||||
// },
|
||||
// {
|
||||
// id: "import",
|
||||
// label: "Import",
|
||||
// icon: <Download className="h-4 w-4" />,
|
||||
// },
|
||||
// {
|
||||
// id: "ingest-review",
|
||||
// label: "Ingest Review",
|
||||
// icon: <AlertCircle className="h-4 w-4" />,
|
||||
// badge: failedImports.length > 0 ? failedImports.length : undefined,
|
||||
// },
|
||||
{
|
||||
id: "import-jobs",
|
||||
label: "Manual Import Jobs",
|
||||
icon: <FileStack className="h-4 w-4" />,
|
||||
},
|
||||
// {
|
||||
// id: "users",
|
||||
// label: "User Management",
|
||||
// icon: <Users className="h-4 w-4" />,
|
||||
// },
|
||||
]
|
||||
const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [
|
||||
{
|
||||
id: "providers",
|
||||
label: "Providers",
|
||||
icon: <Server className="h-4 w-4" />,
|
||||
},
|
||||
// {
|
||||
// id: "manga",
|
||||
// label: "Manga Library",
|
||||
// icon: <BookOpen className="h-4 w-4" />,
|
||||
// },
|
||||
// {
|
||||
// id: "import",
|
||||
// label: "Import",
|
||||
// icon: <Download className="h-4 w-4" />,
|
||||
// },
|
||||
// {
|
||||
// id: "ingest-review",
|
||||
// label: "Ingest Review",
|
||||
// icon: <AlertCircle className="h-4 w-4" />,
|
||||
// badge: failedImports.length > 0 ? failedImports.length : undefined,
|
||||
// },
|
||||
{
|
||||
id: "import-jobs",
|
||||
label: "Manual Import Jobs",
|
||||
icon: <FileStack className="h-4 w-4" />,
|
||||
},
|
||||
// {
|
||||
// id: "users",
|
||||
// label: "User Management",
|
||||
// icon: <Users className="h-4 w-4" />,
|
||||
// },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="sticky top-0 h-screen w-64 shrink-0 border-r border-border bg-card">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sidebar Header */}
|
||||
<div className="border-b border-border px-6 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-bold text-foreground">
|
||||
Admin Panel
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Manage your MangaMochi platform
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="sticky top-0 h-screen w-64 shrink-0 border-r border-border bg-card">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sidebar Header */}
|
||||
<div className="border-b border-border px-6 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-bold text-foreground">
|
||||
Admin Panel
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Manage your MangaMochi platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={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 ${
|
||||
activeTab === tab.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
{tab.badge && (
|
||||
<span
|
||||
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
|
||||
? "bg-primary-foreground text-primary"
|
||||
: "bg-destructive text-destructive-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={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 ${
|
||||
activeTab === tab.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
{tab.badge && (
|
||||
<span
|
||||
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
|
||||
? "bg-primary-foreground text-primary"
|
||||
: "bg-destructive text-destructive-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div className="border-t border-border px-3 py-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{/* Sidebar Footer */}
|
||||
<div className="border-t border-border px-3 py-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 px-8 py-8">
|
||||
{activeTab === "providers" && <ProviderManager />}
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 px-8 py-8">
|
||||
{activeTab === "providers" && <ProviderManager />}
|
||||
|
||||
{/*{activeTab === "manga" && <AdminMangaTable />}*/}
|
||||
{/*{activeTab === "manga" && <AdminMangaTable />}*/}
|
||||
|
||||
{/*{activeTab === "import" && (*/}
|
||||
{/* <div className="space-y-6">*/}
|
||||
{/* <div>*/}
|
||||
{/* <h2 className="text-xl font-semibold text-foreground">*/}
|
||||
{/* Import Manga*/}
|
||||
{/* </h2>*/}
|
||||
{/* <p className="text-sm text-muted-foreground">*/}
|
||||
{/* Import manga from external providers or upload files directly.*/}
|
||||
{/* </p>*/}
|
||||
{/* </div>*/}
|
||||
{/*{activeTab === "import" && (*/}
|
||||
{/* <div className="space-y-6">*/}
|
||||
{/* <div>*/}
|
||||
{/* <h2 className="text-xl font-semibold text-foreground">*/}
|
||||
{/* Import Manga*/}
|
||||
{/* </h2>*/}
|
||||
{/* <p className="text-sm text-muted-foreground">*/}
|
||||
{/* Import manga from external providers or upload files directly.*/}
|
||||
{/* </p>*/}
|
||||
{/* </div>*/}
|
||||
|
||||
{/* <div className="grid grid-cols-1 gap-6 md:grid-cols-2">*/}
|
||||
{/* /!* Import from Provider *!/*/}
|
||||
{/* <Card className="p-6">*/}
|
||||
{/* <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">*/}
|
||||
{/* <Download className="h-6 w-6 text-primary" />*/}
|
||||
{/* </div>*/}
|
||||
{/* <div>*/}
|
||||
{/* <h3 className="font-semibold text-foreground">*/}
|
||||
{/* Import from Provider*/}
|
||||
{/* </h3>*/}
|
||||
{/* <p className="mt-1 text-sm text-muted-foreground">*/}
|
||||
{/* Import manga from MangaDex, MangaPlus, Bato.to, or*/}
|
||||
{/* other supported providers.*/}
|
||||
{/* </p>*/}
|
||||
{/* </div>*/}
|
||||
{/* <ImportDropdown />*/}
|
||||
{/* </div>*/}
|
||||
{/* </Card>*/}
|
||||
{/* <div className="grid grid-cols-1 gap-6 md:grid-cols-2">*/}
|
||||
{/* /!* Import from Provider *!/*/}
|
||||
{/* <Card className="p-6">*/}
|
||||
{/* <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">*/}
|
||||
{/* <Download className="h-6 w-6 text-primary" />*/}
|
||||
{/* </div>*/}
|
||||
{/* <div>*/}
|
||||
{/* <h3 className="font-semibold text-foreground">*/}
|
||||
{/* Import from Provider*/}
|
||||
{/* </h3>*/}
|
||||
{/* <p className="mt-1 text-sm text-muted-foreground">*/}
|
||||
{/* Import manga from MangaDex, MangaPlus, Bato.to, or*/}
|
||||
{/* other supported providers.*/}
|
||||
{/* </p>*/}
|
||||
{/* </div>*/}
|
||||
{/* <ImportDropdown />*/}
|
||||
{/* </div>*/}
|
||||
{/* </Card>*/}
|
||||
|
||||
{/* /!* Import from File *!/*/}
|
||||
{/* <Card className="p-6">*/}
|
||||
{/* <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">*/}
|
||||
{/* <FileUp className="h-6 w-6 text-primary" />*/}
|
||||
{/* </div>*/}
|
||||
{/* <div>*/}
|
||||
{/* <h3 className="font-semibold text-foreground">*/}
|
||||
{/* Bulk File Upload*/}
|
||||
{/* </h3>*/}
|
||||
{/* <p className="mt-1 text-sm text-muted-foreground">*/}
|
||||
{/* Upload JSON, CSV, or text files to import manga data in*/}
|
||||
{/* bulk.*/}
|
||||
{/* </p>*/}
|
||||
{/* </div>*/}
|
||||
{/* <Button*/}
|
||||
{/* variant="outline"*/}
|
||||
{/* className="gap-2"*/}
|
||||
{/* onClick={() => {*/}
|
||||
{/* const importBtn = document.querySelector(*/}
|
||||
{/* '[data-import-dropdown]'*/}
|
||||
{/* ) as HTMLButtonElement*/}
|
||||
{/* if (importBtn) importBtn.click()*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* <FileUp className="h-4 w-4" />*/}
|
||||
{/* Upload Files*/}
|
||||
{/* </Button>*/}
|
||||
{/* </div>*/}
|
||||
{/* </Card>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
{/* /!* Import from File *!/*/}
|
||||
{/* <Card className="p-6">*/}
|
||||
{/* <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">*/}
|
||||
{/* <FileUp className="h-6 w-6 text-primary" />*/}
|
||||
{/* </div>*/}
|
||||
{/* <div>*/}
|
||||
{/* <h3 className="font-semibold text-foreground">*/}
|
||||
{/* Bulk File Upload*/}
|
||||
{/* </h3>*/}
|
||||
{/* <p className="mt-1 text-sm text-muted-foreground">*/}
|
||||
{/* Upload JSON, CSV, or text files to import manga data in*/}
|
||||
{/* bulk.*/}
|
||||
{/* </p>*/}
|
||||
{/* </div>*/}
|
||||
{/* <Button*/}
|
||||
{/* variant="outline"*/}
|
||||
{/* className="gap-2"*/}
|
||||
{/* onClick={() => {*/}
|
||||
{/* const importBtn = document.querySelector(*/}
|
||||
{/* '[data-import-dropdown]'*/}
|
||||
{/* ) as HTMLButtonElement*/}
|
||||
{/* if (importBtn) importBtn.click()*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* <FileUp className="h-4 w-4" />*/}
|
||||
{/* Upload Files*/}
|
||||
{/* </Button>*/}
|
||||
{/* </div>*/}
|
||||
{/* </Card>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/*{activeTab === "ingest-review" && (*/}
|
||||
{/* <div className="space-y-6">*/}
|
||||
{/* <div>*/}
|
||||
{/* <h2 className="text-xl font-semibold text-foreground">*/}
|
||||
{/* Ingest Review*/}
|
||||
{/* </h2>*/}
|
||||
{/* <p className="text-sm text-muted-foreground">*/}
|
||||
{/* Review and resolve imports that need manual matching.*/}
|
||||
{/* </p>*/}
|
||||
{/* </div>*/}
|
||||
{/*{activeTab === "ingest-review" && (*/}
|
||||
{/* <div className="space-y-6">*/}
|
||||
{/* <div>*/}
|
||||
{/* <h2 className="text-xl font-semibold text-foreground">*/}
|
||||
{/* Ingest Review*/}
|
||||
{/* </h2>*/}
|
||||
{/* <p className="text-sm text-muted-foreground">*/}
|
||||
{/* Review and resolve imports that need manual matching.*/}
|
||||
{/* </p>*/}
|
||||
{/* </div>*/}
|
||||
|
||||
{/* {failedImports.length === 0 ? (*/}
|
||||
{/* <Card className="p-8 text-center">*/}
|
||||
{/* <AlertCircle className="mx-auto h-12 w-12 text-muted-foreground" />*/}
|
||||
{/* <h3 className="mt-4 text-lg font-semibold text-foreground">*/}
|
||||
{/* No Pending Reviews*/}
|
||||
{/* </h3>*/}
|
||||
{/* <p className="mt-2 text-muted-foreground">*/}
|
||||
{/* All imports have been processed successfully.*/}
|
||||
{/* </p>*/}
|
||||
{/* </Card>*/}
|
||||
{/* ) : (*/}
|
||||
{/* <div className="space-y-4">*/}
|
||||
{/* <p className="text-sm text-muted-foreground">*/}
|
||||
{/* {failedImports.length} import*/}
|
||||
{/* {failedImports.length !== 1 ? "s" : ""} to review*/}
|
||||
{/* </p>*/}
|
||||
{/* {failedImports.map((fi) => (*/}
|
||||
{/* <FailedImportCard key={fi.id} failedImport={fi} />*/}
|
||||
{/* ))}*/}
|
||||
{/* </div>*/}
|
||||
{/* )}*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
{/* {failedImports.length === 0 ? (*/}
|
||||
{/* <Card className="p-8 text-center">*/}
|
||||
{/* <AlertCircle className="mx-auto h-12 w-12 text-muted-foreground" />*/}
|
||||
{/* <h3 className="mt-4 text-lg font-semibold text-foreground">*/}
|
||||
{/* No Pending Reviews*/}
|
||||
{/* </h3>*/}
|
||||
{/* <p className="mt-2 text-muted-foreground">*/}
|
||||
{/* All imports have been processed successfully.*/}
|
||||
{/* </p>*/}
|
||||
{/* </Card>*/}
|
||||
{/* ) : (*/}
|
||||
{/* <div className="space-y-4">*/}
|
||||
{/* <p className="text-sm text-muted-foreground">*/}
|
||||
{/* {failedImports.length} import*/}
|
||||
{/* {failedImports.length !== 1 ? "s" : ""} to review*/}
|
||||
{/* </p>*/}
|
||||
{/* {failedImports.map((fi) => (*/}
|
||||
{/* <FailedImportCard key={fi.id} failedImport={fi} />*/}
|
||||
{/* ))}*/}
|
||||
{/* </div>*/}
|
||||
{/* )}*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{activeTab === "import-jobs" && <FailedImportJobs />}
|
||||
{activeTab === "import-jobs" && <FailedImportJobs />}
|
||||
|
||||
{/*{activeTab === "users" && <AdminUserManager />}*/}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
{/*{activeTab === "users" && <AdminUserManager />}*/}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
||||
export default Admin;
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Home } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 { setCurrentChapterPage, getCurrentChapterPage } = useReadingTracker();
|
||||
@ -35,12 +35,12 @@ const Chapter = () => {
|
||||
if (currentPage === data.data?.contentImageKeys.length) {
|
||||
mutate({ mangaContentId: chapterNumber });
|
||||
}
|
||||
}, [data, mutate, currentPage]);
|
||||
}, [data, mutate, currentPage, chapterNumber, isLoading]);
|
||||
|
||||
/** Persist reading progress */
|
||||
useEffect(() => {
|
||||
setCurrentChapterPage(chapterNumber, currentPage);
|
||||
}, [chapterNumber, currentPage]);
|
||||
}, [chapterNumber, currentPage, setCurrentChapterPage]);
|
||||
|
||||
/** Restore stored page */
|
||||
useEffect(() => {
|
||||
@ -51,7 +51,7 @@ const Chapter = () => {
|
||||
setVisibleCount(stored); // for infinite scroll
|
||||
}
|
||||
}
|
||||
}, [isLoading, data?.data]);
|
||||
}, [isLoading, data?.data, chapterNumber, getCurrentChapterPage]);
|
||||
|
||||
/** Infinite scroll observer */
|
||||
useEffect(() => {
|
||||
@ -90,7 +90,7 @@ const Chapter = () => {
|
||||
|
||||
imgs.forEach((img) => observer.observe(img));
|
||||
return () => observer.disconnect();
|
||||
}, [infiniteScroll, visibleCount]);
|
||||
}, [infiniteScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.data) return;
|
||||
@ -107,7 +107,7 @@ const Chapter = () => {
|
||||
// Single page mode → scroll to top
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
}, [infiniteScroll]);
|
||||
}, [infiniteScroll, currentPage, data?.data]);
|
||||
|
||||
if (!data?.data) {
|
||||
return (
|
||||
|
||||
@ -1,23 +1,20 @@
|
||||
import { BookOpen, Search } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useGetMangas } from "@/api/generated/catalog/catalog.ts";
|
||||
import { AuthHeader } from "@/components/AuthHeader.tsx";
|
||||
import { Pagination } from "@/components/Pagination.tsx";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
import { FilterSidebar } from "@/features/home/components/FilterSidebar.tsx";
|
||||
import { ImportDropdown } from "@/features/home/components/ImportDropdown.tsx";
|
||||
import { MangaGrid } from "@/features/home/components/MangaGrid.tsx";
|
||||
import {
|
||||
SortDropdown,
|
||||
} from "@/features/home/components/SortDropdown.tsx";
|
||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
import { SortDropdown } from "@/features/home/components/SortDropdown.tsx";
|
||||
import { useDynamicPageSize } from "@/hooks/useDynamicPageSize.ts";
|
||||
import {useGetMangas} from "@/api/generated/catalog/catalog.ts";
|
||||
|
||||
const ROWS_PER_PAGE = 4;
|
||||
|
||||
|
||||
const Home = () => {
|
||||
const itemsPerPage = useDynamicPageSize(ROWS_PER_PAGE);
|
||||
const {
|
||||
@ -60,7 +57,7 @@ const Home = () => {
|
||||
setCurrentPage(1);
|
||||
startSearchRef.current = debouncedSearchText;
|
||||
}
|
||||
}, [debouncedSearchText]);
|
||||
}, [debouncedSearchText, setCurrentPage]);
|
||||
|
||||
const totalPages = mangasData?.data?.totalPages;
|
||||
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useGetMangaIngestReviews } from "@/api/generated/manga-ingest-review/manga-ingest-review.ts";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useAuth } from "@/contexts/AuthContext.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() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -44,7 +44,7 @@ const Login = () => {
|
||||
async (values: z.infer<typeof formSchema>) => {
|
||||
await login(values.email, values.password);
|
||||
},
|
||||
[formSchema, login],
|
||||
[login],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -13,6 +13,14 @@ import {
|
||||
import { useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -24,17 +32,9 @@ import {
|
||||
} from "@/components/ui/collapsible.tsx";
|
||||
import { useAuth } from "@/contexts/AuthContext.tsx";
|
||||
import { useUIState } from "@/contexts/UIStateContext.tsx";
|
||||
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
|
||||
import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
|
||||
import { useScrollPersistence } from "@/hooks/useScrollPersistence.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 { isAuthenticated } = useAuth();
|
||||
@ -46,11 +46,12 @@ const Manga = () => {
|
||||
|
||||
const { data: mangaData, queryKey } = useGetManga(mangaId);
|
||||
|
||||
const { mutate, isPending: fetchPending } = useFetchContentProviderContentList({
|
||||
mutation: {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
},
|
||||
});
|
||||
const { mutate, isPending: fetchPending } =
|
||||
useFetchContentProviderContentList({
|
||||
mutation: {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
},
|
||||
});
|
||||
|
||||
// const { mutate: fetchAllMutate, isPending: fetchAllPending } =
|
||||
// useFetchAllChapters({
|
||||
@ -173,8 +174,8 @@ const Manga = () => {
|
||||
src={
|
||||
(mangaData.data?.coverImageKey &&
|
||||
import.meta.env.VITE_OMV_BASE_URL +
|
||||
"/" +
|
||||
mangaData.data?.coverImageKey) ||
|
||||
"/" +
|
||||
mangaData.data?.coverImageKey) ||
|
||||
"/placeholder.svg"
|
||||
}
|
||||
alt={mangaData.data?.title ?? ""}
|
||||
@ -226,10 +227,11 @@ const Manga = () => {
|
||||
disabled={isPendingFavoriteChange}
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 transition-colors ${mangaData?.data?.favorite
|
||||
? "fill-red-500 text-red-500"
|
||||
: "text-muted-foreground hover:text-red-500"
|
||||
}`}
|
||||
className={`h-4 w-4 transition-colors ${
|
||||
mangaData?.data?.favorite
|
||||
? "fill-red-500 text-red-500"
|
||||
: "text-muted-foreground hover:text-red-500"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</>
|
||||
@ -283,7 +285,7 @@ const Manga = () => {
|
||||
<p className="text-sm text-muted-foreground">Chapters</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{mangaData.data?.chapterCount &&
|
||||
mangaData.data?.chapterCount > 0
|
||||
mangaData.data?.chapterCount > 0
|
||||
? mangaData.data?.chapterCount
|
||||
: "-"}
|
||||
</p>
|
||||
@ -364,41 +366,45 @@ const Manga = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{provider.supportsChapterFetch &&
|
||||
provider.active && (
|
||||
<div className={"flex gap-4 pr-4"}>
|
||||
{/*<Button*/}
|
||||
{/* size="sm"*/}
|
||||
{/* variant="outline"*/}
|
||||
{/* disabled={isPending}*/}
|
||||
{/* onClick={() =>*/}
|
||||
{/* fetchAllMutate({*/}
|
||||
{/* mangaProviderId: provider.id?? -1,*/}
|
||||
{/* })*/}
|
||||
{/* }*/}
|
||||
{/* className="gap-2"*/}
|
||||
{/*>*/}
|
||||
{/* <Database className="h-4 w-4" />*/}
|
||||
{/* Fetch all from Provider*/}
|
||||
{/*</Button>*/}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
mutate({ mangaContentProviderId: provider.id ?? -1 })
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
Fetch from Provider
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{provider.supportsChapterFetch && provider.active && (
|
||||
<div className={"flex gap-4 pr-4"}>
|
||||
{/*<Button*/}
|
||||
{/* size="sm"*/}
|
||||
{/* variant="outline"*/}
|
||||
{/* disabled={isPending}*/}
|
||||
{/* onClick={() =>*/}
|
||||
{/* fetchAllMutate({*/}
|
||||
{/* mangaProviderId: provider.id?? -1,*/}
|
||||
{/* })*/}
|
||||
{/* }*/}
|
||||
{/* className="gap-2"*/}
|
||||
{/*>*/}
|
||||
{/* <Database className="h-4 w-4" />*/}
|
||||
{/* Fetch all from Provider*/}
|
||||
{/*</Button>*/}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
mutate({
|
||||
mangaContentProviderId: provider.id ?? -1,
|
||||
})
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
Fetch from Provider
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@ -118,7 +118,7 @@ const Profile = () => {
|
||||
step="6"
|
||||
value={itemsPerPage}
|
||||
onChange={(e) =>
|
||||
setItemsPerPage(Number.parseInt(e.target.value))
|
||||
setItemsPerPage(Number.parseInt(e.target.value, 10))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@ -55,7 +55,7 @@ const Register = () => {
|
||||
async (data: z.infer<typeof formSchema>) => {
|
||||
await register(data.email, data.password, data.name);
|
||||
},
|
||||
[register, formSchema],
|
||||
[register],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -24,7 +24,7 @@ export const Router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
element: <Admin />
|
||||
element: <Admin />,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user