mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Begin plugins system
This commit is contained in:
@@ -4,21 +4,21 @@ import { useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import type { SystemRole } from "~/lib/auth-client";
|
||||
import { formatRole, getAvailableRoles } from "~/lib/auth-client";
|
||||
@@ -35,7 +35,7 @@ interface UserWithRoles {
|
||||
|
||||
export function AdminUserTable() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRole | "">("");
|
||||
const [selectedRole, setSelectedRole] = useState<SystemRole | "all">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedUser, setSelectedUser] = useState<UserWithRoles | null>(null);
|
||||
const [roleToAssign, setRoleToAssign] = useState<SystemRole | "">("");
|
||||
@@ -48,7 +48,7 @@ export function AdminUserTable() {
|
||||
page,
|
||||
limit: 10,
|
||||
search: search || undefined,
|
||||
role: selectedRole || undefined,
|
||||
role: selectedRole === "all" ? undefined : selectedRole,
|
||||
});
|
||||
|
||||
const assignRole = api.users.assignRole.useMutation({
|
||||
@@ -108,13 +108,15 @@ export function AdminUserTable() {
|
||||
<Label htmlFor="role-filter">Filter by Role</Label>
|
||||
<Select
|
||||
value={selectedRole}
|
||||
onValueChange={(value) => setSelectedRole(value as SystemRole | "")}
|
||||
onValueChange={(value) =>
|
||||
setSelectedRole(value as SystemRole | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All roles" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All roles</SelectItem>
|
||||
<SelectItem value="all">All roles</SelectItem>
|
||||
{availableRoles.map((role) => (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
|
||||
433
src/components/admin/repositories-columns.tsx
Normal file
433
src/components/admin/repositories-columns.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
MoreHorizontal,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Trash2,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
// Define error type for mutations
|
||||
interface TRPCError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type Repository = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
description: string | null;
|
||||
trustLevel: "official" | "verified" | "community";
|
||||
isEnabled: boolean;
|
||||
isOfficial: boolean;
|
||||
lastSyncAt: Date | null;
|
||||
syncStatus: string | null;
|
||||
syncError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const trustLevelConfig = {
|
||||
official: {
|
||||
label: "Official",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
icon: Shield,
|
||||
description: "Official HRIStudio repository",
|
||||
},
|
||||
verified: {
|
||||
label: "Verified",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
icon: Shield,
|
||||
description: "Verified by the community",
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
icon: Shield,
|
||||
description: "Community repository",
|
||||
},
|
||||
};
|
||||
|
||||
const syncStatusConfig = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
className: "bg-gray-100 text-gray-800",
|
||||
icon: Clock,
|
||||
description: "Waiting to sync",
|
||||
},
|
||||
syncing: {
|
||||
label: "Syncing",
|
||||
className: "bg-blue-100 text-blue-800",
|
||||
icon: RefreshCw,
|
||||
description: "Currently syncing",
|
||||
},
|
||||
completed: {
|
||||
label: "Success",
|
||||
className: "bg-green-100 text-green-800",
|
||||
icon: CheckCircle,
|
||||
description: "Last sync completed successfully",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
className: "bg-red-100 text-red-800",
|
||||
icon: AlertTriangle,
|
||||
description: "Last sync failed",
|
||||
},
|
||||
};
|
||||
|
||||
function RepositoryActionsCell({ repository }: { repository: Repository }) {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const syncMutation = api.admin.repositories.sync.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Repository sync started");
|
||||
void utils.admin.repositories.list.invalidate();
|
||||
},
|
||||
onError: (error: TRPCError) => {
|
||||
toast.error(error.message ?? "Failed to sync repository");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = api.admin.repositories.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Repository deleted successfully");
|
||||
void utils.admin.repositories.list.invalidate();
|
||||
},
|
||||
onError: (error: TRPCError) => {
|
||||
toast.error(error.message ?? "Failed to delete repository");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSync = async () => {
|
||||
syncMutation.mutate({ id: repository.id });
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete "${repository.name}"?`)
|
||||
) {
|
||||
deleteMutation.mutate({ id: repository.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(repository.id);
|
||||
toast.success("Repository ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
void navigator.clipboard.writeText(repository.url);
|
||||
toast.success("Repository URL copied to clipboard");
|
||||
};
|
||||
|
||||
const canDelete = !repository.isOfficial;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={handleSync}
|
||||
disabled={syncMutation.isPending}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Sync Repository
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/repositories/${repository.id}/edit`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Edit Repository
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={repository.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
View Repository
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Repository ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyUrl}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Repository URL
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Repository
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const repositoriesColumns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Repository Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const repository = row.original;
|
||||
return (
|
||||
<div className="max-w-[200px] min-w-0 space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
<Link
|
||||
href={`/admin/repositories/${repository.id}`}
|
||||
className="truncate font-medium hover:underline"
|
||||
title={repository.name}
|
||||
>
|
||||
{repository.name}
|
||||
</Link>
|
||||
{repository.isOfficial && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{repository.description && (
|
||||
<p
|
||||
className="text-muted-foreground line-clamp-1 truncate text-sm"
|
||||
title={repository.description}
|
||||
>
|
||||
{repository.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "url",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Repository URL" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const url = row.original.url;
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="max-w-[300px] truncate text-sm text-blue-600 hover:underline"
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "trustLevel",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Trust Level" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const trustLevel = row.original.trustLevel;
|
||||
const config = trustLevelConfig[trustLevel];
|
||||
const TrustIcon = config.icon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={config.className}
|
||||
title={config.description}
|
||||
>
|
||||
<TrustIcon className="mr-1 h-3 w-3" />
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
return value.includes(row.original.trustLevel);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "isEnabled",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isEnabled = row.original.isEnabled;
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isEnabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{isEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const isEnabled = row.original.isEnabled;
|
||||
return value.includes(isEnabled ? "enabled" : "disabled");
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "syncStatus",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Sync Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const syncStatus = row.original.syncStatus;
|
||||
const lastSyncAt = row.original.lastSyncAt;
|
||||
const syncError = row.original.syncError;
|
||||
|
||||
if (!syncStatus) return "-";
|
||||
|
||||
const config =
|
||||
syncStatusConfig[syncStatus as keyof typeof syncStatusConfig];
|
||||
if (!config) return syncStatus;
|
||||
|
||||
const SyncIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={config.className}
|
||||
title={config.description}
|
||||
>
|
||||
<SyncIcon
|
||||
className={`mr-1 h-3 w-3 ${syncStatus === "syncing" ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{config.label}
|
||||
</Badge>
|
||||
{lastSyncAt && syncStatus === "completed" && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(lastSyncAt, { addSuffix: true })}
|
||||
</div>
|
||||
)}
|
||||
{syncError && syncStatus === "failed" && (
|
||||
<div
|
||||
className="max-w-[150px] truncate text-xs text-red-600"
|
||||
title={syncError}
|
||||
>
|
||||
{syncError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Updated" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original.updatedAt;
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => <RepositoryActionsCell repository={row.original} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
220
src/components/admin/repositories-data-table.tsx
Normal file
220
src/components/admin/repositories-data-table.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Database } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import { EmptyState } from "~/components/ui/entity-view";
|
||||
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { ActionButton, PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
repositoriesColumns,
|
||||
type Repository,
|
||||
} from "~/components/admin/repositories-columns";
|
||||
|
||||
export function RepositoriesDataTable() {
|
||||
const [trustLevelFilter, setTrustLevelFilter] = React.useState("all");
|
||||
const [enabledFilter, setEnabledFilter] = React.useState("all");
|
||||
|
||||
const {
|
||||
data: repositoriesData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.admin.repositories.list.useQuery(
|
||||
{
|
||||
trustLevel:
|
||||
trustLevelFilter === "all"
|
||||
? undefined
|
||||
: (trustLevelFilter as "official" | "verified" | "community"),
|
||||
isEnabled:
|
||||
enabledFilter === "all" ? undefined : enabledFilter === "enabled",
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-refresh repositories when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refetch();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refetch]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Administration", href: "/admin" },
|
||||
{ label: "Plugin Repositories" },
|
||||
]);
|
||||
|
||||
// Transform repositories data to match the Repository type expected by columns
|
||||
const repositories: Repository[] = React.useMemo(() => {
|
||||
if (!repositoriesData) return [];
|
||||
return repositoriesData as Repository[];
|
||||
}, [repositoriesData]);
|
||||
|
||||
// Trust level filter options
|
||||
const trustLevelOptions = [
|
||||
{ label: "All Trust Levels", value: "all" },
|
||||
{ label: "Official", value: "official" },
|
||||
{ label: "Verified", value: "verified" },
|
||||
{ label: "Community", value: "community" },
|
||||
];
|
||||
|
||||
// Enabled filter options
|
||||
const enabledOptions = [
|
||||
{ label: "All Repositories", value: "all" },
|
||||
{ label: "Enabled", value: "enabled" },
|
||||
{ label: "Disabled", value: "disabled" },
|
||||
];
|
||||
|
||||
// Filter repositories based on selected filters
|
||||
const filteredRepositories = React.useMemo(() => {
|
||||
return repositories.filter((repository) => {
|
||||
const trustLevelMatch =
|
||||
trustLevelFilter === "all" ||
|
||||
repository.trustLevel === trustLevelFilter;
|
||||
const enabledMatch =
|
||||
enabledFilter === "all" ||
|
||||
(enabledFilter === "enabled" && repository.isEnabled) ||
|
||||
(enabledFilter === "disabled" && !repository.isEnabled);
|
||||
return trustLevelMatch && enabledMatch;
|
||||
});
|
||||
}, [repositories, trustLevelFilter, enabledFilter]);
|
||||
|
||||
const filters = (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={trustLevelFilter} onValueChange={setTrustLevelFilter}>
|
||||
<SelectTrigger className="h-8 w-[160px]">
|
||||
<SelectValue placeholder="Trust Level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{trustLevelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={enabledFilter} onValueChange={setEnabledFilter}>
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugin Repositories"
|
||||
description="Manage plugin repositories for the HRIStudio platform"
|
||||
icon={Database}
|
||||
actions={
|
||||
<ActionButton href="/admin/repositories/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Repository
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<div className="text-red-800">
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Failed to Load Repositories
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
{(error as unknown as Error)?.message ??
|
||||
"An error occurred while loading repositories."}
|
||||
</p>
|
||||
<Button onClick={() => void refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state if no repositories
|
||||
if (!isLoading && repositories.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugin Repositories"
|
||||
description="Manage plugin repositories for the HRIStudio platform"
|
||||
icon={Database}
|
||||
actions={
|
||||
<ActionButton href="/admin/repositories/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Repository
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
<EmptyState
|
||||
icon="Database"
|
||||
title="No Plugin Repositories"
|
||||
description="Add plugin repositories to enable users to browse and install plugins."
|
||||
action={
|
||||
<Button asChild>
|
||||
<a href="/admin/repositories/new">Add First Repository</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Plugin Repositories"
|
||||
description="Manage plugin repositories for the HRIStudio platform"
|
||||
icon={Database}
|
||||
actions={
|
||||
<ActionButton href="/admin/repositories/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Repository
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Data Table */}
|
||||
<DataTable
|
||||
columns={repositoriesColumns}
|
||||
data={filteredRepositories}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search repositories..."
|
||||
isLoading={isLoading}
|
||||
loadingRowCount={5}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user