feat: Add Admin panel with provider management and import job tracking

This commit is contained in:
Rodrigo Verdiani 2026-03-27 18:24:30 -03:00
parent 2ca4566fa5
commit 862677181c
8 changed files with 921 additions and 8 deletions

View File

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

View File

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

View File

@ -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
View 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,
}

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

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

View File

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