feat: Add Admin panel with provider management and import job tracking
This commit is contained in:
parent
2ca4566fa5
commit
862677181c
@ -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;
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ import type {
|
||||
|
||||
import type {
|
||||
DefaultResponseDTOListMangaContentDTO,
|
||||
DefaultResponseDTOListMangaImportJobDTO,
|
||||
DefaultResponseDTOMangaContentImagesDTO,
|
||||
DefaultResponseDTOPresignedImportResponseDTO,
|
||||
DefaultResponseDTOVoid,
|
||||
@ -432,3 +433,96 @@ export function useGetMangaContentImages<TData = Awaited<ReturnType<typeof getMa
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Returns a list of manga import jobs.
|
||||
* @summary Get a list of manga import jobs
|
||||
*/
|
||||
export const getMangaImportJobs = (
|
||||
|
||||
options?: SecondParameter<typeof customInstance>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customInstance<DefaultResponseDTOListMangaImportJobDTO>(
|
||||
{url: `/content/import/jobs`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetMangaImportJobsQueryKey = () => {
|
||||
return [
|
||||
`/content/import/jobs`
|
||||
] 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>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetMangaImportJobsQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMangaImportJobs>>> = ({ signal }) => getMangaImportJobs(requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getMangaImportJobs>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetMangaImportJobsQueryResult = NonNullable<Awaited<ReturnType<typeof getMangaImportJobs>>>
|
||||
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<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getMangaImportJobs>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getMangaImportJobs>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customInstance>}
|
||||
, 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<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getMangaImportJobs>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getMangaImportJobs>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customInstance>}
|
||||
, 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>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary Get a 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>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetMangaImportJobsQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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 = () => {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin" className="cursor-pointer gap-2 text-primary">
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin Dashboard
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleLogout}
|
||||
className="cursor-pointer gap-2 text-destructive"
|
||||
|
||||
114
src/components/ui/table.tsx
Normal file
114
src/components/ui/table.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
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,
|
||||
}
|
||||
304
src/features/admin/components/FailedImportJobs.tsx
Normal file
304
src/features/admin/components/FailedImportJobs.tsx
Normal file
@ -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<ImportStatus | "ALL">("ALL")
|
||||
const [selectedJob, setSelectedJob] = useState<MangaImportJobDTO | null>(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<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;
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className={config.className}>
|
||||
{job.status === "PROCESSING" && (
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{job.status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: importJobs?.length,
|
||||
pending: importJobs?.filter((j) => j.status === "PENDING").length,
|
||||
processing: importJobs?.filter((j) => j.status === "PROCESSING").length,
|
||||
completed: importJobs?.filter((j) => j.status === "SUCCESS").length,
|
||||
failed: importJobs?.filter((j) => j.status === "FAILED").length,
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>*/}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
};
|
||||
123
src/features/admin/components/ProviderManager.tsx
Normal file
123
src/features/admin/components/ProviderManager.tsx
Normal file
@ -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 (
|
||||
<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="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>)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
239
src/pages/Admin.tsx
Normal file
239
src/pages/Admin.tsx
Normal file
@ -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<Tab>("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: <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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 px-8 py-8">
|
||||
{activeTab === "providers" && <ProviderManager />}
|
||||
|
||||
{/*{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>*/}
|
||||
|
||||
{/* <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>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/*{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>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{activeTab === "import-jobs" && <FailedImportJobs />}
|
||||
|
||||
{/*{activeTab === "users" && <AdminUserManager />}*/}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default Admin;
|
||||
@ -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: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
element: <Admin />
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <Login />,
|
||||
@ -34,10 +38,6 @@ export const Router = createBrowserRouter([
|
||||
path: "/profile",
|
||||
element: <Profile />,
|
||||
},
|
||||
{
|
||||
path: "/import-review",
|
||||
element: <ImportReview />,
|
||||
},
|
||||
{
|
||||
path: "/manga",
|
||||
children: [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user