mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-10 03:27:54 +00:00
fix(web): separate skill and suite discovery
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
859987e3bb
commit
a838078cd9
8 changed files with 257 additions and 228 deletions
|
|
@ -228,12 +228,11 @@ const searchRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'search',
|
||||
component: SearchPage,
|
||||
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; resourceType?: 'SKILL' | 'SUITE'; sort: string; page: number; starredOnly: boolean } => {
|
||||
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => {
|
||||
return {
|
||||
q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''),
|
||||
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined,
|
||||
label: typeof search.label === 'string' && search.label ? search.label : undefined,
|
||||
resourceType: search.resourceType === 'SKILL' || search.resourceType === 'SUITE' ? search.resourceType : undefined,
|
||||
sort: (search.sort as string) || 'newest',
|
||||
page: Number(search.page) || 0,
|
||||
starredOnly: search.starredOnly === true || search.starredOnly === 'true',
|
||||
|
|
|
|||
|
|
@ -1763,6 +1763,8 @@
|
|||
"available": "Available",
|
||||
"degraded": "Degraded",
|
||||
"installCommand": "Install command",
|
||||
"copyInstallCommand": "Copy install command",
|
||||
"detailsSidebar": "Suite information and actions",
|
||||
"commandCopied": "Install command copied",
|
||||
"degradedDescription": "A member or its access changed. Restore every member before installing this suite.",
|
||||
"membersTitle": "Included skills ({{count}})",
|
||||
|
|
|
|||
|
|
@ -1760,6 +1760,8 @@
|
|||
"available": "Доступно",
|
||||
"degraded": "Ограничено",
|
||||
"installCommand": "Команда установки",
|
||||
"copyInstallCommand": "Копировать команду установки",
|
||||
"detailsSidebar": "Информация о наборе и действия",
|
||||
"commandCopied": "Команда установки скопирована",
|
||||
"degradedDescription": "Участник или права доступа изменились. Восстановите всех участников перед установкой.",
|
||||
"membersTitle": "Включённые навыки ({{count}})",
|
||||
|
|
|
|||
|
|
@ -1762,6 +1762,8 @@
|
|||
"available": "可用",
|
||||
"degraded": "已降级",
|
||||
"installCommand": "安装命令",
|
||||
"copyInstallCommand": "复制安装命令",
|
||||
"detailsSidebar": "套件信息与操作",
|
||||
"commandCopied": "已复制安装命令",
|
||||
"degradedDescription": "成员失效或权限发生变化,恢复所有成员后才能安装。",
|
||||
"membersTitle": "包含的技能({{count}})",
|
||||
|
|
|
|||
|
|
@ -46,10 +46,6 @@ vi.mock('@/features/skill/skill-card', () => ({
|
|||
SkillCard: () => <div>skill-card</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/suite/resource-card', () => ({
|
||||
ResourceCard: () => <div>resource-card</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/skeleton-loader', () => ({
|
||||
SkeletonList: () => <div>skeleton</div>,
|
||||
}))
|
||||
|
|
@ -92,7 +88,6 @@ vi.mock('@/app/page-shell-style', () => ({
|
|||
}))
|
||||
|
||||
const useSearchSkillsMock = vi.fn()
|
||||
const useResourceSearchMock = vi.fn()
|
||||
|
||||
vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
||||
useSearchSkills: (params: Record<string, unknown>) => {
|
||||
|
|
@ -101,10 +96,6 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-suite-queries', () => ({
|
||||
useResourceSearch: () => useResourceSearchMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-label-queries', () => ({
|
||||
useVisibleLabels: () => ({
|
||||
data: [
|
||||
|
|
@ -157,11 +148,6 @@ describe('SearchPage', () => {
|
|||
isLoading: false,
|
||||
isFetching: false,
|
||||
})
|
||||
useResourceSearchMock.mockReturnValue({
|
||||
data: { items: [], total: 0, page: 0, size: 12 },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks the selected label button as active on initial render', () => {
|
||||
|
|
@ -284,9 +270,9 @@ describe('SearchPage', () => {
|
|||
page: 0,
|
||||
starredOnly: false,
|
||||
})
|
||||
useResourceSearchMock.mockReturnValue({
|
||||
useSearchSkillsMock.mockReturnValue({
|
||||
data: {
|
||||
items: [{ resourceType: 'SKILL', detailUrl: '/space/global/demo', id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', version: '1.0.0', visibility: 'PUBLIC', installCount: 1, available: true, updatedAt: '2026-03-20T00:00:00Z' }],
|
||||
items: [{ id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', downloadCount: 1, starCount: 1, ratingCount: 0, updatedAt: '2026-03-20T00:00:00Z', canSubmitPromotion: false }],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 12,
|
||||
|
|
@ -297,7 +283,8 @@ describe('SearchPage', () => {
|
|||
|
||||
const html = renderToStaticMarkup(<SearchPage />)
|
||||
|
||||
expect(html).toContain('resource-card')
|
||||
expect(html).toContain('skill-card')
|
||||
expect(html).not.toContain('suite.resourceTypeSuite')
|
||||
expect(html).not.toContain('empty-state')
|
||||
})
|
||||
|
||||
|
|
@ -309,7 +296,7 @@ describe('SearchPage', () => {
|
|||
page: 0,
|
||||
starredOnly: false,
|
||||
})
|
||||
useResourceSearchMock.mockReturnValue({
|
||||
useSearchSkillsMock.mockReturnValue({
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
|
|
@ -326,4 +313,20 @@ describe('SearchPage', () => {
|
|||
expect(html).toContain('search.noResults')
|
||||
expect(html).not.toContain('search.enterKeyword')
|
||||
})
|
||||
|
||||
it('keeps search skill-only when an obsolete Suite type query parameter is present', () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
q: 'workflow',
|
||||
resourceType: 'SUITE',
|
||||
sort: 'newest',
|
||||
page: 0,
|
||||
starredOnly: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<SearchPage />)
|
||||
|
||||
expect(searchSkillParams[0]).not.toHaveProperty('resourceType')
|
||||
expect(html).toContain('skill-card')
|
||||
expect(html).not.toContain('suite.resourceTypeSuite')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,16 +2,14 @@ import { startTransition, useEffect, useRef, useState } from 'react'
|
|||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { ResourceType, SkillSummary } from '@/api/types'
|
||||
import type { SkillSummary } from '@/api/types'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { SearchBar } from '@/features/search/search-bar'
|
||||
import { SkillCard } from '@/features/skill/skill-card'
|
||||
import { ResourceCard } from '@/features/suite/resource-card'
|
||||
import { SkeletonList } from '@/shared/components/skeleton-loader'
|
||||
import { EmptyState } from '@/shared/components/empty-state'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
|
||||
import { useResourceSearch } from '@/shared/hooks/use-suite-queries'
|
||||
import { useVisibleLabels } from '@/shared/hooks/use-label-queries'
|
||||
import { useMyStars } from '@/shared/hooks/use-user-queries'
|
||||
import { toRouterPath } from '@/shared/lib/base-path'
|
||||
|
|
@ -96,7 +94,6 @@ export function SearchPage() {
|
|||
const q = normalizeSearchQuery(searchParams.q || '')
|
||||
const namespace = (searchParams.namespace || '').replace(/^@/, '')
|
||||
const selectedLabel = searchParams.label || ''
|
||||
const resourceType = searchParams.resourceType
|
||||
const sort = searchParams.sort || 'newest'
|
||||
const page = searchParams.page ?? 0
|
||||
const starredOnly = searchParams.starredOnly ?? false
|
||||
|
|
@ -121,7 +118,6 @@ export function SearchPage() {
|
|||
previousPageRef.current = page
|
||||
}, [page])
|
||||
|
||||
const useLegacySkillSearch = starredOnly || Boolean(selectedLabel)
|
||||
const { data, isLoading, isFetching } = useSearchSkills({
|
||||
q,
|
||||
namespace: namespace || undefined,
|
||||
|
|
@ -130,19 +126,7 @@ export function SearchPage() {
|
|||
page,
|
||||
size: PAGE_SIZE,
|
||||
starredOnly,
|
||||
}, useLegacySkillSearch)
|
||||
const {
|
||||
data: resourceData,
|
||||
isLoading: isLoadingResources,
|
||||
isFetching: isFetchingResources,
|
||||
} = useResourceSearch({
|
||||
q,
|
||||
namespace: namespace || undefined,
|
||||
resourceType,
|
||||
sort,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
}, !useLegacySkillSearch)
|
||||
}, !starredOnly)
|
||||
const { data: labels } = useVisibleLabels()
|
||||
const {
|
||||
data: starredSkills,
|
||||
|
|
@ -159,44 +143,44 @@ export function SearchPage() {
|
|||
|
||||
if (!parsedInput.query && !parsedInput.namespace) {
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, resourceType, sort, page: 0, starredOnly }, replace: page === 0 })
|
||||
navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, resourceType, sort, page: 0, starredOnly }, replace: true })
|
||||
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
|
||||
})
|
||||
}, 250)
|
||||
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [navigate, namespace, page, q, queryInput, resourceType, selectedLabel, sort, starredOnly])
|
||||
}, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly])
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
const parsedInput = parseNamespaceSearchInput(query)
|
||||
setQueryInput(query)
|
||||
startTransition(() => {
|
||||
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, resourceType, sort, page: 0, starredOnly }, replace: true })
|
||||
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSortChange = (newSort: string) => {
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, resourceType, sort: newSort, page: 0, starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } })
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
blurActiveElement()
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, resourceType, sort, page: newPage, starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: newPage, starredOnly } })
|
||||
}
|
||||
|
||||
const handleLabelToggle = (label: string) => {
|
||||
const nextLabel = selectedLabel === label ? '' : label
|
||||
navigate({ to: '/search', search: { q, namespace, label: nextLabel, resourceType: undefined, sort, page: 0, starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace, label: nextLabel, sort, page: 0, starredOnly } })
|
||||
}
|
||||
|
||||
const handleNamespaceClear = () => {
|
||||
navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, resourceType, sort, page: 0, starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } })
|
||||
}
|
||||
|
||||
const handleStarredToggle = () => {
|
||||
|
|
@ -210,7 +194,7 @@ export function SearchPage() {
|
|||
return
|
||||
}
|
||||
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, resourceType: undefined, sort, page: 0, starredOnly: !starredOnly } })
|
||||
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } })
|
||||
}
|
||||
|
||||
const handleSkillClick = (namespace: string, slug: string) => {
|
||||
|
|
@ -220,13 +204,6 @@ export function SearchPage() {
|
|||
})
|
||||
}
|
||||
|
||||
const handleResourceTypeChange = (nextType?: ResourceType) => {
|
||||
navigate({
|
||||
to: '/search',
|
||||
search: { q, namespace, label: '', resourceType: nextType, sort, page: 0, starredOnly: false },
|
||||
})
|
||||
}
|
||||
|
||||
const filteredStarredSkills = starredOnly
|
||||
? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q, namespace), sort)
|
||||
: []
|
||||
|
|
@ -235,26 +212,19 @@ export function SearchPage() {
|
|||
: []
|
||||
const totalPages = starredOnly
|
||||
? Math.ceil(filteredStarredSkills.length / PAGE_SIZE)
|
||||
: useLegacySkillSearch && data
|
||||
: data
|
||||
? Math.ceil(data.total / data.size)
|
||||
: resourceData
|
||||
? Math.ceil(resourceData.total / resourceData.size)
|
||||
: 0
|
||||
: 0
|
||||
const displayItems = starredOnly
|
||||
? starredPageItems
|
||||
: useLegacySkillSearch
|
||||
? (data?.items ?? [])
|
||||
: []
|
||||
const resourceItems = useLegacySkillSearch ? [] : (resourceData?.items ?? [])
|
||||
const isPageLoading = starredOnly ? isLoadingStarred : useLegacySkillSearch ? isLoading : isLoadingResources
|
||||
: (data?.items ?? [])
|
||||
const isPageLoading = starredOnly ? isLoadingStarred : isLoading
|
||||
const isUpdatingResults = starredOnly
|
||||
? isFetchingStarred && !isLoadingStarred
|
||||
: useLegacySkillSearch
|
||||
? isFetching && !isLoading
|
||||
: isFetchingResources && !isLoadingResources
|
||||
: isFetching && !isLoading
|
||||
const resultCount = starredOnly
|
||||
? filteredStarredSkills.length
|
||||
: useLegacySkillSearch ? (data?.total ?? 0) : (resourceData?.total ?? 0)
|
||||
: (data?.total ?? 0)
|
||||
|
||||
return (
|
||||
<div className={APP_SHELL_PAGE_CLASS_NAME}>
|
||||
|
|
@ -313,10 +283,6 @@ export function SearchPage() {
|
|||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-medium text-muted-foreground">{t('suite.resourceType')}</span>
|
||||
<Button variant={!resourceType && !useLegacySkillSearch ? 'default' : 'outline'} size="sm" onClick={() => handleResourceTypeChange(undefined)}>{t('suite.allTypes')}</Button>
|
||||
<Button variant={resourceType === 'SKILL' ? 'default' : 'outline'} size="sm" onClick={() => handleResourceTypeChange('SKILL')}>{t('suite.resourceTypeSkill')}</Button>
|
||||
<Button variant={resourceType === 'SUITE' ? 'default' : 'outline'} size="sm" onClick={() => handleResourceTypeChange('SUITE')}>{t('suite.resourceTypeSuite')}</Button>
|
||||
<span className="shrink-0 text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
|
||||
<Button
|
||||
variant={starredOnly ? 'default' : 'outline'}
|
||||
|
|
@ -350,7 +316,7 @@ export function SearchPage() {
|
|||
{/* Results */}
|
||||
{isPageLoading ? (
|
||||
<SkeletonList count={PAGE_SIZE} />
|
||||
) : displayItems.length > 0 || resourceItems.length > 0 ? (
|
||||
) : displayItems.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{displayItems.map((skill, idx) => (
|
||||
|
|
@ -362,16 +328,6 @@ export function SearchPage() {
|
|||
/>
|
||||
</div>
|
||||
))}
|
||||
{resourceItems.map((resource, idx) => (
|
||||
<div key={`${resource.resourceType}-${resource.id}`} className={`h-full animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
|
||||
<ResourceCard
|
||||
resource={resource}
|
||||
onClick={() => resource.resourceType === 'SUITE'
|
||||
? navigate({ to: `/suite/${resource.namespace}/${encodeURIComponent(resource.slug)}` })
|
||||
: handleSkillClick(resource.namespace, resource.slug)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillSuite } from '@/api/types'
|
||||
|
|
@ -119,4 +119,15 @@ describe('SuiteDetailPage', () => {
|
|||
expect(deletedLabels.every((label) => label.closest('a') === null)).toBe(true)
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('places Suite metadata and installation in the detail sidebar', () => {
|
||||
mocks.detail = { data: suite(), isLoading: false, error: null }
|
||||
|
||||
render(<SuiteDetailPage />)
|
||||
|
||||
const sidebar = screen.getByRole('complementary', { name: 'suite.detailsSidebar' })
|
||||
expect(within(sidebar).getByText('v1.0.0')).not.toBeNull()
|
||||
expect(within(sidebar).getByText('suite.installCommand')).not.toBeNull()
|
||||
expect(within(sidebar).getByLabelText('suite.copyInstallCommand')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle, ArrowUpRight, Boxes, CheckCircle2, Copy, Wrench } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowUpRight, Boxes, CheckCircle2, Copy, Terminal, Wrench } from 'lucide-react'
|
||||
import { useSuiteDetail, useSuiteVersions, useSubmitSuite } from '@/shared/hooks/use-suite-queries'
|
||||
import { suiteBlockingReasonLabel, suiteStatusLabel, suiteVisibilityLabel } from '@/features/suite/suite-labels'
|
||||
import { SuiteManagementActions } from '@/features/suite/suite-management-actions'
|
||||
|
|
@ -10,7 +10,9 @@ import { Card } from '@/shared/ui/card'
|
|||
import { Button } from '@/shared/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { SkeletonList } from '@/shared/components/skeleton-loader'
|
||||
import { NamespaceBadge } from '@/shared/components/namespace-badge'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
|
||||
|
||||
export function SuiteDetailPage() {
|
||||
|
|
@ -48,161 +50,213 @@ export function SuiteDetailPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const hasPrimaryActions = suite.allowedActions.includes('EDIT')
|
||||
|| suite.allowedActions.includes('SUBMIT')
|
||||
|| suite.allowedActions.includes('PUBLISH_PRIVATE')
|
||||
|
||||
return (
|
||||
<div className={APP_SHELL_PAGE_CLASS_NAME}>
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-start">
|
||||
<div>
|
||||
<div className="mb-2 inline-flex items-center gap-2 text-sm font-medium text-primary">
|
||||
<Boxes className="h-4 w-4" /> @{suite.namespace}/{suite.slug}
|
||||
<div className={cn(APP_SHELL_PAGE_CLASS_NAME, 'mx-auto max-w-6xl')}>
|
||||
<div className="flex flex-col gap-8 lg:flex-row">
|
||||
<div className="min-w-0 flex-1 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<NamespaceBadge type={suite.namespace === 'global' ? 'GLOBAL' : 'TEAM'} name={suite.namespace} />
|
||||
<span className="badge-soft badge-soft-blue inline-flex items-center gap-1.5">
|
||||
<Boxes className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t('suite.resourceTypeSuite')}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">
|
||||
{suite.displayName}
|
||||
</h1>
|
||||
<p className="font-mono text-sm text-muted-foreground">@{suite.namespace}/{suite.slug}</p>
|
||||
<p className="text-lg leading-relaxed text-muted-foreground">
|
||||
{suite.summary || t('suite.noSummary')}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold">{suite.displayName}</h1>
|
||||
<p className="mt-3 max-w-3xl text-muted-foreground">{suite.summary || t('suite.noSummary')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suite.allowedActions.includes('EDIT') ? (
|
||||
<Button variant="outline" onClick={() => navigate({
|
||||
to: `/dashboard/suites/${suite.namespace}/${encodeURIComponent(suite.slug)}/edit`,
|
||||
search: { version: suite.version },
|
||||
})}>{t('suite.editDraft')}</Button>
|
||||
|
||||
{!suite.available ? (
|
||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{t('suite.degraded')}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('suite.degradedDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{suite.allowedActions.includes('SUBMIT') || suite.allowedActions.includes('PUBLISH_PRIVATE') ? (
|
||||
<Button disabled={submitMutation.isPending} onClick={handleSubmit}>
|
||||
{suite.visibility === 'PRIVATE' ? t('suite.publishDirectly') : t('suite.submitReview')}
|
||||
</Button>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{t('suite.overviewTab')}</TabsTrigger>
|
||||
<TabsTrigger value="members">{t('suite.membersTab', { count: suite.members.length })}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-6">
|
||||
<Card className="p-8">
|
||||
{suite.overview ? (
|
||||
<MarkdownRenderer content={suite.overview} />
|
||||
) : (
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
{suite.summary || t('suite.noOverview')}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="members" className="mt-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{suite.members.map((member) => {
|
||||
const memberName = member.displayName || `@${member.namespace}/${member.slug}`
|
||||
const content = (
|
||||
<Card className="h-full p-5 transition-colors hover:border-primary/40">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-2 font-semibold">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="truncate">{memberName}</span>
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
@{member.namespace}/{member.slug}
|
||||
</p>
|
||||
</div>
|
||||
{member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? (
|
||||
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
{member.summary ? (
|
||||
<p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">{member.summary}</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-mono">
|
||||
{t('suite.pinnedVersion', { version: member.version })}
|
||||
</span>
|
||||
{member.entry ? (
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-1 font-medium text-primary">
|
||||
{t('suite.entrySkill')}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={member.blockingReason ? 'text-destructive' : 'text-emerald-600'}>
|
||||
{member.blockingReason
|
||||
? suiteBlockingReasonLabel(t, member.blockingReason)
|
||||
: t('suite.available')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? (
|
||||
<Link
|
||||
key={`${member.namespace}/${member.slug}@${member.version}`}
|
||||
to="/space/$namespace/$slug"
|
||||
params={{ namespace: member.namespace, slug: member.slug }}
|
||||
search={{ returnTo }}
|
||||
aria-label={t('suite.viewMember', { name: memberName })}
|
||||
className="rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={`${member.namespace}/${member.slug}@${member.version}`}>{content}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
className="w-full flex-shrink-0 space-y-5 lg:w-80"
|
||||
aria-label={t('suite.detailsSidebar')}
|
||||
>
|
||||
{hasPrimaryActions ? (
|
||||
<Card className="space-y-3 p-5">
|
||||
{suite.allowedActions.includes('EDIT') ? (
|
||||
<Button className="w-full" variant="outline" onClick={() => navigate({
|
||||
to: `/dashboard/suites/${suite.namespace}/${encodeURIComponent(suite.slug)}/edit`,
|
||||
search: { version: suite.version },
|
||||
})}>{t('suite.editDraft')}</Button>
|
||||
) : null}
|
||||
{suite.allowedActions.includes('SUBMIT') || suite.allowedActions.includes('PUBLISH_PRIVATE') ? (
|
||||
<Button className="w-full" disabled={submitMutation.isPending} onClick={handleSubmit}>
|
||||
{suite.visibility === 'PRIVATE' ? t('suite.publishDirectly') : t('suite.submitReview')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="p-4"><p className="text-xs text-muted-foreground">{t('suite.version')}</p><p className="mt-1 font-mono font-semibold">v{suite.version}</p></Card>
|
||||
<Card className="p-4"><p className="text-xs text-muted-foreground">{t('suite.status')}</p><p className="mt-1 font-semibold">{suiteStatusLabel(t, suite.status)}</p></Card>
|
||||
<Card className="p-4"><p className="text-xs text-muted-foreground">{t('suite.visibility')}</p><p className="mt-1 font-semibold">{suiteVisibilityLabel(t, suite.visibility)}</p></Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-muted-foreground">{t('suite.installStatus')}</p>
|
||||
<p className={`mt-1 flex items-center gap-1 font-semibold ${suite.available ? 'text-emerald-600' : 'text-destructive'}`}>
|
||||
{suite.available ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
|
||||
{suite.available ? t('suite.available') : t('suite.degraded')}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold">{t('suite.installCommand')}</h2>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg bg-secondary p-3">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto text-sm">{command}</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(command)
|
||||
toast.success(t('suite.commandCopied'))
|
||||
}}
|
||||
><Copy className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
{!suite.available ? <p className="mt-3 text-sm text-destructive">{t('suite.degradedDescription')}</p> : null}
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="overview">{t('suite.overviewTab')}</TabsTrigger>
|
||||
<TabsTrigger value="members">{t('suite.membersTab', { count: suite.members.length })}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-5">
|
||||
<Card className="p-6 md:p-8">
|
||||
<h2 className="text-xl font-semibold">{t('suite.overviewTitle')}</h2>
|
||||
<div className="mt-5">
|
||||
{suite.overview ? (
|
||||
<MarkdownRenderer content={suite.overview} />
|
||||
) : (
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
{suite.summary || t('suite.noOverview')}
|
||||
</p>
|
||||
)}
|
||||
<Card className="space-y-5 p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">{t('suite.version')}</span>
|
||||
<span className="break-all text-right font-mono font-semibold text-foreground">v{suite.version}</span>
|
||||
</div>
|
||||
<div className="h-px bg-border/40" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">{t('suite.status')}</span>
|
||||
<span className="text-right font-semibold text-foreground">{suiteStatusLabel(t, suite.status)}</span>
|
||||
</div>
|
||||
<div className="h-px bg-border/40" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">{t('suite.visibility')}</span>
|
||||
<span className="text-right font-semibold text-foreground">{suiteVisibilityLabel(t, suite.visibility)}</span>
|
||||
</div>
|
||||
<div className="h-px bg-border/40" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">{t('suite.installStatus')}</span>
|
||||
<span className={cn(
|
||||
'flex items-center gap-1 text-right font-semibold',
|
||||
suite.available ? 'text-emerald-600' : 'text-destructive',
|
||||
)}>
|
||||
{suite.available
|
||||
? <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
: <AlertTriangle className="h-4 w-4" aria-hidden="true" />}
|
||||
{suite.available ? t('suite.available') : t('suite.degraded')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="members" className="mt-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{suite.members.map((member) => {
|
||||
const memberName = member.displayName || `@${member.namespace}/${member.slug}`
|
||||
const content = (
|
||||
<Card className="h-full p-5 transition-colors hover:border-primary/40">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-2 font-semibold">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="truncate">{memberName}</span>
|
||||
</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
@{member.namespace}/{member.slug}
|
||||
</p>
|
||||
</div>
|
||||
{member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? (
|
||||
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
{member.summary ? (
|
||||
<p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">{member.summary}</p>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-mono">
|
||||
{t('suite.pinnedVersion', { version: member.version })}
|
||||
</span>
|
||||
{member.entry ? (
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-1 font-medium text-primary">
|
||||
{t('suite.entrySkill')}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={member.blockingReason ? 'text-destructive' : 'text-emerald-600'}>
|
||||
{member.blockingReason
|
||||
? suiteBlockingReasonLabel(t, member.blockingReason)
|
||||
: t('suite.available')}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? (
|
||||
<Link
|
||||
key={`${member.namespace}/${member.slug}@${member.version}`}
|
||||
to="/space/$namespace/$slug"
|
||||
params={{ namespace: member.namespace, slug: member.slug }}
|
||||
search={{ returnTo }}
|
||||
aria-label={t('suite.viewMember', { name: memberName })}
|
||||
className="rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={`${member.namespace}/${member.slug}@${member.version}`}>{content}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{versions?.length ? (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold">{t('suite.versionHistory')}</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{versions.map((item) => (
|
||||
<Card className="space-y-4 p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="text-sm font-semibold font-heading text-foreground">{t('suite.installCommand')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-secondary p-3">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto text-sm">{command}</code>
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={item.version === suite.version ? 'default' : 'outline'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({
|
||||
to: `/suite/${namespace}/${encodeURIComponent(slug)}`,
|
||||
search: { version: item.version },
|
||||
})}
|
||||
>v{item.version} · {suiteStatusLabel(t, item.status)}</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
aria-label={t('suite.copyInstallCommand')}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(command)
|
||||
toast.success(t('suite.commandCopied'))
|
||||
}}
|
||||
><Copy className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<SuiteManagementActions suite={suite} />
|
||||
{versions?.length ? (
|
||||
<Card className="p-5">
|
||||
<h2 className="text-sm font-semibold font-heading text-foreground">{t('suite.versionHistory')}</h2>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{versions.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={item.version === suite.version ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => navigate({
|
||||
to: `/suite/${namespace}/${encodeURIComponent(slug)}`,
|
||||
search: { version: item.version },
|
||||
})}
|
||||
>v{item.version} · {suiteStatusLabel(t, item.status)}</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SuiteManagementActions suite={suite} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue