@nospt/plugin-dev-ai-hub 0.2.4 โ†’ 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/api/DevAiHubResourceClient.esm.js +97 -0
  2. package/dist/api/DevAiHubResourceClient.esm.js.map +1 -0
  3. package/dist/components/AiResourcesPage/AiResourcesPage.esm.js +150 -0
  4. package/dist/components/AiResourcesPage/AiResourcesPage.esm.js.map +1 -0
  5. package/dist/components/AiResourcesPage/AiResourcesPage.module.css.esm.js +8 -0
  6. package/dist/components/AiResourcesPage/AiResourcesPage.module.css.esm.js.map +1 -0
  7. package/dist/components/AiResourcesPage/ResourceCard.esm.js +112 -0
  8. package/dist/components/AiResourcesPage/ResourceCard.esm.js.map +1 -0
  9. package/dist/components/AiResourcesPage/ResourceCard.module.css.esm.js +8 -0
  10. package/dist/components/AiResourcesPage/ResourceCard.module.css.esm.js.map +1 -0
  11. package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js +238 -0
  12. package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js.map +1 -0
  13. package/dist/components/AiResourcesPage/ResourceDetailPanel.module.css.esm.js +8 -0
  14. package/dist/components/AiResourcesPage/ResourceDetailPanel.module.css.esm.js.map +1 -0
  15. package/dist/components/AiResourcesPage/ResourceFilters.esm.js +242 -0
  16. package/dist/components/AiResourcesPage/ResourceFilters.esm.js.map +1 -0
  17. package/dist/components/AiResourcesPage/ResourceFilters.module.css.esm.js +8 -0
  18. package/dist/components/AiResourcesPage/ResourceFilters.module.css.esm.js.map +1 -0
  19. package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js +366 -0
  20. package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js.map +1 -0
  21. package/dist/components/AiResourcesPage/ResourceInstallDialog.module.css.esm.js +8 -0
  22. package/dist/components/AiResourcesPage/ResourceInstallDialog.module.css.esm.js.map +1 -0
  23. package/dist/components/AiResourcesPage/StatTiles.aurora.module.css.esm.js +8 -0
  24. package/dist/components/AiResourcesPage/StatTiles.aurora.module.css.esm.js.map +1 -0
  25. package/dist/components/AiResourcesPage/StatTiles.console.module.css.esm.js +8 -0
  26. package/dist/components/AiResourcesPage/StatTiles.console.module.css.esm.js.map +1 -0
  27. package/dist/components/AiResourcesPage/StatTiles.esm.js +82 -0
  28. package/dist/components/AiResourcesPage/StatTiles.esm.js.map +1 -0
  29. package/dist/components/AiResourcesPage/StatTiles.module.css.esm.js +8 -0
  30. package/dist/components/AiResourcesPage/StatTiles.module.css.esm.js.map +1 -0
  31. package/dist/components/AiResourcesPage/index.esm.js +6 -0
  32. package/dist/components/AiResourcesPage/typeMeta.esm.js +34 -0
  33. package/dist/components/AiResourcesPage/typeMeta.esm.js.map +1 -0
  34. package/dist/components/ToolIcon/ToolIcon.esm.js +28 -10
  35. package/dist/components/ToolIcon/ToolIcon.esm.js.map +1 -1
  36. package/dist/hooks/useResourceBody.esm.js +39 -0
  37. package/dist/hooks/useResourceBody.esm.js.map +1 -0
  38. package/dist/hooks/useResources.esm.js +26 -0
  39. package/dist/hooks/useResources.esm.js.map +1 -0
  40. package/dist/hooks/useTelemetryCounts.esm.js +24 -0
  41. package/dist/hooks/useTelemetryCounts.esm.js.map +1 -0
  42. package/dist/index.d.ts +1 -38
  43. package/dist/index.esm.js +1 -3
  44. package/dist/index.esm.js.map +1 -1
  45. package/dist/plugin.esm.js +15 -24
  46. package/dist/plugin.esm.js.map +1 -1
  47. package/dist/routes.esm.js +1 -2
  48. package/dist/routes.esm.js.map +1 -1
  49. package/package.json +17 -10
  50. package/.eslintrc.js +0 -1
  51. package/dev/index.tsx +0 -32
  52. package/dist/api/DevAiHubClient.esm.js +0 -78
  53. package/dist/api/DevAiHubClient.esm.js.map +0 -1
  54. package/dist/components/AssetCard/AssetCard.esm.js +0 -120
  55. package/dist/components/AssetCard/AssetCard.esm.js.map +0 -1
  56. package/dist/components/AssetCard/AssetCard.module.css.esm.js +0 -8
  57. package/dist/components/AssetCard/AssetCard.module.css.esm.js.map +0 -1
  58. package/dist/components/AssetDetailPanel/AssetDetailPanel.esm.js +0 -218
  59. package/dist/components/AssetDetailPanel/AssetDetailPanel.esm.js.map +0 -1
  60. package/dist/components/AssetDetailPanel/AssetDetailPanel.module.css.esm.js +0 -8
  61. package/dist/components/AssetDetailPanel/AssetDetailPanel.module.css.esm.js.map +0 -1
  62. package/dist/components/AssetFilters/AssetFilters.esm.js +0 -176
  63. package/dist/components/AssetFilters/AssetFilters.esm.js.map +0 -1
  64. package/dist/components/AssetFilters/AssetFilters.module.css.esm.js +0 -8
  65. package/dist/components/AssetFilters/AssetFilters.module.css.esm.js.map +0 -1
  66. package/dist/components/AssetInstallDialog/AssetInstallDialog.esm.js +0 -138
  67. package/dist/components/AssetInstallDialog/AssetInstallDialog.esm.js.map +0 -1
  68. package/dist/components/AssetInstallDialog/AssetInstallDialog.module.css.esm.js +0 -8
  69. package/dist/components/AssetInstallDialog/AssetInstallDialog.module.css.esm.js.map +0 -1
  70. package/dist/components/DevAiHubPage/DevAiHubPage.esm.js +0 -195
  71. package/dist/components/DevAiHubPage/DevAiHubPage.esm.js.map +0 -1
  72. package/dist/components/DevAiHubPage/DevAiHubPage.module.css.esm.js +0 -8
  73. package/dist/components/DevAiHubPage/DevAiHubPage.module.css.esm.js.map +0 -1
  74. package/dist/components/DevAiHubPage/index.esm.js +0 -6
  75. package/dist/components/McpConfigPage/McpConfigPage.esm.js +0 -200
  76. package/dist/components/McpConfigPage/McpConfigPage.esm.js.map +0 -1
  77. package/dist/components/McpConfigPage/McpConfigPage.module.css.esm.js +0 -8
  78. package/dist/components/McpConfigPage/McpConfigPage.module.css.esm.js.map +0 -1
  79. package/dist/components/McpConfigPage/index.esm.js +0 -6
  80. package/dist/components/McpConfigPage/index.esm.js.map +0 -1
  81. package/dist/hooks/index.esm.js +0 -114
  82. package/dist/hooks/index.esm.js.map +0 -1
  83. package/dist/pluginLegacy.esm.js +0 -27
  84. package/dist/pluginLegacy.esm.js.map +0 -1
  85. package/package.json-prepack +0 -85
  86. package/src/api/DevAiHubClient.ts +0 -110
  87. package/src/components/AssetCard/AssetCard.module.css +0 -153
  88. package/src/components/AssetCard/AssetCard.test.tsx +0 -182
  89. package/src/components/AssetCard/AssetCard.tsx +0 -152
  90. package/src/components/AssetCard/index.ts +0 -1
  91. package/src/components/AssetDetailPanel/AssetDetailPanel.module.css +0 -207
  92. package/src/components/AssetDetailPanel/AssetDetailPanel.tsx +0 -313
  93. package/src/components/AssetDetailPanel/index.ts +0 -1
  94. package/src/components/AssetFilters/AssetFilters.module.css +0 -67
  95. package/src/components/AssetFilters/AssetFilters.tsx +0 -219
  96. package/src/components/AssetFilters/index.ts +0 -2
  97. package/src/components/AssetInstallDialog/AssetInstallDialog.module.css +0 -72
  98. package/src/components/AssetInstallDialog/AssetInstallDialog.tsx +0 -176
  99. package/src/components/AssetInstallDialog/index.ts +0 -1
  100. package/src/components/DevAiHubPage/DevAiHubPage.module.css +0 -105
  101. package/src/components/DevAiHubPage/DevAiHubPage.tsx +0 -197
  102. package/src/components/DevAiHubPage/index.ts +0 -1
  103. package/src/components/McpConfigDialog/McpConfigDialog.module.css +0 -111
  104. package/src/components/McpConfigDialog/McpConfigDialog.tsx +0 -271
  105. package/src/components/McpConfigDialog/index.ts +0 -1
  106. package/src/components/McpConfigPage/McpConfigPage.module.css +0 -121
  107. package/src/components/McpConfigPage/McpConfigPage.tsx +0 -242
  108. package/src/components/McpConfigPage/index.ts +0 -1
  109. package/src/components/ToolIcon/ToolIcon.tsx +0 -65
  110. package/src/components/ToolIcon/index.ts +0 -1
  111. package/src/css-modules.d.ts +0 -4
  112. package/src/hooks/index.ts +0 -152
  113. package/src/index.ts +0 -10
  114. package/src/plugin.tsx +0 -60
  115. package/src/pluginLegacy.ts +0 -42
  116. package/src/routes.ts +0 -4
  117. package/tests/e2e/.auth.json +0 -14
  118. package/tests/e2e/asset-detail.spec.ts +0 -90
  119. package/tests/e2e/asset-filtering.spec.ts +0 -145
  120. package/tests/e2e/asset-installation.spec.ts +0 -74
  121. package/tests/e2e/dev-ai-hub-page.spec.ts +0 -89
  122. package/tests/e2e/fixtures/base.ts +0 -61
  123. package/tests/e2e/fixtures/mock-api.ts +0 -221
  124. package/tests/e2e/mcp-config.spec.ts +0 -88
  125. package/tsconfig.json +0 -10
  126. /package/dist/components/{DevAiHubPage โ†’ AiResourcesPage}/index.esm.js.map +0 -0
@@ -1,110 +0,0 @@
1
- import {
2
- createApiRef,
3
- DiscoveryApi,
4
- FetchApi,
5
- } from '@backstage/core-plugin-api';
6
- import type {
7
- AiAsset,
8
- AiAssetListResponse,
9
- AiHubProvider,
10
- AiHubStats,
11
- AssetListFilter,
12
- } from '@nospt/plugin-dev-ai-hub-common';
13
-
14
- export const devAiHubApiRef = createApiRef<DevAiHubApi>({
15
- id: 'plugin.dev-ai-hub.service',
16
- });
17
-
18
- export interface DevAiHubApi {
19
- listAssets(filter?: AssetListFilter): Promise<AiAssetListResponse>;
20
- getAsset(id: string): Promise<AiAsset>;
21
- getAssetRaw(id: string): Promise<string>;
22
- /** Returns the absolute URL for the download endpoint (zip for skills, md for others). */
23
- getDownloadUrl(id: string): Promise<string>;
24
- trackInstall(id: string): Promise<void>;
25
- listProviders(): Promise<AiHubProvider[]>;
26
- getProviderStatus(id: string): Promise<AiHubProvider>;
27
- triggerSync(id: string): Promise<void>;
28
- getStats(): Promise<AiHubStats>;
29
- }
30
-
31
- export class DevAiHubClient implements DevAiHubApi {
32
- constructor(
33
- private readonly discoveryApi: DiscoveryApi,
34
- private readonly fetchApi: FetchApi,
35
- ) {}
36
-
37
- private async baseUrl(): Promise<string> {
38
- return this.discoveryApi.getBaseUrl('dev-ai-hub');
39
- }
40
-
41
- private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
42
- const base = await this.baseUrl();
43
- const response = await this.fetchApi.fetch(`${base}${path}`, init);
44
- if (!response.ok) {
45
- const text = await response.text();
46
- throw new Error(
47
- `Dev AI Hub API error ${response.status}: ${text}`,
48
- );
49
- }
50
- return response.json() as Promise<T>;
51
- }
52
-
53
- async listAssets(filter?: AssetListFilter): Promise<AiAssetListResponse> {
54
- const params = new URLSearchParams();
55
- if (filter?.type) params.set('type', filter.type);
56
- if (filter?.tool) params.set('tool', filter.tool);
57
- if (filter?.search) params.set('search', filter.search);
58
- if (filter?.providerId) params.set('provider', filter.providerId);
59
- if (filter?.tags?.length) params.set('tags', filter.tags.join(','));
60
- if (filter?.page) params.set('page', String(filter.page));
61
- if (filter?.pageSize) params.set('pageSize', String(filter.pageSize));
62
-
63
- const qs = params.toString();
64
- return this.fetch<AiAssetListResponse>(`/assets${qs ? `?${qs}` : ''}`);
65
- }
66
-
67
- async getAsset(id: string): Promise<AiAsset> {
68
- return this.fetch<AiAsset>(`/assets/${encodeURIComponent(id)}`);
69
- }
70
-
71
- async getAssetRaw(id: string): Promise<string> {
72
- const base = await this.baseUrl();
73
- const response = await this.fetchApi.fetch(
74
- `${base}/assets/${encodeURIComponent(id)}/raw`,
75
- );
76
- if (!response.ok) throw new Error(`Failed to fetch raw asset: ${response.status}`);
77
- return response.text();
78
- }
79
-
80
- async getDownloadUrl(id: string): Promise<string> {
81
- const base = await this.baseUrl();
82
- return `${base}/assets/${encodeURIComponent(id)}/download`;
83
- }
84
-
85
- async trackInstall(id: string): Promise<void> {
86
- await this.fetch(`/assets/${encodeURIComponent(id)}/track-install`, {
87
- method: 'POST',
88
- });
89
- }
90
-
91
- async listProviders(): Promise<AiHubProvider[]> {
92
- return this.fetch<AiHubProvider[]>('/providers');
93
- }
94
-
95
- async getProviderStatus(id: string): Promise<AiHubProvider> {
96
- return this.fetch<AiHubProvider>(
97
- `/providers/${encodeURIComponent(id)}/status`,
98
- );
99
- }
100
-
101
- async triggerSync(id: string): Promise<void> {
102
- await this.fetch(`/providers/${encodeURIComponent(id)}/sync`, {
103
- method: 'POST',
104
- });
105
- }
106
-
107
- async getStats(): Promise<AiHubStats> {
108
- return this.fetch<AiHubStats>('/stats');
109
- }
110
- }
@@ -1,153 +0,0 @@
1
- @layer components {
2
- .card {
3
- height: 100%;
4
- display: flex;
5
- flex-direction: column;
6
- border-radius: var(--bui-radius-2);
7
- border: 1px solid var(--bui-border-1);
8
- border-left: 3px solid var(--card-accent);
9
- background-color: var(--bui-bg-neutral-1);
10
- transition: all 0.18s ease;
11
- }
12
-
13
- .card:hover {
14
- box-shadow: 0 6px 24px color-mix(in srgb, var(--card-accent) 19%, transparent);
15
- border-color: var(--card-accent);
16
- transform: translateY(-2px);
17
- }
18
-
19
- .cardContent {
20
- padding: var(--bui-space-3);
21
- padding-bottom: 0;
22
- flex: 1;
23
- }
24
-
25
- .header {
26
- display: flex;
27
- align-items: flex-start;
28
- gap: var(--bui-space-2);
29
- margin-bottom: var(--bui-space-2);
30
- }
31
-
32
- .iconBox {
33
- width: 36px;
34
- height: 36px;
35
- border-radius: var(--bui-radius-2);
36
- display: flex;
37
- align-items: center;
38
- justify-content: center;
39
- flex-shrink: 0;
40
- box-shadow: 0 2px 8px color-mix(in srgb, var(--card-accent) 15%, transparent);
41
- }
42
-
43
- .iconImage {
44
- width: 20px;
45
- height: 20px;
46
- object-fit: contain;
47
- }
48
-
49
- .headerInfo {
50
- flex: 1;
51
- min-width: 0;
52
- }
53
-
54
- .titleRow {
55
- display: flex;
56
- align-items: center;
57
- gap: var(--bui-space-1);
58
- margin-bottom: 2px;
59
- }
60
-
61
- .title {
62
- line-height: 1.2;
63
- flex: 1;
64
- overflow: hidden;
65
- white-space: nowrap;
66
- text-overflow: ellipsis;
67
- }
68
-
69
- .newBadge {
70
- height: 16px;
71
- flex-shrink: 0;
72
- font-size: 0.58rem;
73
- font-weight: 700;
74
- background-color: #6AB04C;
75
- color: #fff;
76
- }
77
-
78
- .description {
79
- margin-bottom: var(--bui-space-2);
80
- display: -webkit-box;
81
- -webkit-line-clamp: 2;
82
- -webkit-box-orient: vertical;
83
- overflow: hidden;
84
- line-height: 1.4;
85
- }
86
-
87
- .toolsRow :global(.bui-TagList) {
88
- display: flex;
89
- gap: var(--bui-space-1);
90
- flex-wrap: wrap;
91
- }
92
-
93
- .toolsRowWithTags {
94
- margin-bottom: 6px;
95
- }
96
-
97
- .toolChip {
98
- height: 18px;
99
- font-size: 0.65rem;
100
- font-weight: 600;
101
- background-color: var(--bui-bg-neutral-1-hover);
102
- color: var(--bui-fg-secondary);
103
- }
104
-
105
- .tagsRow :global(.bui-TagList) {
106
- display: flex;
107
- gap: var(--bui-space-1);
108
- flex-wrap: wrap;
109
- }
110
-
111
- .tagChip {
112
- height: 16px;
113
- font-size: 0.6rem;
114
- color: var(--bui-fg-secondary);
115
- background-color: transparent;
116
- border: 1px solid var(--bui-border-1);
117
- }
118
-
119
- .cardActions {
120
- padding: var(--bui-space-2) var(--bui-space-3);
121
- display: flex;
122
- justify-content: space-between;
123
- align-items: center;
124
- margin-top: auto;
125
- border-top: 1px solid var(--bui-border-1);
126
- }
127
-
128
- .metaRow {
129
- display: flex;
130
- align-items: center;
131
- gap: var(--bui-space-2);
132
- }
133
-
134
- .metaText {
135
- font-size: 0.65rem;
136
- }
137
-
138
- .installCountRow {
139
- display: flex;
140
- align-items: center;
141
- gap: 2px;
142
- }
143
-
144
- .installEmoji {
145
- font-size: 0.65rem;
146
- line-height: 1;
147
- }
148
-
149
- .actionsRow {
150
- display: flex;
151
- gap: 2px;
152
- }
153
- }
@@ -1,182 +0,0 @@
1
- import '@testing-library/jest-dom';
2
- import { render, screen, fireEvent } from '@testing-library/react';
3
- import { AssetCard } from './AssetCard';
4
- import type { AiAssetSummary } from '@nospt/plugin-dev-ai-hub-common';
5
-
6
- jest.mock('../ToolIcon', () => ({
7
- ToolIcon: ({ tool }: { tool: string }) => <span data-testid={`tool-icon-${tool}`} />,
8
- }));
9
-
10
- const NOW = new Date('2026-03-14T12:00:00Z').getTime();
11
-
12
- function makeAsset(overrides?: Partial<AiAssetSummary>): AiAssetSummary {
13
- return {
14
- id: 'asset-1',
15
- providerId: 'provider-1',
16
- name: 'My Instruction',
17
- description: 'A helpful coding instruction',
18
- type: 'instruction',
19
- tools: ['claude-code'],
20
- tags: ['python', 'linting'],
21
- author: 'Test Author',
22
- version: '1.0.0',
23
- installCount: 0,
24
- syncedAt: '2026-03-14T12:00:00Z',
25
- createdAt: '2026-03-01T00:00:00Z',
26
- updatedAt: '2026-03-13T00:00:00Z', // 1 day ago โ€” "New"
27
- ...overrides,
28
- };
29
- }
30
-
31
- describe('AssetCard', () => {
32
- beforeEach(() => {
33
- jest.spyOn(Date, 'now').mockReturnValue(NOW);
34
- });
35
-
36
- afterEach(() => {
37
- jest.restoreAllMocks();
38
- });
39
-
40
- it('renders asset name and description', () => {
41
- render(<AssetCard asset={makeAsset()} onView={jest.fn()} onInstall={jest.fn()} />);
42
- expect(screen.getByText('My Instruction')).toBeInTheDocument();
43
- expect(screen.getByText('A helpful coding instruction')).toBeInTheDocument();
44
- });
45
-
46
- it('renders the type label', () => {
47
- render(<AssetCard asset={makeAsset({ type: 'instruction' })} onView={jest.fn()} onInstall={jest.fn()} />);
48
- expect(screen.getByText('Instruction')).toBeInTheDocument();
49
- });
50
-
51
- it('renders the correct type label for each type', () => {
52
- const types = ['agent', 'skill', 'workflow'] as const;
53
- const labels = ['Agent', 'Skill', 'Workflow'];
54
-
55
- types.forEach((type, i) => {
56
- const { unmount } = render(
57
- <AssetCard asset={makeAsset({ type })} onView={jest.fn()} onInstall={jest.fn()} />,
58
- );
59
- expect(screen.getByText(labels[i])).toBeInTheDocument();
60
- unmount();
61
- });
62
- });
63
-
64
- it('renders author and version in the footer', () => {
65
- render(<AssetCard asset={makeAsset()} onView={jest.fn()} onInstall={jest.fn()} />);
66
- expect(screen.getByText(/v1\.0\.0/)).toBeInTheDocument();
67
- expect(screen.getByText(/Test Author/)).toBeInTheDocument();
68
- });
69
-
70
- describe('"New" badge', () => {
71
- it('shows "New" badge when updatedAt is within 14 days', () => {
72
- const recentDate = new Date(NOW - 5 * 24 * 60 * 60 * 1000).toISOString(); // 5 days ago
73
- render(
74
- <AssetCard asset={makeAsset({ updatedAt: recentDate })} onView={jest.fn()} onInstall={jest.fn()} />,
75
- );
76
- expect(screen.getByText('New')).toBeInTheDocument();
77
- });
78
-
79
- it('does not show "New" badge when updatedAt is older than 14 days', () => {
80
- const oldDate = new Date(NOW - 20 * 24 * 60 * 60 * 1000).toISOString(); // 20 days ago
81
- render(
82
- <AssetCard asset={makeAsset({ updatedAt: oldDate })} onView={jest.fn()} onInstall={jest.fn()} />,
83
- );
84
- expect(screen.queryByText('New')).not.toBeInTheDocument();
85
- });
86
- });
87
-
88
- describe('install count display', () => {
89
- it('does not show install count when installCount is 0', () => {
90
- render(<AssetCard asset={makeAsset({ installCount: 0 })} onView={jest.fn()} onInstall={jest.fn()} />);
91
- expect(screen.queryByText('โ†“')).not.toBeInTheDocument();
92
- expect(screen.queryByText('๐Ÿ”ฅ')).not.toBeInTheDocument();
93
- });
94
-
95
- it('shows "โ†“" arrow with count when installCount > 0 and < 5', () => {
96
- render(<AssetCard asset={makeAsset({ installCount: 3 })} onView={jest.fn()} onInstall={jest.fn()} />);
97
- expect(screen.getByText('โ†“')).toBeInTheDocument();
98
- expect(screen.getByText('3')).toBeInTheDocument();
99
- });
100
-
101
- it('shows ๐Ÿ”ฅ emoji when installCount >= 5 (popular threshold)', () => {
102
- render(<AssetCard asset={makeAsset({ installCount: 5 })} onView={jest.fn()} onInstall={jest.fn()} />);
103
- expect(screen.getByText('๐Ÿ”ฅ')).toBeInTheDocument();
104
- expect(screen.getByText('5')).toBeInTheDocument();
105
- });
106
-
107
- it('shows ๐Ÿ”ฅ emoji for installCount > 5', () => {
108
- render(<AssetCard asset={makeAsset({ installCount: 42 })} onView={jest.fn()} onInstall={jest.fn()} />);
109
- expect(screen.getByText('๐Ÿ”ฅ')).toBeInTheDocument();
110
- expect(screen.queryByText('โ†“')).not.toBeInTheDocument();
111
- });
112
- });
113
-
114
- describe('callbacks', () => {
115
- it('calls onView with the asset id when "View details" button is clicked', () => {
116
- const onView = jest.fn();
117
- render(<AssetCard asset={makeAsset()} onView={onView} onInstall={jest.fn()} />);
118
- fireEvent.click(screen.getByRole('button', { name: 'View details' }));
119
- expect(onView).toHaveBeenCalledTimes(1);
120
- expect(onView).toHaveBeenCalledWith('asset-1');
121
- });
122
-
123
- it('calls onInstall with the asset id when "Install in editor" button is clicked', () => {
124
- const onInstall = jest.fn();
125
- render(<AssetCard asset={makeAsset()} onView={jest.fn()} onInstall={onInstall} />);
126
- fireEvent.click(screen.getByRole('button', { name: 'Install in editor' }));
127
- expect(onInstall).toHaveBeenCalledTimes(1);
128
- expect(onInstall).toHaveBeenCalledWith('asset-1');
129
- });
130
- });
131
-
132
- describe('tags', () => {
133
- it('renders tags as chips prefixed with #', () => {
134
- render(
135
- <AssetCard asset={makeAsset({ tags: ['python', 'linting'] })} onView={jest.fn()} onInstall={jest.fn()} />,
136
- );
137
- expect(screen.getByText('#python')).toBeInTheDocument();
138
- expect(screen.getByText('#linting')).toBeInTheDocument();
139
- });
140
-
141
- it('renders at most 3 tags and shows "+N" for the rest', () => {
142
- render(
143
- <AssetCard
144
- asset={makeAsset({ tags: ['a', 'b', 'c', 'd', 'e'] })}
145
- onView={jest.fn()}
146
- onInstall={jest.fn()}
147
- />,
148
- );
149
- expect(screen.getByText('#a')).toBeInTheDocument();
150
- expect(screen.getByText('#b')).toBeInTheDocument();
151
- expect(screen.getByText('#c')).toBeInTheDocument();
152
- expect(screen.queryByText('#d')).not.toBeInTheDocument();
153
- expect(screen.getByText('+2')).toBeInTheDocument();
154
- });
155
-
156
- it('renders no tags section when tags is empty', () => {
157
- render(<AssetCard asset={makeAsset({ tags: [] })} onView={jest.fn()} onInstall={jest.fn()} />);
158
- expect(screen.queryByText(/#/)).not.toBeInTheDocument();
159
- });
160
- });
161
-
162
- describe('tools', () => {
163
- it('renders a tool icon for each tool', () => {
164
- render(
165
- <AssetCard
166
- asset={makeAsset({ tools: ['claude-code', 'github-copilot'] })}
167
- onView={jest.fn()}
168
- onInstall={jest.fn()}
169
- />,
170
- );
171
- expect(screen.getByTestId('tool-icon-claude-code')).toBeInTheDocument();
172
- expect(screen.getByTestId('tool-icon-github-copilot')).toBeInTheDocument();
173
- });
174
-
175
- it('shows "Universal" label for the "all" tool', () => {
176
- render(
177
- <AssetCard asset={makeAsset({ tools: ['all'] })} onView={jest.fn()} onInstall={jest.fn()} />,
178
- );
179
- expect(screen.getByText('Universal')).toBeInTheDocument();
180
- });
181
- });
182
- });
@@ -1,152 +0,0 @@
1
- import type { ElementType } from 'react';
2
- import { Box, Flex, Text, Card, CardBody, CardFooter, Tag, TagGroup, ButtonIcon, Tooltip, TooltipTrigger } from '@backstage/ui';
3
- import { RiDownloadLine, RiExternalLinkLine } from '@remixicon/react';
4
- import { RiArticleLine, RiRobot2Line, RiToolsLine, RiGitBranchLine } from '@remixicon/react';
5
- import type { AiAssetSummary, AssetType, AiTool } from '@nospt/plugin-dev-ai-hub-common';
6
- import { ToolIcon } from '../ToolIcon';
7
- import styles from './AssetCard.module.css';
8
-
9
- const POPULAR_THRESHOLD = 5;
10
- const NEW_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
11
-
12
- const TOOL_LABELS: Record<AiTool, string> = {
13
- 'all': 'Universal',
14
- 'claude-code': 'Claude Code',
15
- 'github-copilot': 'GitHub Copilot',
16
- 'google-gemini': 'Google Gemini',
17
- 'cursor': 'Cursor',
18
- };
19
-
20
- const TYPE_CONFIG: Record<AssetType, { label: string; color: string; bg: string; Icon: ElementType }> = {
21
- instruction: { label: 'Instruction', color: '#54A0FF', bg: 'rgba(84, 160, 255, 0.15)', Icon: RiArticleLine },
22
- agent: { label: 'Agent', color: '#FF6B9D', bg: 'rgba(255, 107, 157, 0.15)', Icon: RiRobot2Line },
23
- skill: { label: 'Skill', color: '#6AB04C', bg: 'rgba(106, 176, 76, 0.15)', Icon: RiToolsLine },
24
- workflow: { label: 'Workflow', color: '#F9CA24', bg: 'rgba(249, 202, 36, 0.15)', Icon: RiGitBranchLine },
25
- };
26
-
27
- interface AssetCardProps {
28
- asset: AiAssetSummary;
29
- onView: (id: string) => void;
30
- onInstall: (id: string) => void;
31
- }
32
-
33
- export function AssetCard({ asset, onView, onInstall }: AssetCardProps) {
34
- const cfg = TYPE_CONFIG[asset.type];
35
- const TypeIcon = cfg.Icon;
36
- const isPopular = asset.installCount >= POPULAR_THRESHOLD;
37
- const isNew = Date.now() - new Date(asset.updatedAt).getTime() < NEW_DAYS_MS;
38
-
39
- return (
40
- <Card
41
- className={styles.card}
42
- style={{ '--card-accent': cfg.color } as React.CSSProperties}
43
- >
44
- <CardBody className={styles.cardContent}>
45
- {/* Header */}
46
- <Flex className={styles.header}>
47
- <Box
48
- className={styles.iconBox}
49
- style={{ backgroundColor: cfg.bg }}
50
- >
51
- {asset.icon ? (
52
- <img
53
- src={asset.icon}
54
- alt={asset.label ?? asset.name}
55
- className={styles.iconImage}
56
- onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
57
- />
58
- ) : (
59
- <TypeIcon size={18} style={{ color: cfg.color }} />
60
- )}
61
- </Box>
62
-
63
- <Box className={styles.headerInfo}>
64
- <Flex className={styles.titleRow}>
65
- <Text variant="body-medium" weight="bold" className={styles.title} title={asset.label ?? asset.name}>
66
- {asset.label ?? asset.name}
67
- </Text>
68
- {isNew && (
69
- <TagGroup aria-label="Status">
70
- <Tag id="new" size="small" className={styles.newBadge}>New</Tag>
71
- </TagGroup>
72
- )}
73
- </Flex>
74
- <Text variant="body-x-small" style={{ color: cfg.color, fontWeight: 600 }}>
75
- {cfg.label}
76
- </Text>
77
- </Box>
78
- </Flex>
79
-
80
- {/* Description */}
81
- <Text variant="body-x-small" color="secondary" className={styles.description}>
82
- {asset.description}
83
- </Text>
84
-
85
- {/* Tools */}
86
- <TagGroup aria-label="Compatible tools" className={`${styles.toolsRow} ${asset.tags.length > 0 ? styles.toolsRowWithTags : ''}`}>
87
- {asset.tools.map(tool => (
88
- <Tag key={tool} id={tool} size="small" className={styles.toolChip} icon={<ToolIcon tool={tool as AiTool} size={12} />}>
89
- {TOOL_LABELS[tool as AiTool] ?? tool}
90
- </Tag>
91
- ))}
92
- </TagGroup>
93
-
94
- {/* Tags */}
95
- {asset.tags.length > 0 && (
96
- <>
97
- <TagGroup aria-label="Tags" className={styles.tagsRow}>
98
- {asset.tags.slice(0, 3).map(tag => (
99
- <Tag key={tag} id={tag} size="small" className={styles.tagChip}>
100
- #{tag}
101
- </Tag>
102
- ))}
103
- </TagGroup>
104
- {asset.tags.length > 3 && (
105
- <Text variant="body-x-small" color="secondary" style={{ alignSelf: 'center' }}>
106
- +{asset.tags.length - 3}
107
- </Text>
108
- )}
109
- </>
110
- )}
111
- </CardBody>
112
-
113
- <CardFooter className={styles.cardActions}>
114
- <Flex className={styles.metaRow}>
115
- <Text variant="body-x-small" color="secondary" className={styles.metaText}>
116
- v{asset.version} ยท {asset.author}
117
- </Text>
118
- {asset.installCount > 0 && (
119
- <Flex className={styles.installCountRow}>
120
- <Text className={styles.installEmoji}>
121
- {isPopular ? '๐Ÿ”ฅ' : 'โ†“'}
122
- </Text>
123
- <Text variant="body-x-small" color="secondary" className={styles.metaText}>
124
- {asset.installCount}
125
- </Text>
126
- </Flex>
127
- )}
128
- </Flex>
129
- <Flex className={styles.actionsRow}>
130
- <TooltipTrigger>
131
- <ButtonIcon
132
- aria-label="Install in editor"
133
- icon={<RiDownloadLine size={16} />}
134
- variant="tertiary"
135
- onPress={() => onInstall(asset.id)}
136
- />
137
- <Tooltip>Install in editor</Tooltip>
138
- </TooltipTrigger>
139
- <TooltipTrigger>
140
- <ButtonIcon
141
- aria-label="View details"
142
- icon={<RiExternalLinkLine size={16} />}
143
- variant="tertiary"
144
- onPress={() => onView(asset.id)}
145
- />
146
- <Tooltip>View details</Tooltip>
147
- </TooltipTrigger>
148
- </Flex>
149
- </CardFooter>
150
- </Card>
151
- );
152
- }
@@ -1 +0,0 @@
1
- export { AssetCard } from './AssetCard';