Files
openclaw-mission-control/frontend/src/app/skills/packs/page.tsx

191 lines
5.9 KiB
TypeScript

"use client";
export const dynamic = "force-dynamic";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/api/mutator";
import type { SkillPackRead } from "@/api/generated/model";
import {
getListSkillPacksApiV1SkillsPacksGetQueryKey,
type listSkillPacksApiV1SkillsPacksGetResponse,
useDeleteSkillPackApiV1SkillsPacksPackIdDelete,
useListSkillPacksApiV1SkillsPacksGet,
useSyncSkillPackApiV1SkillsPacksPackIdSyncPost,
} from "@/api/generated/skills/skills";
import { SkillPacksTable } from "@/components/skills/SkillPacksTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { buttonVariants } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import { useUrlSorting } from "@/lib/use-url-sorting";
const PACKS_SORTABLE_COLUMNS = ["name", "source_url", "skill_count", "updated_at"];
export default function SkillsPacksPage() {
const queryClient = useQueryClient();
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [deleteTarget, setDeleteTarget] = useState<SkillPackRead | null>(null);
const [syncingPackIds, setSyncingPackIds] = useState<Set<string>>(new Set());
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: PACKS_SORTABLE_COLUMNS,
defaultSorting: [{ id: "name", desc: false }],
paramPrefix: "skill_packs",
});
const packsQuery = useListSkillPacksApiV1SkillsPacksGet<
listSkillPacksApiV1SkillsPacksGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn && isAdmin),
refetchOnMount: "always",
refetchInterval: 15_000,
},
});
const packsQueryKey = getListSkillPacksApiV1SkillsPacksGetQueryKey();
const packs = useMemo<SkillPackRead[]>(
() => (packsQuery.data?.status === 200 ? packsQuery.data.data : []),
[packsQuery.data],
);
const deleteMutation =
useDeleteSkillPackApiV1SkillsPacksPackIdDelete<ApiError>(
{
mutation: {
onSuccess: async () => {
setDeleteTarget(null);
await queryClient.invalidateQueries({
queryKey: packsQueryKey,
});
},
},
},
queryClient,
);
const syncMutation =
useSyncSkillPackApiV1SkillsPacksPackIdSyncPost<ApiError>(
{
mutation: {
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: packsQueryKey,
});
},
},
},
queryClient,
);
const handleDelete = () => {
if (!deleteTarget) return;
deleteMutation.mutate({ packId: deleteTarget.id });
};
const handleSyncPack = async (pack: SkillPackRead) => {
setSyncingPackIds((previous) => {
const next = new Set(previous);
next.add(pack.id);
return next;
});
try {
await syncMutation.mutateAsync({
packId: pack.id,
});
} finally {
setSyncingPackIds((previous) => {
const next = new Set(previous);
next.delete(pack.id);
return next;
});
}
};
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to manage skill packs.",
forceRedirectUrl: "/skills/packs",
}}
title="Skill Packs"
description={`${packs.length} pack${packs.length === 1 ? "" : "s"} configured.`}
headerActions={
isAdmin ? (
<Link
href="/skills/packs/new"
className={buttonVariants({ variant: "primary", size: "md" })}
>
Add pack
</Link>
) : null
}
isAdmin={isAdmin}
adminOnlyMessage="Only organization owners and admins can manage skill packs."
stickyHeader
>
<div className="space-y-6">
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<SkillPacksTable
packs={packs}
isLoading={packsQuery.isLoading}
sorting={sorting}
onSortingChange={onSortingChange}
stickyHeader
getEditHref={(pack) => `/skills/packs/${pack.id}/edit`}
canSync
syncingPackIds={syncingPackIds}
onSync={(pack) => {
void handleSyncPack(pack);
}}
onDelete={setDeleteTarget}
emptyState={{
title: "No packs yet",
description: "Add your first skill URL pack to get started.",
actionHref: "/skills/packs/new",
actionLabel: "Add your first pack",
}}
/>
</div>
{packsQuery.error ? (
<p className="text-sm text-rose-600">{packsQuery.error.message}</p>
) : null}
{deleteMutation.error ? (
<p className="text-sm text-rose-600">{deleteMutation.error.message}</p>
) : null}
{syncMutation.error ? (
<p className="text-sm text-rose-600">{syncMutation.error.message}</p>
) : null}
</div>
</DashboardPageLayout>
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
ariaLabel="Delete skill pack"
title="Delete skill pack"
description={
<>
This will remove <strong>{deleteTarget?.name}</strong> from your
pack list. This action cannot be undone.
</>
}
errorMessage={deleteMutation.error?.message}
onConfirm={handleDelete}
isConfirming={deleteMutation.isPending}
/>
</>
);
}