diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 06d8db58..1bfa5f1f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -19,6 +19,8 @@ import type { AuthMethod, OAuthProvider, User, + ManagedNamespace, + Namespace, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -460,6 +462,51 @@ export const skillLifecycleApi = { }, } +function normalizeNamespaceSlug(namespace: string): string { + return namespace.startsWith('@') ? namespace.slice(1) : namespace +} + +export const namespaceApi = { + async listMine(): Promise { + return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) + }, + + async getDetail(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`) + }, + + async freeze(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/freeze`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async unfreeze(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async archive(slug: string, reason?: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/archive`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}), + }) + }, + + async restore(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/restore`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, +} + export const tokenApi = { async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> { const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 8c59a289..36a70f77 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -82,6 +82,9 @@ export interface MergeConfirmRequest { } // Namespace types +export type NamespaceStatus = 'ACTIVE' | 'FROZEN' | 'ARCHIVED' | string +export type NamespaceRole = 'OWNER' | 'ADMIN' | 'MEMBER' | string + export interface Namespace { id: number slug: string @@ -89,15 +92,25 @@ export interface Namespace { description?: string type: 'GLOBAL' | 'TEAM' avatarUrl?: string - status: string + status: NamespaceStatus createdAt: string updatedAt?: string } +export interface ManagedNamespace extends Namespace { + createdBy?: string + currentUserRole?: NamespaceRole + immutable: boolean + canFreeze: boolean + canUnfreeze: boolean + canArchive: boolean + canRestore: boolean +} + export interface NamespaceMember { id: number userId: string - role: string + role: NamespaceRole createdAt: string } diff --git a/web/src/features/namespace/namespace-header.tsx b/web/src/features/namespace/namespace-header.tsx index dea9ad27..cda6c971 100644 --- a/web/src/features/namespace/namespace-header.tsx +++ b/web/src/features/namespace/namespace-header.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import type { Namespace } from '@/api/types' import { NamespaceBadge } from '@/shared/components/namespace-badge' +import { cn } from '@/shared/lib/utils' interface NamespaceHeaderProps { namespace: Namespace @@ -8,6 +9,24 @@ interface NamespaceHeaderProps { export function NamespaceHeader({ namespace }: NamespaceHeaderProps) { const { t } = useTranslation() + const statusLabel = namespace.status === 'FROZEN' + ? t('namespaceStatus.frozen') + : namespace.status === 'ARCHIVED' + ? t('namespaceStatus.archived') + : t('namespaceStatus.active') + const statusClassName = namespace.status === 'FROZEN' + ? 'bg-amber-500/10 text-amber-500 border-amber-500/20' + : namespace.status === 'ARCHIVED' + ? 'bg-slate-500/10 text-slate-500 border-slate-500/20' + : 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' + const hint = namespace.type === 'GLOBAL' + ? t('namespaceStatus.immutableHint') + : namespace.status === 'FROZEN' + ? t('namespaceStatus.frozenHint') + : namespace.status === 'ARCHIVED' + ? t('namespaceStatus.archivedHint') + : null + return (
{namespace.avatarUrl && ( @@ -18,9 +37,12 @@ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) { /> )}
-
+

{namespace.displayName}

+ + {statusLabel} +
{namespace.description && (

{namespace.description}

@@ -28,6 +50,11 @@ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
@{namespace.slug}
+ {hint ? ( +
+ {hint} +
+ ) : null}
) diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index 7db3edf6..ce9ef25a 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -20,13 +20,12 @@ function getAppBaseUrl(): string { return `${window.location.protocol}//${window.location.host}` } -export function InstallCommand({ namespace, slug, version }: InstallCommandProps) { +export function InstallCommand({ slug }: InstallCommandProps) { const { t } = useTranslation() const [copied, setCopied] = useState(false) const baseUrl = useMemo(() => getAppBaseUrl(), []) - const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace const command = useMemo(() => { const installCmd = `clawhub install ${slug} ` // 如果是默认的 clawhub.ai 不需要环境变量,否则显示完整配置 @@ -34,7 +33,7 @@ export function InstallCommand({ namespace, slug, version }: InstallCommandProps return `CLAWHUB_SITE=${baseUrl} CLAWHUB_REGISTRY=${baseUrl} ${installCmd}` } return installCmd - }, [cleanNamespace, slug, version, baseUrl]) + }, [baseUrl, slug]) const handleCopy = async () => { try { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2c92c08e..5274e052 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -265,8 +265,38 @@ "create": "Create Namespace", "typeGlobal": "Global", "typeTeam": "Team", + "roleLabel": "Current role", + "roleUnknown": "Unknown", "manageMembers": "Manage Members", "reviewTasks": "Review Tasks", + "freeze": "Freeze", + "unfreeze": "Unfreeze", + "archive": "Archive", + "restore": "Restore", + "activeHint": "This namespace is fully active and can continue managing members, reviews, and skill publishing.", + "frozenHint": "This namespace is frozen. Members can still view it, but it is read-only and cannot publish or change membership.", + "archivedHint": "This namespace is archived. Public entry points are hidden, but you can still view and restore it from the dashboard.", + "immutableHint": "This is a built-in system namespace and always remains read-only.", + "freezeConfirmTitle": "Freeze namespace", + "freezeConfirmDescription": "\"{{name}}\" will become read-only after freezing. Continue?", + "unfreezeConfirmTitle": "Unfreeze namespace", + "unfreezeConfirmDescription": "\"{{name}}\" will regain normal management and publishing after unfreezing.", + "archiveConfirmTitle": "Archive namespace", + "archiveConfirmDescription": "\"{{name}}\" will be hidden from public entry points and only remain recoverable from the dashboard.", + "restoreConfirmTitle": "Restore namespace", + "restoreConfirmDescription": "\"{{name}}\" will return to an active operating state.", + "freezeSuccessTitle": "Namespace frozen", + "freezeSuccessDescription": "\"{{name}}\" is now read-only.", + "freezeErrorTitle": "Failed to freeze namespace", + "unfreezeSuccessTitle": "Namespace unfrozen", + "unfreezeSuccessDescription": "\"{{name}}\" is active again.", + "unfreezeErrorTitle": "Failed to unfreeze namespace", + "archiveSuccessTitle": "Namespace archived", + "archiveSuccessDescription": "\"{{name}}\" is now hidden from public entry points.", + "archiveErrorTitle": "Failed to archive namespace", + "restoreSuccessTitle": "Namespace restored", + "restoreSuccessDescription": "\"{{name}}\" can operate normally again.", + "restoreErrorTitle": "Failed to restore namespace", "emptyTitle": "No namespaces yet", "emptyDescription": "Create a namespace to organize your skills" }, @@ -546,7 +576,10 @@ "colActions": "Actions", "remove": "Remove", "empty": "No members", - "namespaceNotFound": "Namespace not found" + "namespaceNotFound": "Namespace not found", + "globalReadOnly": "This is a built-in system namespace. Membership can only be viewed and cannot be changed.", + "frozenReadOnly": "This namespace is frozen. You can still view members, but cannot add, remove, or change roles.", + "archivedReadOnly": "This namespace is archived. You can still view members, but cannot change membership until it is restored." }, "upload": { "dropHint": "Drop to upload...", @@ -564,7 +597,18 @@ "version": "Version {{version}}", "tabPending": "Pending", "tabApproved": "Approved", - "tabRejected": "Rejected" + "tabRejected": "Rejected", + "globalReadOnly": "This is a built-in system namespace. Review history is visible, but no new review actions are allowed.", + "frozenReadOnly": "This namespace is frozen. You can still view review history, but cannot continue processing review tasks.", + "archivedReadOnly": "This namespace is archived. You can still view review history, but cannot continue processing review tasks until it is restored." + }, + "namespaceStatus": { + "active": "Active", + "frozen": "Frozen", + "archived": "Archived", + "frozenHint": "This namespace is currently read-only and cannot publish, review, or change members.", + "archivedHint": "This namespace is hidden from public entry points and only visible to dashboard members.", + "immutableHint": "This is a built-in system namespace and does not allow governance or membership changes." }, "fileTree": { "title": "Files ({{count}})" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index c115779c..78773076 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -265,8 +265,38 @@ "create": "创建命名空间", "typeGlobal": "全局", "typeTeam": "团队", + "roleLabel": "当前角色", + "roleUnknown": "未知", "manageMembers": "管理成员", "reviewTasks": "审核任务", + "freeze": "冻结", + "unfreeze": "解冻", + "archive": "归档", + "restore": "恢复", + "activeHint": "命名空间运行正常,可继续管理成员、处理审核和发布技能。", + "frozenHint": "命名空间已冻结。成员仍可查看,但当前处于只读状态,不能继续发布或变更成员。", + "archivedHint": "命名空间已归档。公开入口已隐藏,但你仍可在管理台查看并恢复。", + "immutableHint": "系统内置命名空间,始终保持只读,不允许任何治理操作。", + "freezeConfirmTitle": "确认冻结命名空间", + "freezeConfirmDescription": "冻结后“{{name}}”将进入只读状态,确定继续吗?", + "unfreezeConfirmTitle": "确认解冻命名空间", + "unfreezeConfirmDescription": "解冻后“{{name}}”会重新恢复正常管理和发布能力。", + "archiveConfirmTitle": "确认归档命名空间", + "archiveConfirmDescription": "归档后“{{name}}”会从公开入口隐藏,仅保留管理台恢复入口。", + "restoreConfirmTitle": "确认恢复命名空间", + "restoreConfirmDescription": "恢复后“{{name}}”会重新进入可运营状态。", + "freezeSuccessTitle": "命名空间已冻结", + "freezeSuccessDescription": "“{{name}}”已切换为只读状态。", + "freezeErrorTitle": "冻结命名空间失败", + "unfreezeSuccessTitle": "命名空间已解冻", + "unfreezeSuccessDescription": "“{{name}}”已恢复正常状态。", + "unfreezeErrorTitle": "解冻命名空间失败", + "archiveSuccessTitle": "命名空间已归档", + "archiveSuccessDescription": "“{{name}}”已从公开入口隐藏。", + "archiveErrorTitle": "归档命名空间失败", + "restoreSuccessTitle": "命名空间已恢复", + "restoreSuccessDescription": "“{{name}}”已重新开放治理能力。", + "restoreErrorTitle": "恢复命名空间失败", "emptyTitle": "还没有命名空间", "emptyDescription": "创建一个命名空间来组织你的技能" }, @@ -546,7 +576,10 @@ "colActions": "操作", "remove": "移除", "empty": "暂无成员", - "namespaceNotFound": "命名空间不存在" + "namespaceNotFound": "命名空间不存在", + "globalReadOnly": "这是系统内置命名空间,成员信息仅供查看,不能进行任何变更。", + "frozenReadOnly": "命名空间已冻结,成员列表仍可查看,但当前不能添加、移除或调整成员角色。", + "archivedReadOnly": "命名空间已归档,成员列表仍可查看,但在恢复前不能修改成员。" }, "upload": { "dropHint": "放开以上传文件...", @@ -564,7 +597,18 @@ "version": "版本 {{version}}", "tabPending": "待审核", "tabApproved": "已通过", - "tabRejected": "已拒绝" + "tabRejected": "已拒绝", + "globalReadOnly": "这是系统内置命名空间,仅可查看审核记录,不允许执行新的审核动作。", + "frozenReadOnly": "命名空间已冻结。你仍可查看审核记录,但当前不能继续处理审核任务。", + "archivedReadOnly": "命名空间已归档。你仍可查看审核记录,但在恢复前不能继续处理审核任务。" + }, + "namespaceStatus": { + "active": "正常", + "frozen": "已冻结", + "archived": "已归档", + "frozenHint": "命名空间当前为只读状态,不能继续发布、审核或调整成员。", + "archivedHint": "命名空间已从公开入口隐藏,仅管理台成员可见并可恢复。", + "immutableHint": "系统内置命名空间,不允许任何治理或成员修改操作。" }, "fileTree": { "title": "文件列表 ({{count}})" diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index b7641c26..a913f754 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -1,16 +1,30 @@ +import { useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { EmptyState } from '@/shared/components/empty-state' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' -import { useMyNamespaces } from '@/shared/hooks/use-skill-queries' +import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-skill-queries' +import { toast } from '@/shared/lib/toast' + +type PendingNamespaceAction = + | { action: 'freeze'; slug: string; name: string } + | { action: 'unfreeze'; slug: string; name: string } + | { action: 'archive'; slug: string; name: string } + | { action: 'restore'; slug: string; name: string } export function MyNamespacesPage() { const navigate = useNavigate() const { t } = useTranslation() + const [pendingAction, setPendingAction] = useState(null) const { data: namespaces, isLoading } = useMyNamespaces() + const freezeMutation = useFreezeNamespace() + const unfreezeMutation = useUnfreezeNamespace() + const archiveMutation = useArchiveNamespace() + const restoreMutation = useRestoreNamespace() const handleNamespaceClick = (slug: string) => { navigate({ to: `/space/${slug}` }) @@ -26,6 +40,110 @@ export function MyNamespacesPage() { navigate({ to: `/dashboard/namespaces/${slug}/reviews` }) } + const resolveStatusLabel = (status: string) => { + if (status === 'FROZEN') { + return t('namespaceStatus.frozen') + } + if (status === 'ARCHIVED') { + return t('namespaceStatus.archived') + } + return t('namespaceStatus.active') + } + + const resolveStatusClassName = (status: string) => { + if (status === 'FROZEN') { + return 'bg-amber-500/10 text-amber-500 border-amber-500/20' + } + if (status === 'ARCHIVED') { + return 'bg-slate-500/10 text-slate-500 border-slate-500/20' + } + return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' + } + + const resolveHint = (status: string, type: string) => { + if (type === 'GLOBAL') { + return t('myNamespaces.immutableHint') + } + if (status === 'FROZEN') { + return t('myNamespaces.frozenHint') + } + if (status === 'ARCHIVED') { + return t('myNamespaces.archivedHint') + } + return t('myNamespaces.activeHint') + } + + const resolveActionCopy = (action: PendingNamespaceAction['action'], name: string) => { + if (action === 'freeze') { + return { + title: t('myNamespaces.freezeConfirmTitle'), + description: t('myNamespaces.freezeConfirmDescription', { name }), + confirmText: t('myNamespaces.freeze'), + successTitle: t('myNamespaces.freezeSuccessTitle'), + successDescription: t('myNamespaces.freezeSuccessDescription', { name }), + errorTitle: t('myNamespaces.freezeErrorTitle'), + variant: 'default' as const, + } + } + if (action === 'unfreeze') { + return { + title: t('myNamespaces.unfreezeConfirmTitle'), + description: t('myNamespaces.unfreezeConfirmDescription', { name }), + confirmText: t('myNamespaces.unfreeze'), + successTitle: t('myNamespaces.unfreezeSuccessTitle'), + successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }), + errorTitle: t('myNamespaces.unfreezeErrorTitle'), + variant: 'default' as const, + } + } + if (action === 'archive') { + return { + title: t('myNamespaces.archiveConfirmTitle'), + description: t('myNamespaces.archiveConfirmDescription', { name }), + confirmText: t('myNamespaces.archive'), + successTitle: t('myNamespaces.archiveSuccessTitle'), + successDescription: t('myNamespaces.archiveSuccessDescription', { name }), + errorTitle: t('myNamespaces.archiveErrorTitle'), + variant: 'destructive' as const, + } + } + return { + title: t('myNamespaces.restoreConfirmTitle'), + description: t('myNamespaces.restoreConfirmDescription', { name }), + confirmText: t('myNamespaces.restore'), + successTitle: t('myNamespaces.restoreSuccessTitle'), + successDescription: t('myNamespaces.restoreSuccessDescription', { name }), + errorTitle: t('myNamespaces.restoreErrorTitle'), + variant: 'default' as const, + } + } + + const handleNamespaceAction = async () => { + if (!pendingAction) { + return + } + + const copy = resolveActionCopy(pendingAction.action, pendingAction.name) + + try { + if (pendingAction.action === 'freeze') { + await freezeMutation.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'unfreeze') { + await unfreezeMutation.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'archive') { + await archiveMutation.mutateAsync({ slug: pendingAction.slug }) + } else { + await restoreMutation.mutateAsync({ slug: pendingAction.slug }) + } + + toast.success(copy.successTitle, copy.successDescription) + setPendingAction(null) + } catch (error) { + toast.error(copy.errorTitle, error instanceof Error ? error.message : '') + throw error + } + } + if (isLoading) { return (
@@ -63,6 +181,9 @@ export function MyNamespacesPage() { type={namespace.type} name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')} /> + + {resolveStatusLabel(namespace.status)} +
{namespace.description && (

@@ -70,9 +191,15 @@ export function MyNamespacesPage() {

)}
@{namespace.slug}
+
+ {resolveHint(namespace.status, namespace.type)} +
+
+ {t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')} +
-
+
{namespace.type === 'TEAM' && ( + {namespace.canFreeze && ( + + )} + {namespace.canUnfreeze && ( + + )} + {namespace.canArchive && ( + + )} + {namespace.canRestore && ( + + )}
@@ -101,6 +276,20 @@ export function MyNamespacesPage() { action={} /> )} + + { + if (!open) { + setPendingAction(null) + } + }} + title={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).title : ''} + description={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).description : ''} + confirmText={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).confirmText : undefined} + variant={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).variant : 'default'} + onConfirm={handleNamespaceAction} + /> ) } diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index b7af1374..5fc53cc3 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -15,6 +15,14 @@ export function NamespaceMembersPage() { const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug) const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug) + const isReadOnly = namespace?.type === 'GLOBAL' || namespace?.status !== 'ACTIVE' + const readOnlyMessage = namespace?.type === 'GLOBAL' + ? t('members.globalReadOnly') + : namespace?.status === 'FROZEN' + ? t('members.frozenReadOnly') + : namespace?.status === 'ARCHIVED' + ? t('members.archivedReadOnly') + : null if (isLoadingNamespace) { return ( @@ -42,8 +50,14 @@ export function NamespaceMembersPage() {
+ {readOnlyMessage ? ( + + {readOnlyMessage} + + ) : null} +
- +
{isLoadingMembers ? ( @@ -77,7 +91,7 @@ export function NamespaceMembersPage() { {new Date(member.createdAt).toLocaleDateString(language)} - diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 16f4cf7a..111a851f 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { useNamespaceDetail } from '@/shared/hooks/use-skill-queries' import { useReviewList } from '@/features/review/use-review-list' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { NamespaceHeader } from '@/features/namespace/namespace-header' function ReviewListSection({ namespaceId }: { namespaceId?: number }) { const { t } = useTranslation() @@ -54,6 +55,13 @@ export function NamespaceReviewsPage() { const { t } = useTranslation() const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' }) const { data: namespace } = useNamespaceDetail(slug) + const readOnlyMessage = namespace?.type === 'GLOBAL' + ? t('nsReviews.globalReadOnly') + : namespace?.status === 'FROZEN' + ? t('nsReviews.frozenReadOnly') + : namespace?.status === 'ARCHIVED' + ? t('nsReviews.archivedReadOnly') + : null return (
@@ -61,6 +69,12 @@ export function NamespaceReviewsPage() { title={t('nsReviews.title')} subtitle={namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')} /> + {namespace ? : null} + {readOnlyMessage ? ( + + {readOnlyMessage} + + ) : null}
) diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 7b177fa0..b1f84104 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types' -import { fetchJson, fetchText, getCsrfHeaders, meApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' +import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace } from '@/api/types' +import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' const PUBLISH_REQUEST_TIMEOUT_MS = 60_000 @@ -56,14 +56,12 @@ async function getMyStars(): Promise { return meApi.getStars() } -async function getMyNamespaces(): Promise { - const page = await fetchJson>(`${WEB_API_PREFIX}/namespaces`) - return page.items +async function getMyNamespaces(): Promise { + return namespaceApi.listMine() } async function getNamespaceDetail(slug: string): Promise { - const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug - return fetchJson(`${WEB_API_PREFIX}/namespaces/${cleanSlug}`) + return namespaceApi.getDetail(slug) } async function getNamespaceMembers(slug: string): Promise { @@ -173,6 +171,13 @@ export function useNamespaceMembers(slug: string) { }) } +function invalidateNamespaceQueries(queryClient: ReturnType, slug: string) { + queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['namespaces', slug] }) + queryClient.invalidateQueries({ queryKey: ['namespaces', slug, 'members'] }) + queryClient.invalidateQueries({ queryKey: ['reviews'] }) +} + export function usePublishSkill() { const queryClient = useQueryClient() @@ -261,3 +266,47 @@ export function useRereleaseSkillVersion() { }, }) } + +export function useFreezeNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ slug }: { slug: string }) => namespaceApi.freeze(slug), + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + }, + }) +} + +export function useUnfreezeNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ slug }: { slug: string }) => namespaceApi.unfreeze(slug), + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + }, + }) +} + +export function useArchiveNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ slug, reason }: { slug: string; reason?: string }) => namespaceApi.archive(slug, reason), + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + }, + }) +} + +export function useRestoreNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ slug }: { slug: string }) => namespaceApi.restore(slug), + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + }, + }) +}