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

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

164
package-lock.json generated
View File

@ -40,6 +40,7 @@
"zod": "^4.1.12"
},
"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",

View File

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

View File

@ -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[];

View File

@ -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> };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,29 +1,29 @@
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { Progress as ProgressPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Progress({
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 };

View File

@ -1,114 +1,114 @@
import * as React from "react"
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
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,
};

View File

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

View File

@ -6,8 +6,8 @@ import {
useState,
} 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";

View File

@ -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;
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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[];

View File

@ -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],
);

View File

@ -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>

View File

@ -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;

View File

@ -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 && (

View File

@ -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>

View File

@ -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;
};

View File

@ -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
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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 (

View File

@ -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;

View File

@ -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();

View File

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

View File

@ -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>

View File

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

View File

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

View File

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