mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: refine skill market cards and drawer
This commit is contained in:
parent
697b2efdc9
commit
8405314761
@ -2027,6 +2027,9 @@ export const en = {
|
||||
'skillCenter.marketplace.filter.apiKey': 'API key',
|
||||
'skillCenter.marketplace.total': 'Total',
|
||||
'skillCenter.marketplace.loadedCount': '{count} loaded',
|
||||
'skillCenter.marketplace.status.loaded': '{count} loaded',
|
||||
'skillCenter.marketplace.status.safe': '{safe}/{total} verified',
|
||||
'skillCenter.marketplace.status.installed': '{count} installed',
|
||||
'skillCenter.marketplace.totalHint': 'Current result set',
|
||||
'skillCenter.marketplace.safe': 'Verified',
|
||||
'skillCenter.marketplace.safeHint': 'Clean or trusted signals',
|
||||
@ -2053,6 +2056,10 @@ export const en = {
|
||||
'skillCenter.marketplace.sort.updated': 'Updated',
|
||||
'skillCenter.marketplace.sort.trending': 'Trending',
|
||||
'skillCenter.marketplace.installedBadge': 'Installed',
|
||||
'skillCenter.marketplace.previewAvailable': 'Preview',
|
||||
'skillCenter.marketplace.noPreviewBadge': 'No preview',
|
||||
'skillCenter.marketplace.cardMeta.downloads': '{count} downloads',
|
||||
'skillCenter.marketplace.cardMeta.stars': '{count} stars',
|
||||
'skillCenter.marketplace.trust.clean': 'Clean',
|
||||
'skillCenter.marketplace.trust.benign': 'Benign',
|
||||
'skillCenter.marketplace.trust.signed': 'Signed',
|
||||
@ -2074,6 +2081,12 @@ export const en = {
|
||||
'skillCenter.marketplace.risk.requires-api-key': 'API key',
|
||||
'skillCenter.marketplace.entryPreview': 'Entry preview',
|
||||
'skillCenter.marketplace.filePreview': 'File preview',
|
||||
'skillCenter.marketplace.drawer.summary': 'Summary',
|
||||
'skillCenter.marketplace.drawer.decision': 'Install decision',
|
||||
'skillCenter.marketplace.drawer.safety': 'Safety',
|
||||
'skillCenter.marketplace.drawer.metadata': 'Metadata',
|
||||
'skillCenter.marketplace.drawer.links': 'Links',
|
||||
'skillCenter.marketplace.loadingCard': 'Loading marketplace skills',
|
||||
'skillCenter.marketplace.previewTruncated': 'Truncated',
|
||||
'skillCenter.marketplace.previewUnavailable': 'Preview unavailable',
|
||||
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub does not expose a safe raw file preview endpoint yet.',
|
||||
|
||||
@ -2029,6 +2029,9 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.filter.apiKey': 'API キー',
|
||||
'skillCenter.marketplace.total': '合計',
|
||||
'skillCenter.marketplace.loadedCount': '{count} 件読み込み済み',
|
||||
'skillCenter.marketplace.status.loaded': '{count} 件読み込み済み',
|
||||
'skillCenter.marketplace.status.safe': '{safe}/{total} 検証済み',
|
||||
'skillCenter.marketplace.status.installed': '{count} 件インストール済み',
|
||||
'skillCenter.marketplace.totalHint': '現在の結果',
|
||||
'skillCenter.marketplace.safe': '信頼済み',
|
||||
'skillCenter.marketplace.safeHint': '安全または信頼シグナル',
|
||||
@ -2055,6 +2058,10 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.sort.updated': '更新日時',
|
||||
'skillCenter.marketplace.sort.trending': '人気',
|
||||
'skillCenter.marketplace.installedBadge': 'インストール済み',
|
||||
'skillCenter.marketplace.previewAvailable': 'プレビュー',
|
||||
'skillCenter.marketplace.noPreviewBadge': 'プレビューなし',
|
||||
'skillCenter.marketplace.cardMeta.downloads': '{count} ダウンロード',
|
||||
'skillCenter.marketplace.cardMeta.stars': '{count} スター',
|
||||
'skillCenter.marketplace.trust.clean': '安全',
|
||||
'skillCenter.marketplace.trust.benign': '低リスク',
|
||||
'skillCenter.marketplace.trust.signed': '署名済み',
|
||||
@ -2076,6 +2083,12 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.risk.requires-api-key': 'API キー',
|
||||
'skillCenter.marketplace.entryPreview': 'エントリプレビュー',
|
||||
'skillCenter.marketplace.filePreview': 'ファイルプレビュー',
|
||||
'skillCenter.marketplace.drawer.summary': '概要',
|
||||
'skillCenter.marketplace.drawer.decision': 'インストール判断',
|
||||
'skillCenter.marketplace.drawer.safety': '安全',
|
||||
'skillCenter.marketplace.drawer.metadata': 'メタデータ',
|
||||
'skillCenter.marketplace.drawer.links': 'リンク',
|
||||
'skillCenter.marketplace.loadingCard': 'マーケットスキルを読み込み中',
|
||||
'skillCenter.marketplace.previewTruncated': '切り詰め済み',
|
||||
'skillCenter.marketplace.previewUnavailable': 'プレビュー不可',
|
||||
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub はまだ安全な raw ファイルプレビュー API を提供していません。',
|
||||
|
||||
@ -2029,6 +2029,9 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.filter.apiKey': 'API 키',
|
||||
'skillCenter.marketplace.total': '전체',
|
||||
'skillCenter.marketplace.loadedCount': '{count}개 로드됨',
|
||||
'skillCenter.marketplace.status.loaded': '{count}개 로드됨',
|
||||
'skillCenter.marketplace.status.safe': '{safe}/{total} 검증됨',
|
||||
'skillCenter.marketplace.status.installed': '{count}개 설치됨',
|
||||
'skillCenter.marketplace.totalHint': '현재 결과',
|
||||
'skillCenter.marketplace.safe': '신뢰됨',
|
||||
'skillCenter.marketplace.safeHint': '안전 또는 신뢰 신호',
|
||||
@ -2055,6 +2058,10 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.sort.updated': '업데이트',
|
||||
'skillCenter.marketplace.sort.trending': '인기',
|
||||
'skillCenter.marketplace.installedBadge': '설치됨',
|
||||
'skillCenter.marketplace.previewAvailable': '미리보기',
|
||||
'skillCenter.marketplace.noPreviewBadge': '미리보기 없음',
|
||||
'skillCenter.marketplace.cardMeta.downloads': '{count} 다운로드',
|
||||
'skillCenter.marketplace.cardMeta.stars': '{count} 스타',
|
||||
'skillCenter.marketplace.trust.clean': '안전',
|
||||
'skillCenter.marketplace.trust.benign': '낮은 위험',
|
||||
'skillCenter.marketplace.trust.signed': '서명됨',
|
||||
@ -2076,6 +2083,12 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.risk.requires-api-key': 'API 키',
|
||||
'skillCenter.marketplace.entryPreview': '엔트리 미리보기',
|
||||
'skillCenter.marketplace.filePreview': '파일 미리보기',
|
||||
'skillCenter.marketplace.drawer.summary': '요약',
|
||||
'skillCenter.marketplace.drawer.decision': '설치 판단',
|
||||
'skillCenter.marketplace.drawer.safety': '안전',
|
||||
'skillCenter.marketplace.drawer.metadata': '메타데이터',
|
||||
'skillCenter.marketplace.drawer.links': '링크',
|
||||
'skillCenter.marketplace.loadingCard': '마켓 스킬 로드 중',
|
||||
'skillCenter.marketplace.previewTruncated': '잘림',
|
||||
'skillCenter.marketplace.previewUnavailable': '미리보기 불가',
|
||||
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub는 아직 안전한 원본 파일 미리보기 API를 제공하지 않습니다.',
|
||||
|
||||
@ -2029,6 +2029,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.filter.apiKey': '需 Key',
|
||||
'skillCenter.marketplace.total': '總數',
|
||||
'skillCenter.marketplace.loadedCount': '已載入 {count} 個',
|
||||
'skillCenter.marketplace.status.loaded': '已載入 {count} 個',
|
||||
'skillCenter.marketplace.status.safe': '{safe}/{total} 已驗證',
|
||||
'skillCenter.marketplace.status.installed': '已安裝 {count} 個',
|
||||
'skillCenter.marketplace.totalHint': '目前結果集',
|
||||
'skillCenter.marketplace.safe': '可信',
|
||||
'skillCenter.marketplace.safeHint': '安全或可信訊號',
|
||||
@ -2055,6 +2058,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.sort.updated': '最近更新',
|
||||
'skillCenter.marketplace.sort.trending': '熱門',
|
||||
'skillCenter.marketplace.installedBadge': '已安裝',
|
||||
'skillCenter.marketplace.previewAvailable': '可預覽',
|
||||
'skillCenter.marketplace.noPreviewBadge': '無預覽',
|
||||
'skillCenter.marketplace.cardMeta.downloads': '{count} 次下載',
|
||||
'skillCenter.marketplace.cardMeta.stars': '{count} 星標',
|
||||
'skillCenter.marketplace.trust.clean': '安全',
|
||||
'skillCenter.marketplace.trust.benign': '低風險',
|
||||
'skillCenter.marketplace.trust.signed': '已簽名',
|
||||
@ -2076,6 +2083,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.risk.requires-api-key': 'API Key',
|
||||
'skillCenter.marketplace.entryPreview': '入口預覽',
|
||||
'skillCenter.marketplace.filePreview': '檔案預覽',
|
||||
'skillCenter.marketplace.drawer.summary': '摘要',
|
||||
'skillCenter.marketplace.drawer.decision': '安裝決策',
|
||||
'skillCenter.marketplace.drawer.safety': '安全',
|
||||
'skillCenter.marketplace.drawer.metadata': '中繼資料',
|
||||
'skillCenter.marketplace.drawer.links': '連結',
|
||||
'skillCenter.marketplace.loadingCard': '正在載入市場技能',
|
||||
'skillCenter.marketplace.previewTruncated': '已截斷',
|
||||
'skillCenter.marketplace.previewUnavailable': '暫時無法預覽',
|
||||
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub 目前尚未提供安全的原始檔案預覽介面。',
|
||||
|
||||
@ -2029,6 +2029,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.filter.apiKey': '需 Key',
|
||||
'skillCenter.marketplace.total': '总数',
|
||||
'skillCenter.marketplace.loadedCount': '已加载 {count} 个',
|
||||
'skillCenter.marketplace.status.loaded': '已加载 {count} 个',
|
||||
'skillCenter.marketplace.status.safe': '{safe}/{total} 已验证',
|
||||
'skillCenter.marketplace.status.installed': '已安装 {count} 个',
|
||||
'skillCenter.marketplace.totalHint': '当前结果集',
|
||||
'skillCenter.marketplace.safe': '可信',
|
||||
'skillCenter.marketplace.safeHint': '安全或可信信号',
|
||||
@ -2055,6 +2058,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.sort.updated': '最近更新',
|
||||
'skillCenter.marketplace.sort.trending': '热门',
|
||||
'skillCenter.marketplace.installedBadge': '已安装',
|
||||
'skillCenter.marketplace.previewAvailable': '可预览',
|
||||
'skillCenter.marketplace.noPreviewBadge': '无预览',
|
||||
'skillCenter.marketplace.cardMeta.downloads': '{count} 次下载',
|
||||
'skillCenter.marketplace.cardMeta.stars': '{count} 星标',
|
||||
'skillCenter.marketplace.trust.clean': '安全',
|
||||
'skillCenter.marketplace.trust.benign': '低风险',
|
||||
'skillCenter.marketplace.trust.signed': '已签名',
|
||||
@ -2076,6 +2083,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'skillCenter.marketplace.risk.requires-api-key': 'API Key',
|
||||
'skillCenter.marketplace.entryPreview': '入口预览',
|
||||
'skillCenter.marketplace.filePreview': '文件预览',
|
||||
'skillCenter.marketplace.drawer.summary': '摘要',
|
||||
'skillCenter.marketplace.drawer.decision': '安装决策',
|
||||
'skillCenter.marketplace.drawer.safety': '安全',
|
||||
'skillCenter.marketplace.drawer.metadata': '元数据',
|
||||
'skillCenter.marketplace.drawer.links': '链接',
|
||||
'skillCenter.marketplace.loadingCard': '正在加载市场技能',
|
||||
'skillCenter.marketplace.previewTruncated': '已截断',
|
||||
'skillCenter.marketplace.previewUnavailable': '暂时无法预览',
|
||||
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub 目前还没有提供安全的原始文件预览接口。',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { skillMarketApi } from '../api/skillMarket'
|
||||
@ -185,6 +185,93 @@ describe('SkillCenter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders marketplace cards with trust, preview, install, API key, and popularity signals', async () => {
|
||||
mockedSkillMarketApi.list.mockResolvedValue({
|
||||
items: [
|
||||
makeItem({
|
||||
slug: 'skill-vetter',
|
||||
displayName: 'Skill Vetter',
|
||||
summary: 'Security-first skill vetting.',
|
||||
downloads: 260960,
|
||||
trustState: 'clean',
|
||||
installed: false,
|
||||
}),
|
||||
makeItem({
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Fetch current weather through a command-line workflow.',
|
||||
downloads: 18420,
|
||||
trustState: 'clean',
|
||||
installed: true,
|
||||
requiresApiKey: false,
|
||||
}),
|
||||
makeItem({
|
||||
slug: 'crm-sync',
|
||||
displayName: 'CRM Sync',
|
||||
summary: 'Connects to an external CRM API.',
|
||||
downloads: 7420,
|
||||
trustState: 'warning',
|
||||
requiresApiKey: true,
|
||||
}),
|
||||
],
|
||||
nextCursor: null,
|
||||
source: 'clawhub',
|
||||
sourceStatus: 'ok',
|
||||
})
|
||||
|
||||
render(<SkillCenter />)
|
||||
|
||||
const market = screen.getByTestId('skill-marketplace-tab')
|
||||
const vetterCard = await within(market).findByRole('button', { name: 'Skill Vetter' })
|
||||
const weatherCard = within(market).getByRole('button', { name: 'Weather' })
|
||||
const crmCard = within(market).getByRole('button', { name: 'CRM Sync' })
|
||||
expect(vetterCard).toBeInTheDocument()
|
||||
expect(within(market).getAllByText('Clean').length).toBeGreaterThan(0)
|
||||
expect(within(market).getAllByText('Preview').length).toBeGreaterThan(0)
|
||||
expect(within(weatherCard).getByText('Installed')).toBeInTheDocument()
|
||||
expect(within(crmCard).getByText('API key')).toBeInTheDocument()
|
||||
expect(within(vetterCard).getByText(/260\.9K|260,960/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses marketplace-shaped loading skeletons while the catalog loads', () => {
|
||||
mockedSkillMarketApi.list.mockReturnValue(new Promise(() => {}))
|
||||
act(() => {
|
||||
useSkillMarketStore.setState({
|
||||
isLoading: true,
|
||||
items: [],
|
||||
})
|
||||
})
|
||||
|
||||
render(<SkillCenter />)
|
||||
|
||||
expect(screen.getByTestId('skill-marketplace-loading')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skill-card-skeleton')).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('places install decision, blocking reason, and file preview in the drawer decision area', async () => {
|
||||
mockedSkillMarketApi.detail.mockResolvedValue({
|
||||
detail: makeDetail({
|
||||
installEligibility: { status: 'blocked', reason: 'A full package safety scan is required before install.' },
|
||||
trustSummary: 'Scanner found no known malicious content, but the package scan is incomplete.',
|
||||
filePreviews: [{
|
||||
path: 'SKILL.md',
|
||||
content: '# Skill Vetter\n\nSecurity-first skill vetting.',
|
||||
language: 'markdown',
|
||||
size: 512,
|
||||
}],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<SkillCenter />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'PPT Generator' })
|
||||
expect(within(dialog).getByRole('button', { name: 'Blocked' })).toBeDisabled()
|
||||
expect(within(dialog).getByText(/full package safety scan/i)).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('File preview')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('SKILL.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders raw Markdown and Python previews for an uninstalled marketplace skill', async () => {
|
||||
mockedSkillMarketApi.detail.mockResolvedValue({
|
||||
detail: makeDetail({
|
||||
|
||||
@ -105,10 +105,6 @@ export function SkillCenter() {
|
||||
() => items.filter((item) => TRUST_SAFE.includes(item.trustState)).length,
|
||||
[items],
|
||||
)
|
||||
const totalDownloads = useMemo(
|
||||
() => items.reduce((sum, item) => sum + (item.downloads ?? item.installs ?? 0), 0),
|
||||
[items],
|
||||
)
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter((item) => matchesMarketFilter(item, marketFilter)),
|
||||
[items, marketFilter],
|
||||
@ -279,7 +275,6 @@ export function SkillCenter() {
|
||||
total={items.length}
|
||||
safeCount={safeCount}
|
||||
installedCount={installedCount}
|
||||
totalDownloads={totalDownloads}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
@ -298,7 +293,7 @@ export function SkillCenter() {
|
||||
<div className="min-h-0">
|
||||
<div className="min-w-0">
|
||||
{isLoading ? (
|
||||
<LoadingState label={t('skillCenter.marketplace.loading')} />
|
||||
<MarketplaceSkeletonGrid />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Search size={22} aria-hidden="true" />}
|
||||
@ -395,7 +390,6 @@ function MarketplaceStatusLine({
|
||||
total,
|
||||
safeCount,
|
||||
installedCount,
|
||||
totalDownloads,
|
||||
loading,
|
||||
}: {
|
||||
t: TFunction
|
||||
@ -406,7 +400,6 @@ function MarketplaceStatusLine({
|
||||
total: number
|
||||
safeCount: number
|
||||
installedCount: number
|
||||
totalDownloads: number
|
||||
loading: boolean
|
||||
}) {
|
||||
const sourceName = resolvedSource
|
||||
@ -423,10 +416,9 @@ function MarketplaceStatusLine({
|
||||
{sourceName}
|
||||
<span className="text-[var(--color-text-tertiary)]">{statusLabel}</span>
|
||||
</span>
|
||||
<StatusDot label={t('skillCenter.marketplace.loadedCount', { count: String(total) })} />
|
||||
<StatusDot label={`${t('skillCenter.marketplace.safe')} ${safeCount}/${total}`} />
|
||||
<StatusDot label={`${t('skillCenter.marketplace.downloads')} ${compactNumber(totalDownloads)}`} />
|
||||
<StatusDot label={t('skillCenter.marketplace.installedHint', { count: String(installedCount) })} />
|
||||
<StatusDot label={t('skillCenter.marketplace.status.loaded', { count: String(total) })} />
|
||||
<StatusDot label={t('skillCenter.marketplace.status.safe', { safe: String(safeCount), total: String(total) })} />
|
||||
<StatusDot label={t('skillCenter.marketplace.status.installed', { count: String(installedCount) })} />
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--color-text-secondary)]">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
@ -453,6 +445,31 @@ function StatusDot({ label }: { label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StateBadge({
|
||||
tone,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
tone: 'neutral' | 'info' | 'success' | 'warning'
|
||||
icon: ReactNode
|
||||
label: string
|
||||
}) {
|
||||
const className = tone === 'success'
|
||||
? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
|
||||
: tone === 'warning'
|
||||
? 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
|
||||
: tone === 'info'
|
||||
? 'bg-[var(--color-info-container)] text-[var(--color-info)]'
|
||||
: 'border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)]'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${className}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceSwitch({
|
||||
value,
|
||||
onChange,
|
||||
@ -565,6 +582,7 @@ function SkillMarketCard({
|
||||
const stats = formatStats(item, t)
|
||||
const sourceLabel = t(`skillCenter.marketplace.source.${item.source}`)
|
||||
const summary = item.summaryZh || item.summary
|
||||
const hasPreview = Boolean(item.trustSummary || item.summary || item.summaryZh)
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -572,7 +590,7 @@ function SkillMarketCard({
|
||||
aria-label={item.displayName}
|
||||
onClick={onSelect}
|
||||
className={[
|
||||
'group flex min-h-[120px] flex-col rounded-lg border bg-[var(--color-surface)] p-3 text-left transition-colors',
|
||||
'group flex min-h-[124px] flex-col rounded-lg border bg-[var(--color-surface)] p-3 text-left transition-[border-color,background-color,box-shadow,transform] duration-200 ease-out active:translate-y-px',
|
||||
'hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] hover:shadow-sm',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
|
||||
active ? 'border-[var(--color-border-focus)] bg-[var(--color-surface-selected)] shadow-sm' : 'border-[var(--color-border)]',
|
||||
@ -608,19 +626,34 @@ function SkillMarketCard({
|
||||
{summary}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<div className="mt-2 flex min-h-7 flex-wrap gap-1.5">
|
||||
<TrustBadge state={item.trustState} />
|
||||
{item.installed ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-[var(--color-info-container)] px-2 py-1 text-xs text-[var(--color-info)]">
|
||||
<Check size={12} aria-hidden="true" />
|
||||
{t('skillCenter.marketplace.installedBadge')}
|
||||
</span>
|
||||
<StateBadge
|
||||
tone="info"
|
||||
icon={<Check size={12} aria-hidden="true" />}
|
||||
label={t('skillCenter.marketplace.installedBadge')}
|
||||
/>
|
||||
) : null}
|
||||
{hasPreview ? (
|
||||
<StateBadge
|
||||
tone="neutral"
|
||||
icon={<FileText size={12} aria-hidden="true" />}
|
||||
label={t('skillCenter.marketplace.previewAvailable')}
|
||||
/>
|
||||
) : (
|
||||
<StateBadge
|
||||
tone="neutral"
|
||||
icon={<FileText size={12} aria-hidden="true" />}
|
||||
label={t('skillCenter.marketplace.noPreviewBadge')}
|
||||
/>
|
||||
)}
|
||||
{item.requiresApiKey ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2 py-1 text-xs text-[var(--color-text-secondary)]">
|
||||
<Lock size={12} aria-hidden="true" />
|
||||
{t('skillCenter.marketplace.requiresApiKey')}
|
||||
</span>
|
||||
<StateBadge
|
||||
tone="neutral"
|
||||
icon={<Lock size={12} aria-hidden="true" />}
|
||||
label={t('skillCenter.marketplace.requiresApiKey')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -667,7 +700,7 @@ function SkillMarketDetailPanel({
|
||||
aria-label={t('skillCenter.marketplace.selectionTitle')}
|
||||
className="absolute right-0 top-0 flex h-full w-[min(520px,calc(100vw-24px))] flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-dropdown)]"
|
||||
>
|
||||
<LoadingState label={t('skillCenter.marketplace.detailLoading')} />
|
||||
<DetailDrawerSkeleton />
|
||||
</aside>
|
||||
</div>,
|
||||
document.body,
|
||||
@ -781,85 +814,97 @@ function SkillMarketDetailPanel({
|
||||
? t('skillCenter.marketplace.viewInstalled')
|
||||
: installLabel(t, eligibility)}
|
||||
</button>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs">
|
||||
<a
|
||||
href={detail.canonicalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline"
|
||||
>
|
||||
{t('skillCenter.marketplace.openSource')}
|
||||
<ExternalLink size={12} aria-hidden="true" />
|
||||
</a>
|
||||
{detail.upstreamUrl ? (
|
||||
{eligibility.status === 'blocked' || eligibility.status === 'conflict' ? (
|
||||
<div className="mt-3 rounded-md border border-[var(--color-warning)]/30 bg-[var(--color-warning-container)] px-3 py-2 text-xs leading-5 text-[var(--color-warning)]">
|
||||
{eligibilityMessage(t, eligibility)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-5">
|
||||
<DrawerSection title={t('skillCenter.marketplace.drawer.summary')}>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{summary}
|
||||
</p>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection title={t('skillCenter.marketplace.drawer.safety')}>
|
||||
<div className="space-y-3">
|
||||
{detail.trustSummary ? (
|
||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
|
||||
{detail.trustSummary}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail.riskLabels.length > 0 ? (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium text-[var(--color-text-secondary)]">
|
||||
{t('skillCenter.marketplace.riskLabels')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{detail.riskLabels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-md bg-[var(--color-warning-container)] px-2 py-1 text-xs text-[var(--color-warning)]"
|
||||
>
|
||||
{t(`skillCenter.marketplace.risk.${label}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-md bg-[var(--color-success-container)] px-3 py-2 text-xs text-[var(--color-success)]">
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
{t('skillCenter.marketplace.noRiskLabels')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.requiresApiKey ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
{t('skillCenter.marketplace.requiresApiKey')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection title={t('skillCenter.marketplace.filePreview')}>
|
||||
<FilePreviewSection detail={detail} />
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection title={t('skillCenter.marketplace.drawer.metadata')}>
|
||||
<dl className="grid grid-cols-2 gap-2 text-xs">
|
||||
<MetaItem icon={<FileText size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.files')} value={String(detail.files.length)} />
|
||||
<MetaItem icon={<Download size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.downloads')} value={formatNumber(detail.downloads)} />
|
||||
<MetaItem icon={<Star size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.stars')} value={formatNumber(detail.stars)} />
|
||||
<MetaItem icon={<Clock3 size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.license')} value={detail.license || '-'} />
|
||||
</dl>
|
||||
</DrawerSection>
|
||||
|
||||
<DrawerSection title={t('skillCenter.marketplace.drawer.links')}>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<a
|
||||
href={detail.upstreamUrl}
|
||||
href={detail.canonicalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline"
|
||||
>
|
||||
{t('skillCenter.marketplace.openUpstream')}
|
||||
{t('skillCenter.marketplace.openSource')}
|
||||
<ExternalLink size={12} aria-hidden="true" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{summary}
|
||||
</p>
|
||||
|
||||
{detail.trustSummary ? (
|
||||
<div className="mt-4 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
|
||||
{detail.trustSummary}
|
||||
{detail.upstreamUrl ? (
|
||||
<a
|
||||
href={detail.upstreamUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline"
|
||||
>
|
||||
{t('skillCenter.marketplace.openUpstream')}
|
||||
<ExternalLink size={12} aria-hidden="true" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{eligibility.status === 'blocked' || eligibility.status === 'conflict' ? (
|
||||
<div className="mt-4 rounded-md border border-[var(--color-warning)]/30 bg-[var(--color-warning-container)] px-3 py-2 text-xs leading-5 text-[var(--color-warning)]">
|
||||
{eligibilityMessage(t, eligibility)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-2 text-xs">
|
||||
<MetaItem icon={<FileText size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.files')} value={String(detail.files.length)} />
|
||||
<MetaItem icon={<Download size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.downloads')} value={formatNumber(detail.downloads)} />
|
||||
<MetaItem icon={<Star size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.stars')} value={formatNumber(detail.stars)} />
|
||||
<MetaItem icon={<Clock3 size={13} aria-hidden="true" />} label={t('skillCenter.marketplace.license')} value={detail.license || '-'} />
|
||||
</dl>
|
||||
|
||||
{detail.riskLabels.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-xs font-medium text-[var(--color-text-secondary)]">
|
||||
{t('skillCenter.marketplace.riskLabels')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{detail.riskLabels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-md bg-[var(--color-warning-container)] px-2 py-1 text-xs text-[var(--color-warning)]"
|
||||
>
|
||||
{t(`skillCenter.marketplace.risk.${label}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-md bg-[var(--color-success-container)] px-3 py-2 text-xs text-[var(--color-success)]">
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
{t('skillCenter.marketplace.noRiskLabels')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.requiresApiKey ? (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
{t('skillCenter.marketplace.requiresApiKey')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FilePreviewSection detail={detail} />
|
||||
</DrawerSection>
|
||||
</div>
|
||||
</aside>
|
||||
</div>,
|
||||
@ -884,7 +929,7 @@ function FilePreviewSection({ detail }: { detail: SkillMarketDetail }) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mt-4 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
|
||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)]">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
{t('skillCenter.marketplace.previewUnavailable')}
|
||||
@ -897,11 +942,7 @@ function FilePreviewSection({ detail }: { detail: SkillMarketDetail }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)]">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
{t('skillCenter.marketplace.filePreview')}
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
{previews.map((preview) => (
|
||||
<section
|
||||
@ -938,6 +979,17 @@ function FilePreviewSection({ detail }: { detail: SkillMarketDetail }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="border-t border-[var(--color-border)] pt-4 first:border-t-0 first:pt-0">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-normal text-[var(--color-text-tertiary)]">
|
||||
{title}
|
||||
</h4>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DecisionHint({
|
||||
icon,
|
||||
title,
|
||||
@ -1048,11 +1100,50 @@ function EligibilityBadge({ eligibility }: { eligibility: SkillMarketInstallElig
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingState({ label }: { label: string }) {
|
||||
function MarketplaceSkeletonGrid() {
|
||||
const t = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[260px] items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-8 text-sm text-[var(--color-text-secondary)]">
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
{label}
|
||||
<div
|
||||
data-testid="skill-marketplace-loading"
|
||||
aria-label={t('skillCenter.marketplace.loadingCard')}
|
||||
className="grid gap-2.5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-testid="skill-card-skeleton"
|
||||
className="min-h-[124px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-3"
|
||||
>
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-3 h-3 w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-2 h-3 w-5/6 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="h-5 w-16 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="h-5 w-20 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailDrawerSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-[var(--color-border)] px-5 py-4">
|
||||
<div className="h-5 w-28 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-3 h-6 w-2/3 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
</div>
|
||||
<div className="border-b border-[var(--color-border)] p-5">
|
||||
<div className="h-10 w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="mt-3 h-4 w-3/4 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
</div>
|
||||
<div className="space-y-3 p-5">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
<div className="h-40 w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1126,13 +1217,13 @@ function matchesMarketFilter(item: SkillMarketItem, filter: MarketFilter) {
|
||||
|
||||
function formatStats(item: SkillMarketItem, t: TFunction): string {
|
||||
if (typeof item.downloads === 'number') {
|
||||
return `${compactNumber(item.downloads)} ${t('skillCenter.marketplace.downloads')}`
|
||||
return t('skillCenter.marketplace.cardMeta.downloads', { count: formatNumber(item.downloads) })
|
||||
}
|
||||
if (typeof item.installs === 'number') {
|
||||
return `${compactNumber(item.installs)} ${t('skillCenter.marketplace.sort.installs')}`
|
||||
}
|
||||
if (typeof item.stars === 'number') {
|
||||
return `${compactNumber(item.stars)} ${t('skillCenter.marketplace.stars')}`
|
||||
return t('skillCenter.marketplace.cardMeta.stars', { count: formatNumber(item.stars) })
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user