@nospt/plugin-dev-ai-hub 0.2.3 โ†’ 0.3.0

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 (122) 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 +158 -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 +234 -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.esm.js +49 -0
  24. package/dist/components/AiResourcesPage/StatTiles.esm.js.map +1 -0
  25. package/dist/components/AiResourcesPage/StatTiles.module.css.esm.js +8 -0
  26. package/dist/components/AiResourcesPage/StatTiles.module.css.esm.js.map +1 -0
  27. package/dist/components/AiResourcesPage/index.esm.js +6 -0
  28. package/dist/components/AiResourcesPage/typeMeta.esm.js +35 -0
  29. package/dist/components/AiResourcesPage/typeMeta.esm.js.map +1 -0
  30. package/dist/components/ToolIcon/ToolIcon.esm.js +26 -9
  31. package/dist/components/ToolIcon/ToolIcon.esm.js.map +1 -1
  32. package/dist/hooks/useResourceBody.esm.js +39 -0
  33. package/dist/hooks/useResourceBody.esm.js.map +1 -0
  34. package/dist/hooks/useResources.esm.js +26 -0
  35. package/dist/hooks/useResources.esm.js.map +1 -0
  36. package/dist/hooks/useTelemetryCounts.esm.js +24 -0
  37. package/dist/hooks/useTelemetryCounts.esm.js.map +1 -0
  38. package/dist/index.d.ts +1 -38
  39. package/dist/index.esm.js +1 -3
  40. package/dist/index.esm.js.map +1 -1
  41. package/dist/plugin.esm.js +17 -26
  42. package/dist/plugin.esm.js.map +1 -1
  43. package/dist/routes.esm.js +1 -2
  44. package/dist/routes.esm.js.map +1 -1
  45. package/package.json +17 -10
  46. package/.eslintrc.js +0 -1
  47. package/dev/index.tsx +0 -32
  48. package/dist/api/DevAiHubClient.esm.js +0 -78
  49. package/dist/api/DevAiHubClient.esm.js.map +0 -1
  50. package/dist/components/AssetCard/AssetCard.esm.js +0 -120
  51. package/dist/components/AssetCard/AssetCard.esm.js.map +0 -1
  52. package/dist/components/AssetCard/AssetCard.module.css.esm.js +0 -8
  53. package/dist/components/AssetCard/AssetCard.module.css.esm.js.map +0 -1
  54. package/dist/components/AssetDetailPanel/AssetDetailPanel.esm.js +0 -218
  55. package/dist/components/AssetDetailPanel/AssetDetailPanel.esm.js.map +0 -1
  56. package/dist/components/AssetDetailPanel/AssetDetailPanel.module.css.esm.js +0 -8
  57. package/dist/components/AssetDetailPanel/AssetDetailPanel.module.css.esm.js.map +0 -1
  58. package/dist/components/AssetFilters/AssetFilters.esm.js +0 -176
  59. package/dist/components/AssetFilters/AssetFilters.esm.js.map +0 -1
  60. package/dist/components/AssetFilters/AssetFilters.module.css.esm.js +0 -8
  61. package/dist/components/AssetFilters/AssetFilters.module.css.esm.js.map +0 -1
  62. package/dist/components/AssetInstallDialog/AssetInstallDialog.esm.js +0 -138
  63. package/dist/components/AssetInstallDialog/AssetInstallDialog.esm.js.map +0 -1
  64. package/dist/components/AssetInstallDialog/AssetInstallDialog.module.css.esm.js +0 -8
  65. package/dist/components/AssetInstallDialog/AssetInstallDialog.module.css.esm.js.map +0 -1
  66. package/dist/components/DevAiHubPage/DevAiHubPage.esm.js +0 -195
  67. package/dist/components/DevAiHubPage/DevAiHubPage.esm.js.map +0 -1
  68. package/dist/components/DevAiHubPage/DevAiHubPage.module.css.esm.js +0 -8
  69. package/dist/components/DevAiHubPage/DevAiHubPage.module.css.esm.js.map +0 -1
  70. package/dist/components/DevAiHubPage/index.esm.js +0 -6
  71. package/dist/components/McpConfigPage/McpConfigPage.esm.js +0 -200
  72. package/dist/components/McpConfigPage/McpConfigPage.esm.js.map +0 -1
  73. package/dist/components/McpConfigPage/McpConfigPage.module.css.esm.js +0 -8
  74. package/dist/components/McpConfigPage/McpConfigPage.module.css.esm.js.map +0 -1
  75. package/dist/components/McpConfigPage/index.esm.js +0 -6
  76. package/dist/components/McpConfigPage/index.esm.js.map +0 -1
  77. package/dist/hooks/index.esm.js +0 -114
  78. package/dist/hooks/index.esm.js.map +0 -1
  79. package/dist/pluginLegacy.esm.js +0 -27
  80. package/dist/pluginLegacy.esm.js.map +0 -1
  81. package/package.json-prepack +0 -85
  82. package/src/api/DevAiHubClient.ts +0 -110
  83. package/src/components/AssetCard/AssetCard.module.css +0 -153
  84. package/src/components/AssetCard/AssetCard.test.tsx +0 -182
  85. package/src/components/AssetCard/AssetCard.tsx +0 -152
  86. package/src/components/AssetCard/index.ts +0 -1
  87. package/src/components/AssetDetailPanel/AssetDetailPanel.module.css +0 -207
  88. package/src/components/AssetDetailPanel/AssetDetailPanel.tsx +0 -313
  89. package/src/components/AssetDetailPanel/index.ts +0 -1
  90. package/src/components/AssetFilters/AssetFilters.module.css +0 -67
  91. package/src/components/AssetFilters/AssetFilters.tsx +0 -219
  92. package/src/components/AssetFilters/index.ts +0 -2
  93. package/src/components/AssetInstallDialog/AssetInstallDialog.module.css +0 -72
  94. package/src/components/AssetInstallDialog/AssetInstallDialog.tsx +0 -176
  95. package/src/components/AssetInstallDialog/index.ts +0 -1
  96. package/src/components/DevAiHubPage/DevAiHubPage.module.css +0 -105
  97. package/src/components/DevAiHubPage/DevAiHubPage.tsx +0 -197
  98. package/src/components/DevAiHubPage/index.ts +0 -1
  99. package/src/components/McpConfigDialog/McpConfigDialog.module.css +0 -111
  100. package/src/components/McpConfigDialog/McpConfigDialog.tsx +0 -271
  101. package/src/components/McpConfigDialog/index.ts +0 -1
  102. package/src/components/McpConfigPage/McpConfigPage.module.css +0 -121
  103. package/src/components/McpConfigPage/McpConfigPage.tsx +0 -242
  104. package/src/components/McpConfigPage/index.ts +0 -1
  105. package/src/components/ToolIcon/ToolIcon.tsx +0 -65
  106. package/src/components/ToolIcon/index.ts +0 -1
  107. package/src/css-modules.d.ts +0 -4
  108. package/src/hooks/index.ts +0 -152
  109. package/src/index.ts +0 -10
  110. package/src/plugin.tsx +0 -60
  111. package/src/pluginLegacy.ts +0 -42
  112. package/src/routes.ts +0 -4
  113. package/tests/e2e/.auth.json +0 -14
  114. package/tests/e2e/asset-detail.spec.ts +0 -90
  115. package/tests/e2e/asset-filtering.spec.ts +0 -145
  116. package/tests/e2e/asset-installation.spec.ts +0 -74
  117. package/tests/e2e/dev-ai-hub-page.spec.ts +0 -89
  118. package/tests/e2e/fixtures/base.ts +0 -61
  119. package/tests/e2e/fixtures/mock-api.ts +0 -221
  120. package/tests/e2e/mcp-config.spec.ts +0 -88
  121. package/tsconfig.json +0 -10
  122. /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';