diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index 304a5d8..2a8d7cb 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -93,6 +93,8 @@ export interface ContentProviderDTO { id?: number; /** @minLength 1 */ name: string; + url?: string; + active?: boolean; } export interface ContentProviderListDTO { @@ -143,6 +145,36 @@ export interface MangaContentImagesDTO { contentImageKeys: string[]; } +export interface DefaultResponseDTOListMangaImportJobDTO { + timestamp?: string; + data?: MangaImportJobDTO[]; + message?: string; +} + +export type MangaImportJobDTOStatus = typeof MangaImportJobDTOStatus[keyof typeof MangaImportJobDTOStatus]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const MangaImportJobDTOStatus = { + PENDING: 'PENDING', + PROCESSING: 'PROCESSING', + SUCCESS: 'SUCCESS', + FAILED: 'FAILED', +} as const; + +export interface MangaImportJobDTO { + id?: number; + status?: MangaImportJobDTOStatus; + malId?: number; + aniListId?: number; + filename?: string; + s3Key?: string; + createdAt?: string; + updatedAt?: string; + errorMessage?: string; + errorStackTrace?: string; +} + export interface DefaultResponseDTOPageMangaListDTO { timestamp?: string; data?: PageMangaListDTO; @@ -184,17 +216,17 @@ export interface PageMangaListDTO { number?: number; pageable?: PageableObject; numberOfElements?: number; + sort?: SortObject; first?: boolean; last?: boolean; - sort?: SortObject; empty?: boolean; } export interface PageableObject { offset?: number; - paged?: boolean; pageNumber?: number; pageSize?: number; + paged?: boolean; unpaged?: boolean; sort?: SortObject; } diff --git a/src/api/generated/content/content.ts b/src/api/generated/content/content.ts index b44e74e..284dfa8 100644 --- a/src/api/generated/content/content.ts +++ b/src/api/generated/content/content.ts @@ -25,6 +25,7 @@ import type { import type { DefaultResponseDTOListMangaContentDTO, + DefaultResponseDTOListMangaImportJobDTO, DefaultResponseDTOMangaContentImagesDTO, DefaultResponseDTOPresignedImportResponseDTO, DefaultResponseDTOVoid, @@ -432,3 +433,96 @@ export function useGetMangaContentImages,signal?: AbortSignal +) => { + + + return customInstance( + {url: `/content/import/jobs`, method: 'GET', signal + }, + options); + } + + + + +export const getGetMangaImportJobsQueryKey = () => { + return [ + `/content/import/jobs` + ] as const; + } + + +export const getGetMangaImportJobsQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetMangaImportJobsQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getMangaImportJobs(requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetMangaImportJobsQueryResult = NonNullable>> +export type GetMangaImportJobsQueryError = unknown + + +export function useGetMangaImportJobs>, TError = unknown>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetMangaImportJobs>, TError = unknown>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetMangaImportJobs>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get a list of manga import jobs + */ + +export function useGetMangaImportJobs>, TError = unknown>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetMangaImportJobsQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/src/components/AuthHeader.tsx b/src/components/AuthHeader.tsx index e377b14..81a8b77 100644 --- a/src/components/AuthHeader.tsx +++ b/src/components/AuthHeader.tsx @@ -1,4 +1,4 @@ -import { LogIn, LogOut, Settings, 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"; @@ -66,6 +66,13 @@ export const AuthHeader = () => { + + + + Admin Dashboard + + + ) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/features/admin/components/FailedImportJobs.tsx b/src/features/admin/components/FailedImportJobs.tsx new file mode 100644 index 0000000..56fe305 --- /dev/null +++ b/src/features/admin/components/FailedImportJobs.tsx @@ -0,0 +1,304 @@ +import {useGetMangaImportJobs} from "@/api/generated/content/content.ts"; +import type {MangaImportJobDTO, MangaImportJobDTOStatus} from "@/api/generated/api.schemas.ts"; +import { Badge } from "@/components/ui/badge"; +import {AlertTriangle, ExternalLink, FileText, RefreshCw} from "lucide-react"; +import {Card} from "@/components/ui/card.tsx"; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table.tsx"; +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog.tsx"; +import {useState} from "react"; +import {Button} from "@/components/ui/button.tsx"; + +export const FailedImportJobs = () => { + const {data} = useGetMangaImportJobs(); + const importJobs = data?.data; + + // const [searchQuery, setSearchQuery] = useState("") + // const [statusFilter, setStatusFilter] = useState("ALL") + const [selectedJob, setSelectedJob] = useState(null) + const [errorDialogOpen, setErrorDialogOpen] = useState(false) + + // 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 getStatusBadge = (job: MangaImportJobDTO) => { + const variants: Record = { + 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; + } + + const config = variants[job.status] + + if (job.status === "FAILED") { + return ( + { + setSelectedJob(job) + setErrorDialogOpen(true) + }} + > + + {job.status} + + ) + } + + return ( + + {job.status === "PROCESSING" && ( + + )} + {job.status} + + ) + } + + const stats = { + total: importJobs?.length, + pending: importJobs?.filter((j) => j.status === "PENDING").length, + processing: importJobs?.filter((j) => j.status === "PROCESSING").length, + completed: importJobs?.filter((j) => j.status === "SUCCESS").length, + failed: importJobs?.filter((j) => j.status === "FAILED").length, + } + + return ( +
+
+

+ Manual Import Jobs +

+

+ View and manage file import jobs from S3 storage. +

+
+ + {/* Stats */} +
+ +

Total Jobs

+

{stats.total}

+
+ +

Pending

+

+ {stats.pending} +

+
+ +

Processing

+

{stats.processing}

+
+ +

Completed

+

{stats.completed}

+
+ +

Failed

+

{stats.failed}

+
+
+ + {/* Filters */} + {/*
*/} + {/*
*/} + {/* */} + {/* setSearchQuery(e.target.value)}*/} + {/* className="pl-10"*/} + {/* />*/} + {/*
*/} + {/* setStatusFilter(value as ImportStatus | "ALL")}*/} + {/* >*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* All Statuses*/} + {/* Pending*/} + {/* Processing*/} + {/* Completed*/} + {/* Failed*/} + {/* */} + {/* */} + {/*
*/} + + {/* Table */} + + + + + ID + Status + MAL ID + AniList ID + Filename + S3 Key + Created At + Updated At + + + + {!importJobs || importJobs.length === 0 ? ( + + +
+ +

No import jobs found

+
+
+
+ ) : ( + importJobs.map((job) => ( + + {job.id} + {getStatusBadge(job)} + + {job.malId ? ( + + {job.malId} + + + ) : ( + - + )} + + + {job.aniListId ? ( + + {job.aniListId} + + + ) : ( + - + )} + + + {job.filename} + + + + {job.s3Key} + + + + + {formatDate(job.createdAt ?? "")} + + + {formatDate(job.updatedAt ?? "")} + + + )) + )} +
+
+
+ + {/* Error Details Dialog */} + + + + + + Import Error Details + + + Job ID: {selectedJob?.id} | File: {selectedJob?.filename} + + + {selectedJob?.errorMessage && ( +
+
+ +

+ {selectedJob.errorMessage} +

+
+ {selectedJob.errorStackTrace && ( +
+ +
+                                        {selectedJob.errorStackTrace}
+                                    
+
+ )} +
+ + {/* {*/} + {/* // Mock retry action*/} + {/* setJobs((prev) =>*/} + {/* prev.map((j) =>*/} + {/* j.id === selectedJob.id*/} + {/* ? { ...j, status: "PENDING" as ImportStatus, updatedAt: new Date().toISOString() }*/} + {/* : j*/} + {/* )*/} + {/* )*/} + {/* setErrorDialogOpen(false)*/} + {/* }}*/} + {/*>*/} + {/* */} + {/* Retry Import*/} + {/**/} +
+
+ )} +
+
+
+ ) +}; \ No newline at end of file diff --git a/src/features/admin/components/ProviderManager.tsx b/src/features/admin/components/ProviderManager.tsx new file mode 100644 index 0000000..2ca5dcd --- /dev/null +++ b/src/features/admin/components/ProviderManager.tsx @@ -0,0 +1,123 @@ +import { + 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"; + +export const ProviderManager = () => { + const { data } = useGetContentProviders(); + const providers = data?.data?.providers; + + const { mutate: mutateFetchContentProviderMangas, isPending: isPendingFetchContentProviderMangas } = useFetchContentProviderMangas(); + + const { mutate: mutateFetchAllContentProviderMangas, isPending: isPendingFetchAllContentProviderMangas } = useFetchAllContentProviderMangas(); + + const activeCount = providers?.filter((p) => p.active).length + + return ( +
+
+
+

+ Manga Providers +

+

+ {activeCount} of {providers?.length} providers active +

+
+ +
+ +
+ {providers?.map((provider) => { + return ( + +
+ {/* Active Toggle */} + {/* handleToggleActive(provider.id)}*/} + {/* disabled={isUpdating}*/} + {/*/>*/} + +
+
+

+ {provider.name} +

+ + {provider?.active ? "active" : "inactive"} + +
+ + {provider?.url && + (
+
+ + {provider.url} + + + + +
+
)} + +

+ {/*{provider.mangaCount} manga indexed | Last updated:{" "}*/} + {/*{new Date(provider.lastUpdated).toLocaleString()}*/} +

+
+ + +
+
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx new file mode 100644 index 0000000..2380a94 --- /dev/null +++ b/src/pages/Admin.tsx @@ -0,0 +1,239 @@ +import {useAuth} from "@/contexts/AuthContext.tsx"; +import {type ReactNode, useEffect, useState} from "react"; +import {useNavigate} from "react-router"; +import {AlertCircle, 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"; + +type Tab = "import" | "providers" | "manga" | "ingest-review" | "import-jobs" | "users" + +const Admin = () => { + const { isLoading, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState("providers") + + useEffect(() => { + // TODO: fix this + if (!isLoading && !isAuthenticated) { + // navigate("/login"); + // return; + } + + // TODO: add user role verification + }, [isAuthenticated, isLoading, navigate]); + + const failedImports = []; + + const tabs: { id: Tab; label: string; icon: ReactNode; badge?: number }[] = [ + { + id: "providers", + label: "Providers", + icon: , + }, + // { + // id: "manga", + // label: "Manga Library", + // icon: , + // }, + // { + // id: "import", + // label: "Import", + // icon: , + // }, + // { + // id: "ingest-review", + // label: "Ingest Review", + // icon: , + // badge: failedImports.length > 0 ? failedImports.length : undefined, + // }, + { + id: "import-jobs", + label: "Manual Import Jobs", + icon: , + }, + // { + // id: "users", + // label: "User Management", + // icon: , + // }, + ] + + return ( +
+
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {activeTab === "providers" && } + + {/*{activeTab === "manga" && }*/} + + {/*{activeTab === "import" && (*/} + {/*
*/} + {/*
*/} + {/*

*/} + {/* Import Manga*/} + {/*

*/} + {/*

*/} + {/* Import manga from external providers or upload files directly.*/} + {/*

*/} + {/*
*/} + + {/*
*/} + {/* /!* Import from Provider *!/*/} + {/* */} + {/*
*/} + {/*
*/} + {/* */} + {/*
*/} + {/*
*/} + {/*

*/} + {/* Import from Provider*/} + {/*

*/} + {/*

*/} + {/* Import manga from MangaDex, MangaPlus, Bato.to, or*/} + {/* other supported providers.*/} + {/*

*/} + {/*
*/} + {/* */} + {/*
*/} + {/*
*/} + + {/* /!* Import from File *!/*/} + {/* */} + {/*
*/} + {/*
*/} + {/* */} + {/*
*/} + {/*
*/} + {/*

*/} + {/* Bulk File Upload*/} + {/*

*/} + {/*

*/} + {/* Upload JSON, CSV, or text files to import manga data in*/} + {/* bulk.*/} + {/*

*/} + {/*
*/} + {/* {*/} + {/* const importBtn = document.querySelector(*/} + {/* '[data-import-dropdown]'*/} + {/* ) as HTMLButtonElement*/} + {/* if (importBtn) importBtn.click()*/} + {/* }}*/} + {/* >*/} + {/* */} + {/* Upload Files*/} + {/* */} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/*)}*/} + + {/*{activeTab === "ingest-review" && (*/} + {/*
*/} + {/*
*/} + {/*

*/} + {/* Ingest Review*/} + {/*

*/} + {/*

*/} + {/* Review and resolve imports that need manual matching.*/} + {/*

*/} + {/*
*/} + + {/* {failedImports.length === 0 ? (*/} + {/* */} + {/* */} + {/*

*/} + {/* No Pending Reviews*/} + {/*

*/} + {/*

*/} + {/* All imports have been processed successfully.*/} + {/*

*/} + {/*
*/} + {/* ) : (*/} + {/*
*/} + {/*

*/} + {/* {failedImports.length} import*/} + {/* {failedImports.length !== 1 ? "s" : ""} to review*/} + {/*

*/} + {/* {failedImports.map((fi) => (*/} + {/* */} + {/* ))}*/} + {/*
*/} + {/* )}*/} + {/*
*/} + {/*)}*/} + + {activeTab === "import-jobs" && } + + {/*{activeTab === "users" && }*/} +
+
+
+ ) +} + +export default Admin; \ No newline at end of file diff --git a/src/pages/Router.tsx b/src/pages/Router.tsx index e96e2fd..12fe9df 100644 --- a/src/pages/Router.tsx +++ b/src/pages/Router.tsx @@ -3,7 +3,7 @@ import { createBrowserRouter } from "react-router"; import { AppLayout } from "@/components/Layout/AppLayout.tsx"; const Home = lazy(() => import("./Home.tsx")); -const ImportReview = lazy(() => import("./ImportReview.tsx")); +const Admin = lazy(() => import("./Admin.tsx")); const Manga = lazy(() => import("./Manga.tsx")); const Chapter = lazy(() => import("./Chapter.tsx")); const Login = lazy(() => import("./Login.tsx")); @@ -22,6 +22,10 @@ export const Router = createBrowserRouter([ index: true, element: , }, + { + path: "/admin", + element: + }, { path: "/login", element: , @@ -34,10 +38,6 @@ export const Router = createBrowserRouter([ path: "/profile", element: , }, - { - path: "/import-review", - element: , - }, { path: "/manga", children: [