feat: add skill pack management features including creation, editing, and syncing
This commit is contained in:
committed by
Abhimanyu Saharan
parent
88565f4d69
commit
a7e1e5cbf4
@@ -6,13 +6,14 @@ import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Bot,
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Folder,
|
||||
Building2,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Package,
|
||||
Settings,
|
||||
Store,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -165,6 +166,42 @@ export function DashboardSidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Skills
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<Link
|
||||
href="/skills/marketplace"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname === "/skills" || pathname.startsWith("/skills/marketplace")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Store className="h-4 w-4" />
|
||||
Marketplace
|
||||
</Link>
|
||||
<Link
|
||||
href="/skills/packs"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/skills/packs")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Boxes className="h-4 w-4" />
|
||||
Packs
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
Administration
|
||||
@@ -196,20 +233,6 @@ export function DashboardSidebar() {
|
||||
Gateways
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/skills"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/skills")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
Skills
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/agents"
|
||||
|
||||
@@ -8,13 +8,14 @@ import { clearLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Package,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
Store,
|
||||
Trello,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -156,7 +157,12 @@ export function UserMenu({
|
||||
{ href: "/activity", label: "Activity", icon: Activity },
|
||||
{ href: "/agents", label: "Agents", icon: Bot },
|
||||
{ href: "/gateways", label: "Gateways", icon: Server },
|
||||
{ href: "/skills", label: "Skills", icon: Package },
|
||||
{
|
||||
href: "/skills/marketplace",
|
||||
label: "Skills marketplace",
|
||||
icon: Store,
|
||||
},
|
||||
{ href: "/skills/packs", label: "Skill packs", icon: Boxes },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
] as const
|
||||
).map((item) => (
|
||||
|
||||
170
frontend/src/components/skills/MarketplaceSkillForm.tsx
Normal file
170
frontend/src/components/skills/MarketplaceSkillForm.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type MarketplaceSkillFormValues = {
|
||||
sourceUrl: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type MarketplaceSkillFormProps = {
|
||||
initialValues?: MarketplaceSkillFormValues;
|
||||
sourceUrlReadOnly?: boolean;
|
||||
sourceUrlHelpText?: string;
|
||||
sourceLabel?: string;
|
||||
sourcePlaceholder?: string;
|
||||
nameLabel?: string;
|
||||
namePlaceholder?: string;
|
||||
descriptionLabel?: string;
|
||||
descriptionPlaceholder?: string;
|
||||
requiredUrlMessage?: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: MarketplaceSkillFormValues) => Promise<void>;
|
||||
};
|
||||
|
||||
const DEFAULT_VALUES: MarketplaceSkillFormValues = {
|
||||
sourceUrl: "",
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
|
||||
const extractErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (error instanceof ApiError) return error.message || fallback;
|
||||
if (error instanceof Error) return error.message || fallback;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export function MarketplaceSkillForm({
|
||||
initialValues,
|
||||
sourceUrlReadOnly = false,
|
||||
sourceUrlHelpText,
|
||||
sourceLabel = "Skill URL",
|
||||
sourcePlaceholder = "https://github.com/org/skill-repo",
|
||||
nameLabel = "Name (optional)",
|
||||
namePlaceholder = "Deploy Helper",
|
||||
descriptionLabel = "Description (optional)",
|
||||
descriptionPlaceholder = "Short summary shown in the marketplace.",
|
||||
requiredUrlMessage = "Skill URL is required.",
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: MarketplaceSkillFormProps) {
|
||||
const resolvedInitial = initialValues ?? DEFAULT_VALUES;
|
||||
const [sourceUrl, setSourceUrl] = useState(resolvedInitial.sourceUrl);
|
||||
const [name, setName] = useState(resolvedInitial.name);
|
||||
const [description, setDescription] = useState(resolvedInitial.description);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedUrl = sourceUrl.trim();
|
||||
if (!normalizedUrl) {
|
||||
setErrorMessage(requiredUrlMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
sourceUrl: normalizedUrl,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(extractErrorMessage(error, "Unable to save skill."));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="source-url"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
|
||||
>
|
||||
{sourceLabel}
|
||||
</label>
|
||||
<Input
|
||||
id="source-url"
|
||||
type="url"
|
||||
value={sourceUrl}
|
||||
onChange={(event) => setSourceUrl(event.target.value)}
|
||||
placeholder={sourcePlaceholder}
|
||||
readOnly={sourceUrlReadOnly}
|
||||
disabled={isSubmitting || sourceUrlReadOnly}
|
||||
/>
|
||||
{sourceUrlHelpText ? (
|
||||
<p className="text-xs text-slate-500">{sourceUrlHelpText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="skill-name"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
|
||||
>
|
||||
{nameLabel}
|
||||
</label>
|
||||
<Input
|
||||
id="skill-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={namePlaceholder}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="skill-description"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
|
||||
>
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
<Textarea
|
||||
id="skill-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder={descriptionPlaceholder}
|
||||
className="min-h-[120px]"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? submittingLabel : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
269
frontend/src/components/skills/MarketplaceSkillsTable.tsx
Normal file
269
frontend/src/components/skills/MarketplaceSkillsTable.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import type { MarketplaceSkillCardRead } from "@/api/generated/model";
|
||||
import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable";
|
||||
import { dateCell } from "@/components/tables/cell-formatters";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { truncateText as truncate } from "@/lib/formatters";
|
||||
|
||||
type MarketplaceSkillsTableProps = {
|
||||
skills: MarketplaceSkillCardRead[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
stickyHeader?: boolean;
|
||||
disableSorting?: boolean;
|
||||
canInstallActions: boolean;
|
||||
isMutating?: boolean;
|
||||
onSkillClick?: (skill: MarketplaceSkillCardRead) => void;
|
||||
onUninstall: (skill: MarketplaceSkillCardRead) => void;
|
||||
onDelete?: (skill: MarketplaceSkillCardRead) => void;
|
||||
getEditHref?: (skill: MarketplaceSkillCardRead) => string;
|
||||
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
||||
icon?: DataTableEmptyState["icon"];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_EMPTY_ICON = (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 7h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 17h16" />
|
||||
<path d="M8 7v10" />
|
||||
<path d="M16 7v10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const toPackUrl = (sourceUrl: string): string => {
|
||||
try {
|
||||
const parsed = new URL(sourceUrl);
|
||||
const treeMarker = "/tree/";
|
||||
const markerIndex = parsed.pathname.indexOf(treeMarker);
|
||||
if (markerIndex > 0) {
|
||||
const repoPath = parsed.pathname.slice(0, markerIndex);
|
||||
return `${parsed.origin}${repoPath}`;
|
||||
}
|
||||
return sourceUrl;
|
||||
} catch {
|
||||
return sourceUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const toPackLabel = (packUrl: string): string => {
|
||||
try {
|
||||
const parsed = new URL(packUrl);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
if (segments.length >= 2) {
|
||||
return `${segments[0]}/${segments[1]}`;
|
||||
}
|
||||
return parsed.host;
|
||||
} catch {
|
||||
return "Open pack";
|
||||
}
|
||||
};
|
||||
|
||||
const toPackDetailHref = (packUrl: string): string => {
|
||||
const params = new URLSearchParams({ source_url: packUrl });
|
||||
return `/skills/packs/detail?${params.toString()}`;
|
||||
};
|
||||
|
||||
export function MarketplaceSkillsTable({
|
||||
skills,
|
||||
isLoading = false,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
stickyHeader = false,
|
||||
disableSorting = false,
|
||||
canInstallActions,
|
||||
isMutating = false,
|
||||
onSkillClick,
|
||||
onUninstall,
|
||||
onDelete,
|
||||
getEditHref,
|
||||
emptyState,
|
||||
}: MarketplaceSkillsTableProps) {
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const resolvedSorting = sorting ?? internalSorting;
|
||||
const handleSortingChange: OnChangeFn<SortingState> =
|
||||
onSortingChange ??
|
||||
((updater: Updater<SortingState>) => {
|
||||
setInternalSorting(updater);
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<MarketplaceSkillCardRead>[]>(() => {
|
||||
const baseColumns: ColumnDef<MarketplaceSkillCardRead>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Skill",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
{onSkillClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSkillClick(row.original)}
|
||||
className="text-sm font-medium text-blue-700 hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-slate-900">{row.original.name}</p>
|
||||
)}
|
||||
<p className="mt-1 line-clamp-2 text-xs text-slate-500">
|
||||
{row.original.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "source_url",
|
||||
header: "Pack",
|
||||
cell: ({ row }) => {
|
||||
const packUrl = toPackUrl(row.original.source_url);
|
||||
return (
|
||||
<Link
|
||||
href={toPackDetailHref(packUrl)}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-blue-600"
|
||||
>
|
||||
{truncate(toPackLabel(packUrl), 40)}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "category",
|
||||
header: "Category",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{row.original.category || "uncategorized"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "risk",
|
||||
header: "Risk",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{row.original.risk || "unknown"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "source",
|
||||
header: "Source",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700" title={row.original.source || ""}>
|
||||
{truncate(row.original.source || "unknown", 36)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => dateCell(row.original.updated_at),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
{row.original.installed ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onUninstall(row.original)}
|
||||
disabled={isMutating || !canInstallActions}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
) : null}
|
||||
{getEditHref ? (
|
||||
<Link
|
||||
href={getEditHref(row.original)}
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(row.original)}
|
||||
disabled={isMutating}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return baseColumns;
|
||||
}, [
|
||||
canInstallActions,
|
||||
getEditHref,
|
||||
isMutating,
|
||||
onDelete,
|
||||
onSkillClick,
|
||||
onUninstall,
|
||||
]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: skills,
|
||||
columns,
|
||||
enableSorting: !disableSorting,
|
||||
state: {
|
||||
...(!disableSorting ? { sorting: resolvedSorting } : {}),
|
||||
},
|
||||
...(disableSorting ? {} : { onSortingChange: handleSortingChange }),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
...(disableSorting ? {} : { getSortedRowModel: getSortedRowModel() }),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
stickyHeader={stickyHeader}
|
||||
rowClassName="transition hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4 align-top"
|
||||
emptyState={
|
||||
emptyState
|
||||
? {
|
||||
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
|
||||
title: emptyState.title,
|
||||
description: emptyState.description,
|
||||
actionHref: emptyState.actionHref,
|
||||
actionLabel: emptyState.actionLabel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
188
frontend/src/components/skills/SkillPacksTable.tsx
Normal file
188
frontend/src/components/skills/SkillPacksTable.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import type { SkillPackRead } from "@/api/generated/model";
|
||||
import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable";
|
||||
import { dateCell } from "@/components/tables/cell-formatters";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { truncateText as truncate } from "@/lib/formatters";
|
||||
|
||||
type SkillPacksTableProps = {
|
||||
packs: SkillPackRead[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
stickyHeader?: boolean;
|
||||
canSync?: boolean;
|
||||
syncingPackIds?: Set<string>;
|
||||
onSync?: (pack: SkillPackRead) => void;
|
||||
onDelete?: (pack: SkillPackRead) => void;
|
||||
getEditHref?: (pack: SkillPackRead) => string;
|
||||
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
||||
icon?: DataTableEmptyState["icon"];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_EMPTY_ICON = (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 7h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 17h16" />
|
||||
<path d="M8 7v10" />
|
||||
<path d="M16 7v10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function SkillPacksTable({
|
||||
packs,
|
||||
isLoading = false,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
stickyHeader = false,
|
||||
canSync = false,
|
||||
syncingPackIds,
|
||||
onSync,
|
||||
onDelete,
|
||||
getEditHref,
|
||||
emptyState,
|
||||
}: SkillPacksTableProps) {
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
]);
|
||||
const resolvedSorting = sorting ?? internalSorting;
|
||||
const handleSortingChange: OnChangeFn<SortingState> =
|
||||
onSortingChange ??
|
||||
((updater: Updater<SortingState>) => {
|
||||
setInternalSorting(updater);
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<SkillPackRead>[]>(() => {
|
||||
const baseColumns: ColumnDef<SkillPackRead>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Pack",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{row.original.name}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-slate-500">
|
||||
{row.original.description || "No description provided."}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "source_url",
|
||||
header: "Pack URL",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={row.original.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-blue-600"
|
||||
>
|
||||
{truncate(row.original.source_url, 48)}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "skill_count",
|
||||
header: "Skills",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/skills/marketplace?packId=${encodeURIComponent(row.original.id)}`}
|
||||
className="text-sm font-medium text-blue-700 hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{row.original.skill_count ?? 0}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => dateCell(row.original.updated_at),
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
if (!onSync) return null;
|
||||
const isThisPackSyncing = Boolean(syncingPackIds?.has(row.original.id));
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onSync(row.original)}
|
||||
disabled={isThisPackSyncing || !canSync}
|
||||
>
|
||||
{isThisPackSyncing ? "Syncing..." : "Sync"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
return baseColumns;
|
||||
}, [canSync, onSync, syncingPackIds]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: packs,
|
||||
columns,
|
||||
state: {
|
||||
sorting: resolvedSorting,
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
stickyHeader={stickyHeader}
|
||||
rowClassName="transition hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4 align-top"
|
||||
rowActions={
|
||||
getEditHref || onDelete
|
||||
? {
|
||||
...(getEditHref ? { getEditHref } : {}),
|
||||
...(onDelete ? { onDelete } : {}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyState={
|
||||
emptyState
|
||||
? {
|
||||
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
|
||||
title: emptyState.title,
|
||||
description: emptyState.description,
|
||||
actionHref: emptyState.actionHref,
|
||||
actionLabel: emptyState.actionLabel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user