feat: Add Admin panel with provider management and import job tracking #21
@ -93,6 +93,8 @@ export interface ContentProviderDTO {
|
|||||||
id?: number;
|
id?: number;
|
||||||
/** @minLength 1 */
|
/** @minLength 1 */
|
||||||
name: string;
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentProviderListDTO {
|
export interface ContentProviderListDTO {
|
||||||
@ -143,6 +145,36 @@ export interface MangaContentImagesDTO {
|
|||||||
contentImageKeys: string[];
|
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 {
|
export interface DefaultResponseDTOPageMangaListDTO {
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
data?: PageMangaListDTO;
|
data?: PageMangaListDTO;
|
||||||
@ -184,17 +216,17 @@ export interface PageMangaListDTO {
|
|||||||
number?: number;
|
number?: number;
|
||||||
pageable?: PageableObject;
|
pageable?: PageableObject;
|
||||||
numberOfElements?: number;
|
numberOfElements?: number;
|
||||||
|
sort?: SortObject;
|
||||||
first?: boolean;
|
first?: boolean;
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
sort?: SortObject;
|
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageableObject {
|
export interface PageableObject {
|
||||||
offset?: number;
|
offset?: number;
|
||||||
paged?: boolean;
|
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
paged?: boolean;
|
||||||
unpaged?: boolean;
|
unpaged?: boolean;
|
||||||
sort?: SortObject;
|
sort?: SortObject;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import type {
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
DefaultResponseDTOListMangaContentDTO,
|
DefaultResponseDTOListMangaContentDTO,
|
||||||
|
DefaultResponseDTOListMangaImportJobDTO,
|
||||||
DefaultResponseDTOMangaContentImagesDTO,
|
DefaultResponseDTOMangaContentImagesDTO,
|
||||||
DefaultResponseDTOPresignedImportResponseDTO,
|
DefaultResponseDTOPresignedImportResponseDTO,
|
||||||
DefaultResponseDTOVoid,
|
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 { Link, useNavigate } from "react-router";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar.tsx";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar.tsx";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -66,6 +66,13 @@ export const AuthHeader = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<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
|
<DropdownMenuItem
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="cursor-pointer gap-2 text-destructive"
|
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";
|
import { AppLayout } from "@/components/Layout/AppLayout.tsx";
|
||||||
|
|
||||||
const Home = lazy(() => import("./Home.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 Manga = lazy(() => import("./Manga.tsx"));
|
||||||
const Chapter = lazy(() => import("./Chapter.tsx"));
|
const Chapter = lazy(() => import("./Chapter.tsx"));
|
||||||
const Login = lazy(() => import("./Login.tsx"));
|
const Login = lazy(() => import("./Login.tsx"));
|
||||||
@ -22,6 +22,10 @@ export const Router = createBrowserRouter([
|
|||||||
index: true,
|
index: true,
|
||||||
element: <Home />,
|
element: <Home />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/admin",
|
||||||
|
element: <Admin />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
element: <Login />,
|
element: <Login />,
|
||||||
@ -34,10 +38,6 @@ export const Router = createBrowserRouter([
|
|||||||
path: "/profile",
|
path: "/profile",
|
||||||
element: <Profile />,
|
element: <Profile />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/import-review",
|
|
||||||
element: <ImportReview />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/manga",
|
path: "/manga",
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user