@toolr/ui-design 0.1.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.
- package/README.md +63 -0
- package/components/content/info-panel-primitives.tsx +297 -0
- package/components/diagrams/diagram-utils.tsx +908 -0
- package/components/hooks/use-click-outside.ts +27 -0
- package/components/hooks/use-dropdown-max-height.ts +20 -0
- package/components/hooks/use-navigation-history.ts +94 -0
- package/components/lib/ai-tools.tsx +44 -0
- package/components/lib/cn.ts +6 -0
- package/components/lib/form-colors.ts +32 -0
- package/components/lib/theme-engine.ts +97 -0
- package/components/lib/toolr-brand.tsx +31 -0
- package/components/sections/ai-tools-paths/index.ts +37 -0
- package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
- package/components/sections/ai-tools-paths/types.ts +111 -0
- package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
- package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
- package/components/sections/captured-issues/index.ts +38 -0
- package/components/sections/captured-issues/types.ts +113 -0
- package/components/sections/captured-issues/use-captured-issues.ts +111 -0
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
- package/components/sections/golden-snapshots/index.ts +145 -0
- package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
- package/components/sections/golden-snapshots/status-overview.tsx +305 -0
- package/components/sections/golden-snapshots/types.ts +288 -0
- package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
- package/components/sections/golden-snapshots/version-manager.tsx +186 -0
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
- package/components/sections/prompt-editor/index.ts +121 -0
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
- package/components/sections/prompt-editor/types.ts +101 -0
- package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
- package/components/sections/report-bug/error-logger.ts +392 -0
- package/components/sections/report-bug/index.ts +59 -0
- package/components/sections/report-bug/issue-reporter-api.ts +83 -0
- package/components/sections/report-bug/report-bug-form.tsx +282 -0
- package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
- package/components/sections/report-bug/use-report-bug.ts +170 -0
- package/components/sections/snapshot-browser/index.ts +53 -0
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
- package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
- package/components/sections/snapshot-browser/types.ts +106 -0
- package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
- package/components/sections/snippets-editor/index.ts +31 -0
- package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
- package/components/sections/snippets-editor/types.ts +48 -0
- package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
- package/components/ui/action-dialog.tsx +309 -0
- package/components/ui/ai-action-button.tsx +137 -0
- package/components/ui/ai-execution-action-buttons.tsx +106 -0
- package/components/ui/badge.tsx +67 -0
- package/components/ui/bottom-panel-header.tsx +240 -0
- package/components/ui/breadcrumb.tsx +168 -0
- package/components/ui/checkbox.tsx +102 -0
- package/components/ui/collapsible-section.tsx +100 -0
- package/components/ui/confirm-badge.tsx +71 -0
- package/components/ui/detail-section.tsx +67 -0
- package/components/ui/detail-view-wrapper.tsx +55 -0
- package/components/ui/editor-placeholder-card.tsx +197 -0
- package/components/ui/editor-toolbar.tsx +123 -0
- package/components/ui/execution-details-panel.tsx +93 -0
- package/components/ui/extension-list-card.tsx +105 -0
- package/components/ui/file-structure-section.tsx +373 -0
- package/components/ui/file-tree.tsx +171 -0
- package/components/ui/files-panel.tsx +251 -0
- package/components/ui/filter-dropdown.tsx +173 -0
- package/components/ui/form-actions.tsx +127 -0
- package/components/ui/frontmatter-form-header.tsx +80 -0
- package/components/ui/icon-button.tsx +388 -0
- package/components/ui/input.tsx +211 -0
- package/components/ui/label.tsx +159 -0
- package/components/ui/layout-tab-bar.tsx +289 -0
- package/components/ui/modal.tsx +194 -0
- package/components/ui/nav-card.tsx +81 -0
- package/components/ui/navigation-bar.tsx +285 -0
- package/components/ui/number-input.tsx +165 -0
- package/components/ui/registry-browser.tsx +261 -0
- package/components/ui/registry-card.tsx +710 -0
- package/components/ui/registry-detail.tsx +224 -0
- package/components/ui/resizable-textarea.tsx +290 -0
- package/components/ui/scope-badge.tsx +67 -0
- package/components/ui/segmented-toggle.tsx +133 -0
- package/components/ui/select.tsx +172 -0
- package/components/ui/selection-grid.tsx +313 -0
- package/components/ui/setting-row.tsx +97 -0
- package/components/ui/snapshot-card.tsx +107 -0
- package/components/ui/snippets-panel.tsx +161 -0
- package/components/ui/sort-dropdown.tsx +109 -0
- package/components/ui/status-card.tsx +96 -0
- package/components/ui/tab-bar.tsx +340 -0
- package/components/ui/toggle.tsx +142 -0
- package/components/ui/tooltip.tsx +326 -0
- package/dist/content.d.ts +110 -0
- package/dist/content.js +195 -0
- package/dist/diagrams.d.ts +371 -0
- package/dist/diagrams.js +702 -0
- package/dist/index.d.ts +2714 -0
- package/dist/index.js +11220 -0
- package/dist/preset.d.ts +24 -0
- package/dist/preset.js +17 -0
- package/dist/tokens/tokens/primitives.css +45 -0
- package/dist/tokens/tokens/semantic.css +46 -0
- package/dist/tokens/tokens/theme.css +11 -0
- package/dist/tokens/tokens/tokens.json +65 -0
- package/index.ts +123 -0
- package/package.json +63 -0
- package/tailwind-preset.ts +22 -0
- package/tokens/primitives.css +45 -0
- package/tokens/semantic.css +46 -0
- package/tokens/theme.css +11 -0
- package/tokens/tokens.json +65 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { type ReactNode, type MouseEvent, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Clock, Download, FolderPlus, Loader2, Plus, Power, Star, Trash2,
|
|
4
|
+
Sparkles, Bot, Webhook, Terminal, Plug, Settings, Package, Puzzle,
|
|
5
|
+
} from 'lucide-react'
|
|
6
|
+
import { Tooltip } from './tooltip.tsx'
|
|
7
|
+
import { IconButton, type IconName } from './icon-button.tsx'
|
|
8
|
+
import { Label, type LabelColor } from './label.tsx'
|
|
9
|
+
import { AiToolIcon, AI_TOOL_NAMES, type AiToolKey } from '../lib/ai-tools.tsx'
|
|
10
|
+
export { AiToolIcon, AI_TOOL_NAMES, type AiToolKey }
|
|
11
|
+
|
|
12
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export type RegistryItemType = 'skill' | 'hook' | 'agent' | 'command' | 'plugin' | 'mcp' | 'settings'
|
|
15
|
+
export type ExtensionSource = 'project' | 'plugin' | 'user' | 'local'
|
|
16
|
+
|
|
17
|
+
const TYPE_ICONS: Record<string, typeof Sparkles> = {
|
|
18
|
+
skill: Sparkles,
|
|
19
|
+
command: Terminal,
|
|
20
|
+
hook: Webhook,
|
|
21
|
+
agent: Bot,
|
|
22
|
+
mcp: Plug,
|
|
23
|
+
settings: Settings,
|
|
24
|
+
plugin: Package,
|
|
25
|
+
extension: Puzzle,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SEEDR_ICON_COLORS: Record<string, string> = {
|
|
29
|
+
skill: 'text-pink-400',
|
|
30
|
+
hook: 'text-purple-400',
|
|
31
|
+
agent: 'text-blue-400',
|
|
32
|
+
command: 'text-amber-400',
|
|
33
|
+
plugin: 'text-indigo-400',
|
|
34
|
+
mcp: 'text-teal-400',
|
|
35
|
+
settings: 'text-orange-400',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SEEDR_TYPE_LABELS: Record<string, string> = {
|
|
39
|
+
skill: 'Skill',
|
|
40
|
+
hook: 'Hook',
|
|
41
|
+
agent: 'Agent',
|
|
42
|
+
command: 'Command',
|
|
43
|
+
plugin: 'Plugin',
|
|
44
|
+
mcp: 'MCP Server',
|
|
45
|
+
settings: 'Settings',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SEEDR_CONTENT_TYPES = [
|
|
49
|
+
{ key: 'skills' as const, icon: Sparkles, color: 'text-pink-400', label: 'Skill', labelPlural: 'Skills' },
|
|
50
|
+
{ key: 'agents' as const, icon: Bot, color: 'text-blue-400', label: 'Agent', labelPlural: 'Agents' },
|
|
51
|
+
{ key: 'hooks' as const, icon: Webhook, color: 'text-purple-400', label: 'Hook', labelPlural: 'Hooks' },
|
|
52
|
+
{ key: 'commands' as const, icon: Terminal, color: 'text-amber-400', label: 'Command', labelPlural: 'Commands' },
|
|
53
|
+
{ key: 'mcpServers' as const, icon: Plug, color: 'text-teal-400', label: 'MCP Server', labelPlural: 'MCP Servers' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const DISPLAYABLE_SCOPES: ReadonlySet<string> = new Set(['user', 'project', 'local'])
|
|
57
|
+
|
|
58
|
+
const ALREADY_AT_USER = 'Already installed at user level'
|
|
59
|
+
|
|
60
|
+
// ── Category badge helpers ────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const CATEGORY_LABEL_COLORS: LabelColor[] = [
|
|
63
|
+
'cyan', 'amber', 'violet', 'emerald', 'pink', 'blue',
|
|
64
|
+
'orange', 'teal', 'red', 'indigo', 'green', 'purple',
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
function getCategoryLabelColor(category: string): LabelColor {
|
|
68
|
+
let hash = 0
|
|
69
|
+
for (let i = 0; i < category.length; i++) {
|
|
70
|
+
hash = ((hash << 5) - hash + category.charCodeAt(i)) | 0
|
|
71
|
+
}
|
|
72
|
+
return CATEGORY_LABEL_COLORS[Math.abs(hash) % CATEGORY_LABEL_COLORS.length]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatCategory(category: string): string {
|
|
76
|
+
return category.replace(/(^|-)(\w)/g, (_, sep, ch) => sep + ch.toUpperCase())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Time helpers ──────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function formatRelativeTime(isoDate: string): string {
|
|
82
|
+
const diffMs = Date.now() - new Date(isoDate).getTime()
|
|
83
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
84
|
+
if (diffDays === 0) return 'today'
|
|
85
|
+
if (diffDays === 1) return '1d ago'
|
|
86
|
+
if (diffDays < 7) return `${diffDays}d ago`
|
|
87
|
+
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`
|
|
88
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`
|
|
89
|
+
return `${Math.floor(diffDays / 365)}y ago`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatFullDate(isoDate: string): string {
|
|
93
|
+
return new Date(isoDate).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatCount(n?: number): string {
|
|
97
|
+
if (n == null) return '0'
|
|
98
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
|
99
|
+
return String(n)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Scope helpers ─────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function computeSeedrScopes(
|
|
105
|
+
installedScopes: ReadonlySet<ExtensionSource> | undefined,
|
|
106
|
+
isUserScope: boolean,
|
|
107
|
+
) {
|
|
108
|
+
const isInstalledInViewScope = (() => {
|
|
109
|
+
if (!installedScopes || installedScopes.size === 0) return false
|
|
110
|
+
if (isUserScope) return installedScopes.has('user')
|
|
111
|
+
return installedScopes.has('project') || installedScopes.has('local')
|
|
112
|
+
})()
|
|
113
|
+
|
|
114
|
+
const removeScope: ExtensionSource | null = (() => {
|
|
115
|
+
if (!installedScopes || installedScopes.size === 0) return null
|
|
116
|
+
if (isUserScope && installedScopes.has('user')) return 'user'
|
|
117
|
+
if (!isUserScope) {
|
|
118
|
+
if (installedScopes.has('local')) return 'local'
|
|
119
|
+
if (installedScopes.has('project')) return 'project'
|
|
120
|
+
}
|
|
121
|
+
if (installedScopes.has('user')) return 'user'
|
|
122
|
+
if (installedScopes.has('project')) return 'project'
|
|
123
|
+
if (installedScopes.has('local')) return 'local'
|
|
124
|
+
return null
|
|
125
|
+
})()
|
|
126
|
+
|
|
127
|
+
const displayableScopes = installedScopes
|
|
128
|
+
? [...installedScopes].filter((s) => DISPLAYABLE_SCOPES.has(s))
|
|
129
|
+
: []
|
|
130
|
+
|
|
131
|
+
const isInstalled = displayableScopes.length > 0
|
|
132
|
+
|
|
133
|
+
return { isInstalled, isInstalledInViewScope, removeScope, displayableScopes }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function CardClickable({ children, onClick }: { children: ReactNode; onClick: (e: MouseEvent) => void }) {
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
role="button"
|
|
142
|
+
className="inline-flex cursor-pointer hover:brightness-125 transition-all"
|
|
143
|
+
onClick={(e) => {
|
|
144
|
+
e.stopPropagation()
|
|
145
|
+
onClick(e)
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
{children}
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function CategoryBadge({ category, onFilter }: { category: string; onFilter?: (category: string) => void }) {
|
|
154
|
+
return (
|
|
155
|
+
<Label
|
|
156
|
+
text={formatCategory(category)}
|
|
157
|
+
color={getCategoryLabelColor(category)}
|
|
158
|
+
icon="tag"
|
|
159
|
+
tooltip={{ description: onFilter ? `${formatCategory(category)} \u00b7 Click to filter` : formatCategory(category) }}
|
|
160
|
+
size="sm"
|
|
161
|
+
onClick={onFilter ? () => onFilter(category) : undefined}
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Badge row ────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const SOURCE_TYPE_BADGES: Record<string, { color: LabelColor; icon: IconName }> = {
|
|
169
|
+
official: { color: 'amber', icon: 'shield-check' },
|
|
170
|
+
toolr: { color: 'cyan', icon: 'sparkles' },
|
|
171
|
+
community: { color: 'violet', icon: 'users' },
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const PLUGIN_TYPE_BADGES: Record<string, { color: LabelColor; icon: IconName }> = {
|
|
175
|
+
package: { color: 'indigo', icon: 'package' },
|
|
176
|
+
wrapper: { color: 'teal', icon: 'puzzle' },
|
|
177
|
+
integration: { color: 'purple', icon: 'plug' },
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const SCOPE_ICONS: Record<string, IconName> = { user: 'home', project: 'folder', local: 'lock' }
|
|
181
|
+
|
|
182
|
+
interface RegistryCardBadgeRowProps {
|
|
183
|
+
sourceType?: string
|
|
184
|
+
pluginType?: string
|
|
185
|
+
category?: string
|
|
186
|
+
onFilterCategory?: (category: string) => void
|
|
187
|
+
isInstalled: boolean
|
|
188
|
+
isDisabled?: boolean
|
|
189
|
+
scopes: string[]
|
|
190
|
+
status?: SeedrItemStatus
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function RegistryCardBadgeRow({ sourceType, pluginType, category, onFilterCategory, isInstalled, isDisabled, scopes, status }: RegistryCardBadgeRowProps) {
|
|
194
|
+
const knownScopes = scopes.filter((s) => SCOPE_ICONS[s])
|
|
195
|
+
const scopeIcons = knownScopes.map((s) => SCOPE_ICONS[s]).filter(Boolean) as IconName[]
|
|
196
|
+
|
|
197
|
+
const hasContent = sourceType || pluginType || category || isInstalled || isDisabled || status?.updateAvailable || status?.modified
|
|
198
|
+
if (!hasContent) return null
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
202
|
+
{sourceType && SOURCE_TYPE_BADGES[sourceType] && (
|
|
203
|
+
<Label
|
|
204
|
+
text={sourceType}
|
|
205
|
+
color={SOURCE_TYPE_BADGES[sourceType].color}
|
|
206
|
+
icon={SOURCE_TYPE_BADGES[sourceType].icon}
|
|
207
|
+
tooltip={{ description: `Source: ${sourceType}` }}
|
|
208
|
+
size="sm"
|
|
209
|
+
textTransform="capitalize"
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
{pluginType && PLUGIN_TYPE_BADGES[pluginType] && (
|
|
213
|
+
<Label
|
|
214
|
+
text={pluginType}
|
|
215
|
+
color={PLUGIN_TYPE_BADGES[pluginType].color}
|
|
216
|
+
icon={PLUGIN_TYPE_BADGES[pluginType].icon}
|
|
217
|
+
tooltip={{ description: `Plugin type: ${pluginType}` }}
|
|
218
|
+
size="sm"
|
|
219
|
+
textTransform="capitalize"
|
|
220
|
+
/>
|
|
221
|
+
)}
|
|
222
|
+
{category && <CategoryBadge category={category} onFilter={onFilterCategory} />}
|
|
223
|
+
{isInstalled && !isDisabled && (
|
|
224
|
+
<Label
|
|
225
|
+
text="Added"
|
|
226
|
+
color="green"
|
|
227
|
+
icon={scopeIcons.length > 0 ? scopeIcons : 'check'}
|
|
228
|
+
tooltip={{ description: `Installed in: ${scopes.join(', ')}` }}
|
|
229
|
+
size="sm"
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
232
|
+
{isDisabled && (
|
|
233
|
+
<Label text="Disabled" color="red" icon="x" tooltip={{ description: 'Installed but disabled' }} size="sm" />
|
|
234
|
+
)}
|
|
235
|
+
{status?.updateAvailable && (
|
|
236
|
+
<Label text="Update" color="amber" icon="download" tooltip={{ description: 'Update available' }} size="sm" />
|
|
237
|
+
)}
|
|
238
|
+
{status?.modified && (
|
|
239
|
+
<Label text="Modified" color="violet" icon="pencil" tooltip={{ description: 'Locally modified' }} size="sm" />
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export interface SeedrItemStatus {
|
|
248
|
+
updateAvailable: boolean
|
|
249
|
+
modified: boolean
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface RegistryCardBase {
|
|
253
|
+
onClick: () => void
|
|
254
|
+
flash?: 'success' | 'error' | 'warning' | null
|
|
255
|
+
name: string
|
|
256
|
+
type: string
|
|
257
|
+
description?: string
|
|
258
|
+
errorMessage?: string
|
|
259
|
+
onInstall: () => void
|
|
260
|
+
isInstalling?: boolean
|
|
261
|
+
updatedAt?: string
|
|
262
|
+
onDateClick?: () => void
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface SeedrVariant extends RegistryCardBase {
|
|
266
|
+
variant: 'seedr'
|
|
267
|
+
author: string
|
|
268
|
+
sourceType: string
|
|
269
|
+
pluginType?: string
|
|
270
|
+
wrapper?: string
|
|
271
|
+
installedScopes?: ReadonlySet<ExtensionSource>
|
|
272
|
+
viewScope?: 'user' | 'project'
|
|
273
|
+
status?: SeedrItemStatus
|
|
274
|
+
compatibility: string[]
|
|
275
|
+
packageCounts?: Record<string, number>
|
|
276
|
+
isDisabled?: boolean
|
|
277
|
+
onActivate?: () => void
|
|
278
|
+
onRemove?: (scope: ExtensionSource) => void
|
|
279
|
+
onFilterSource?: () => void
|
|
280
|
+
onFilterPluginType?: (pluginType: string) => void
|
|
281
|
+
/** Render scope confirmation modal */
|
|
282
|
+
renderScopeConfirm?: (props: { isOpen: boolean; onClose: () => void; onConfirm: () => void; name: string }) => ReactNode
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface ClaudePluginsVariant extends RegistryCardBase {
|
|
286
|
+
variant: 'claude-plugins'
|
|
287
|
+
author: string
|
|
288
|
+
category?: string
|
|
289
|
+
stars?: number
|
|
290
|
+
downloads?: number
|
|
291
|
+
installedScopes?: ReadonlySet<ExtensionSource>
|
|
292
|
+
viewScope?: 'user' | 'project'
|
|
293
|
+
onRemove?: () => void
|
|
294
|
+
onSortBy?: (field: string) => void
|
|
295
|
+
onFilterCategory?: (category: string) => void
|
|
296
|
+
/** Tool keys compatible with this item type */
|
|
297
|
+
compatibleTools?: string[]
|
|
298
|
+
/** Render scope confirmation modal */
|
|
299
|
+
renderScopeConfirm?: (props: { isOpen: boolean; onClose: () => void; onConfirm: () => void; name: string }) => ReactNode
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
interface AitmplVariant extends RegistryCardBase {
|
|
303
|
+
variant: 'aitmpl'
|
|
304
|
+
category: string
|
|
305
|
+
installedScopes?: ReadonlySet<ExtensionSource>
|
|
306
|
+
viewScope?: 'user' | 'project'
|
|
307
|
+
onRemove?: () => void
|
|
308
|
+
onFilterCategory?: (category: string) => void
|
|
309
|
+
/** Render scope confirmation modal */
|
|
310
|
+
renderScopeConfirm?: (props: { isOpen: boolean; onClose: () => void; onConfirm: () => void; name: string }) => ReactNode
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
interface SkillsShVariant extends RegistryCardBase {
|
|
314
|
+
variant: 'skills-sh'
|
|
315
|
+
author: string
|
|
316
|
+
installs?: number
|
|
317
|
+
installedScopes?: ReadonlySet<ExtensionSource>
|
|
318
|
+
viewScope?: 'user' | 'project'
|
|
319
|
+
onRemove?: () => void
|
|
320
|
+
/** All tool keys to show in bottom row */
|
|
321
|
+
allToolKeys?: string[]
|
|
322
|
+
/** Render scope confirmation modal */
|
|
323
|
+
renderScopeConfirm?: (props: { isOpen: boolean; onClose: () => void; onConfirm: () => void; name: string }) => ReactNode
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export type RegistryCardProps = SeedrVariant | ClaudePluginsVariant | AitmplVariant | SkillsShVariant
|
|
327
|
+
|
|
328
|
+
// ── Main Component ────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
export function RegistryCard(props: RegistryCardProps) {
|
|
331
|
+
const { onClick, flash, name, type, description, errorMessage, isInstalling, updatedAt, onDateClick } = props
|
|
332
|
+
const [showScopeConfirm, setShowScopeConfirm] = useState(false)
|
|
333
|
+
|
|
334
|
+
const TypeIcon = TYPE_ICONS[type] || TYPE_ICONS.extension
|
|
335
|
+
const typeIconColor = SEEDR_ICON_COLORS[type] || 'text-neutral-400'
|
|
336
|
+
const typeLabel = SEEDR_TYPE_LABELS[type] || type
|
|
337
|
+
|
|
338
|
+
// ── Variant-specific rendering ──
|
|
339
|
+
|
|
340
|
+
let topLeft: ReactNode = null
|
|
341
|
+
let subtitle: ReactNode = null
|
|
342
|
+
let installButton: ReactNode = null
|
|
343
|
+
let bottomLogos: ReactNode[] = []
|
|
344
|
+
let bottomStats: ReactNode[] = []
|
|
345
|
+
let installedElsewhere = false
|
|
346
|
+
|
|
347
|
+
if (props.variant === 'seedr') {
|
|
348
|
+
const isUserScope = props.viewScope === 'user'
|
|
349
|
+
const { isInstalled, isInstalledInViewScope, removeScope, displayableScopes } = computeSeedrScopes(props.installedScopes, isUserScope)
|
|
350
|
+
installedElsewhere = isInstalled && !isInstalledInViewScope
|
|
351
|
+
|
|
352
|
+
topLeft = (
|
|
353
|
+
<RegistryCardBadgeRow
|
|
354
|
+
sourceType={props.sourceType}
|
|
355
|
+
pluginType={props.pluginType}
|
|
356
|
+
isInstalled={isInstalled}
|
|
357
|
+
isDisabled={props.isDisabled}
|
|
358
|
+
scopes={displayableScopes}
|
|
359
|
+
status={props.status}
|
|
360
|
+
/>
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
subtitle = <>by {props.author}</>
|
|
364
|
+
|
|
365
|
+
installButton = props.isDisabled ? (
|
|
366
|
+
<IconButton
|
|
367
|
+
icon={<Power className="w-3.5 h-3.5" />}
|
|
368
|
+
onClick={() => props.onActivate?.()}
|
|
369
|
+
size="sm"
|
|
370
|
+
color="green"
|
|
371
|
+
tooltip={{ description: `Activate ${name}` }}
|
|
372
|
+
/>
|
|
373
|
+
) : isInstalledInViewScope && removeScope ? (
|
|
374
|
+
<IconButton
|
|
375
|
+
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
376
|
+
onClick={() => props.onRemove?.(removeScope)}
|
|
377
|
+
size="sm"
|
|
378
|
+
color="red"
|
|
379
|
+
tooltip={{ description: `Remove ${name} from ${removeScope}` }}
|
|
380
|
+
/>
|
|
381
|
+
) : installedElsewhere ? (
|
|
382
|
+
<IconButton
|
|
383
|
+
icon={<FolderPlus className="w-3.5 h-3.5" />}
|
|
384
|
+
onClick={() => setShowScopeConfirm(true)}
|
|
385
|
+
size="sm"
|
|
386
|
+
color="blue"
|
|
387
|
+
tooltip={{ description: ALREADY_AT_USER }}
|
|
388
|
+
/>
|
|
389
|
+
) : (
|
|
390
|
+
<IconButton
|
|
391
|
+
icon={<Plus className="w-3.5 h-3.5" />}
|
|
392
|
+
onClick={props.onInstall}
|
|
393
|
+
size="sm"
|
|
394
|
+
color="green"
|
|
395
|
+
tooltip={{ description: `Add ${name}` }}
|
|
396
|
+
/>
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
bottomLogos = props.compatibility.filter((t) => t in AI_TOOL_NAMES).map((tool) => (
|
|
400
|
+
<Tooltip key={tool} content={{ description: `Compatible with ${AI_TOOL_NAMES[tool as AiToolKey] ?? tool}` }} position="top">
|
|
401
|
+
<AiToolIcon tool={tool} size={16} />
|
|
402
|
+
</Tooltip>
|
|
403
|
+
))
|
|
404
|
+
|
|
405
|
+
const counts = props.packageCounts ?? (props.wrapper ? { [props.wrapper]: 1 } : undefined)
|
|
406
|
+
if (counts) {
|
|
407
|
+
bottomStats = SEEDR_CONTENT_TYPES.flatMap(({ key, icon: Icon, color, label, labelPlural }) => {
|
|
408
|
+
const typeKey = key === 'mcpServers' ? 'mcp' : key.replace(/s$/, '')
|
|
409
|
+
const count = counts[typeKey]
|
|
410
|
+
if (!count || count <= 0) return []
|
|
411
|
+
return [(
|
|
412
|
+
<Tooltip key={`pkg-${key}`} content={{ description: `${count} ${count === 1 ? label : labelPlural}` }} position="top">
|
|
413
|
+
<span className="flex items-center gap-0.5">
|
|
414
|
+
<Icon className={`w-3 h-3 ${color}`} />
|
|
415
|
+
<span className="text-[10px] text-neutral-500">{count}</span>
|
|
416
|
+
</span>
|
|
417
|
+
</Tooltip>
|
|
418
|
+
)]
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
} else if (props.variant === 'claude-plugins') {
|
|
422
|
+
const isUserScope = props.viewScope === 'user'
|
|
423
|
+
const { isInstalled, isInstalledInViewScope, removeScope, displayableScopes } = computeSeedrScopes(props.installedScopes, isUserScope)
|
|
424
|
+
installedElsewhere = isInstalled && !isInstalledInViewScope
|
|
425
|
+
|
|
426
|
+
topLeft = (
|
|
427
|
+
<RegistryCardBadgeRow
|
|
428
|
+
category={props.category}
|
|
429
|
+
onFilterCategory={props.onFilterCategory}
|
|
430
|
+
isInstalled={isInstalled}
|
|
431
|
+
scopes={displayableScopes}
|
|
432
|
+
/>
|
|
433
|
+
)
|
|
434
|
+
subtitle = <>by {props.author}</>
|
|
435
|
+
|
|
436
|
+
installButton = isInstalledInViewScope && removeScope ? (
|
|
437
|
+
<IconButton
|
|
438
|
+
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
439
|
+
onClick={() => props.onRemove?.()}
|
|
440
|
+
size="sm"
|
|
441
|
+
color="red"
|
|
442
|
+
tooltip={{ description: `Remove ${name} from ${removeScope}` }}
|
|
443
|
+
/>
|
|
444
|
+
) : installedElsewhere ? (
|
|
445
|
+
<IconButton
|
|
446
|
+
icon={<FolderPlus className="w-3.5 h-3.5" />}
|
|
447
|
+
onClick={() => setShowScopeConfirm(true)}
|
|
448
|
+
size="sm"
|
|
449
|
+
color="blue"
|
|
450
|
+
tooltip={{ description: ALREADY_AT_USER }}
|
|
451
|
+
/>
|
|
452
|
+
) : (
|
|
453
|
+
<IconButton
|
|
454
|
+
icon={<Plus className="w-3.5 h-3.5" />}
|
|
455
|
+
onClick={props.onInstall}
|
|
456
|
+
size="sm"
|
|
457
|
+
color="green"
|
|
458
|
+
tooltip={{ description: `Add ${name}` }}
|
|
459
|
+
/>
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
const compatibleTools = props.compatibleTools ?? []
|
|
463
|
+
bottomLogos = compatibleTools.map((tool) => (
|
|
464
|
+
<Tooltip key={tool} content={{ description: `Compatible with ${AI_TOOL_NAMES[tool as AiToolKey] ?? tool}` }} position="top">
|
|
465
|
+
<AiToolIcon tool={tool} size={16} />
|
|
466
|
+
</Tooltip>
|
|
467
|
+
))
|
|
468
|
+
bottomStats = [
|
|
469
|
+
...(props.stars != null && props.stars > 0 ? [(
|
|
470
|
+
<CardClickable key="stars" onClick={() => props.onSortBy?.('stars')}>
|
|
471
|
+
<Tooltip content={{ description: `${props.stars.toLocaleString()} stars \u00b7 Click to sort` }} position="top">
|
|
472
|
+
<span className="flex items-center gap-1 text-[10px] text-amber-400/80">
|
|
473
|
+
<Star className="w-3 h-3" />
|
|
474
|
+
{formatCount(props.stars)}
|
|
475
|
+
</span>
|
|
476
|
+
</Tooltip>
|
|
477
|
+
</CardClickable>
|
|
478
|
+
)] : []),
|
|
479
|
+
...(props.downloads != null && props.downloads > 0 ? [(
|
|
480
|
+
<CardClickable key="downloads" onClick={() => props.onSortBy?.('downloads')}>
|
|
481
|
+
<Tooltip content={{ description: `${props.downloads.toLocaleString()} downloads \u00b7 Click to sort` }} position="top">
|
|
482
|
+
<span className="flex items-center gap-1 text-[10px] text-emerald-400/80">
|
|
483
|
+
<Download className="w-3 h-3" />
|
|
484
|
+
{formatCount(props.downloads)}
|
|
485
|
+
</span>
|
|
486
|
+
</Tooltip>
|
|
487
|
+
</CardClickable>
|
|
488
|
+
)] : []),
|
|
489
|
+
]
|
|
490
|
+
} else if (props.variant === 'aitmpl') {
|
|
491
|
+
const isUserScope = props.viewScope === 'user'
|
|
492
|
+
const { isInstalled, isInstalledInViewScope, removeScope, displayableScopes } = computeSeedrScopes(props.installedScopes, isUserScope)
|
|
493
|
+
installedElsewhere = isInstalled && !isInstalledInViewScope
|
|
494
|
+
|
|
495
|
+
topLeft = (
|
|
496
|
+
<RegistryCardBadgeRow
|
|
497
|
+
category={props.category}
|
|
498
|
+
onFilterCategory={props.onFilterCategory}
|
|
499
|
+
isInstalled={isInstalled}
|
|
500
|
+
scopes={displayableScopes}
|
|
501
|
+
/>
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
installButton = isInstalledInViewScope && removeScope ? (
|
|
505
|
+
<IconButton
|
|
506
|
+
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
507
|
+
onClick={() => props.onRemove?.()}
|
|
508
|
+
size="sm"
|
|
509
|
+
color="red"
|
|
510
|
+
tooltip={{ description: `Remove ${name} from ${removeScope}` }}
|
|
511
|
+
/>
|
|
512
|
+
) : installedElsewhere ? (
|
|
513
|
+
<IconButton
|
|
514
|
+
icon={<FolderPlus className="w-3.5 h-3.5" />}
|
|
515
|
+
onClick={() => setShowScopeConfirm(true)}
|
|
516
|
+
size="sm"
|
|
517
|
+
color="blue"
|
|
518
|
+
tooltip={{ description: ALREADY_AT_USER }}
|
|
519
|
+
/>
|
|
520
|
+
) : (
|
|
521
|
+
<IconButton
|
|
522
|
+
icon={<Plus className="w-3.5 h-3.5" />}
|
|
523
|
+
onClick={props.onInstall}
|
|
524
|
+
size="sm"
|
|
525
|
+
color="green"
|
|
526
|
+
tooltip={{ description: `Add ${name}` }}
|
|
527
|
+
/>
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
bottomLogos = [(
|
|
531
|
+
<Tooltip key="claude" content={{ description: 'Compatible with Claude Code' }} position="top">
|
|
532
|
+
<AiToolIcon tool="claude" size={16} />
|
|
533
|
+
</Tooltip>
|
|
534
|
+
)]
|
|
535
|
+
} else if (props.variant === 'skills-sh') {
|
|
536
|
+
const isUserScope = props.viewScope === 'user'
|
|
537
|
+
const { isInstalled, isInstalledInViewScope, removeScope, displayableScopes } = computeSeedrScopes(props.installedScopes, isUserScope)
|
|
538
|
+
installedElsewhere = isInstalled && !isInstalledInViewScope
|
|
539
|
+
|
|
540
|
+
topLeft = (
|
|
541
|
+
<RegistryCardBadgeRow
|
|
542
|
+
isInstalled={isInstalled}
|
|
543
|
+
scopes={displayableScopes}
|
|
544
|
+
/>
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
subtitle = <>by {props.author}</>
|
|
548
|
+
|
|
549
|
+
installButton = isInstalledInViewScope && removeScope ? (
|
|
550
|
+
<IconButton
|
|
551
|
+
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
552
|
+
onClick={() => props.onRemove?.()}
|
|
553
|
+
size="sm"
|
|
554
|
+
color="red"
|
|
555
|
+
tooltip={{ description: `Remove ${name} from ${removeScope}` }}
|
|
556
|
+
/>
|
|
557
|
+
) : installedElsewhere ? (
|
|
558
|
+
<IconButton
|
|
559
|
+
icon={<FolderPlus className="w-3.5 h-3.5" />}
|
|
560
|
+
onClick={() => setShowScopeConfirm(true)}
|
|
561
|
+
size="sm"
|
|
562
|
+
color="blue"
|
|
563
|
+
tooltip={{ description: ALREADY_AT_USER }}
|
|
564
|
+
/>
|
|
565
|
+
) : (
|
|
566
|
+
<IconButton
|
|
567
|
+
icon={<Plus className="w-3.5 h-3.5" />}
|
|
568
|
+
onClick={props.onInstall}
|
|
569
|
+
size="sm"
|
|
570
|
+
color="green"
|
|
571
|
+
tooltip={{ description: `Add ${name}` }}
|
|
572
|
+
/>
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
const allTools = props.allToolKeys ?? []
|
|
576
|
+
bottomLogos = allTools.map((tool) => (
|
|
577
|
+
<Tooltip key={tool} content={{ description: `Compatible with ${AI_TOOL_NAMES[tool as AiToolKey] ?? tool}` }} position="top">
|
|
578
|
+
<AiToolIcon tool={tool} size={14} />
|
|
579
|
+
</Tooltip>
|
|
580
|
+
))
|
|
581
|
+
if (props.installs != null && props.installs > 0) {
|
|
582
|
+
bottomStats = [(
|
|
583
|
+
<Tooltip key="installs" content={{ description: `${props.installs.toLocaleString()} installs` }} position="top">
|
|
584
|
+
<span className="flex items-center gap-1 text-[10px] text-neutral-500">
|
|
585
|
+
<Download className="w-3 h-3" />
|
|
586
|
+
{props.installs.toLocaleString()}
|
|
587
|
+
</span>
|
|
588
|
+
</Tooltip>
|
|
589
|
+
)]
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── Render ──
|
|
594
|
+
|
|
595
|
+
const borderClass = flash === 'success'
|
|
596
|
+
? 'border-green-500/60 ring-1 ring-green-500/30 bg-green-500/5'
|
|
597
|
+
: flash === 'error'
|
|
598
|
+
? 'border-red-500/60 ring-1 ring-red-500/30 bg-red-500/5'
|
|
599
|
+
: flash === 'warning'
|
|
600
|
+
? 'border-amber-500/60 ring-1 ring-amber-500/30 bg-amber-500/5'
|
|
601
|
+
: 'border-neutral-700'
|
|
602
|
+
|
|
603
|
+
return (
|
|
604
|
+
<div
|
|
605
|
+
role="button"
|
|
606
|
+
tabIndex={0}
|
|
607
|
+
className={`group text-left w-full cursor-pointer border rounded-lg p-3 transition-all duration-500 hover:border-neutral-600 flex flex-col bg-neutral-800/50 hover:bg-neutral-800 ${borderClass}`}
|
|
608
|
+
onClick={onClick}
|
|
609
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } }}
|
|
610
|
+
>
|
|
611
|
+
{/* Top row */}
|
|
612
|
+
<div className="flex items-start justify-between gap-2 mb-2">
|
|
613
|
+
<div className="flex-1 min-w-0 min-h-7 overflow-hidden">
|
|
614
|
+
{topLeft}
|
|
615
|
+
</div>
|
|
616
|
+
<div className="relative w-7 h-7 flex items-center justify-center shrink-0">
|
|
617
|
+
{isInstalling ? (
|
|
618
|
+
<Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
|
|
619
|
+
) : (
|
|
620
|
+
<>
|
|
621
|
+
<Tooltip content={{ description: `${typeLabel} extension` }} position="top">
|
|
622
|
+
<TypeIcon className={`w-4 h-4 group-hover:opacity-0 transition-opacity ${typeIconColor}`} />
|
|
623
|
+
</Tooltip>
|
|
624
|
+
{installButton && (
|
|
625
|
+
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
|
|
626
|
+
{installButton}
|
|
627
|
+
</div>
|
|
628
|
+
)}
|
|
629
|
+
</>
|
|
630
|
+
)}
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
{/* Name */}
|
|
635
|
+
<h3 className="text-sm font-medium text-white">{name}</h3>
|
|
636
|
+
|
|
637
|
+
{/* Subtitle */}
|
|
638
|
+
{subtitle && <div className="text-xs text-neutral-400 mb-1.5">{subtitle}</div>}
|
|
639
|
+
|
|
640
|
+
{/* Description */}
|
|
641
|
+
{description && <p className="text-xs text-neutral-500 mb-3 flex-grow line-clamp-2">{description}</p>}
|
|
642
|
+
|
|
643
|
+
{/* Error/warning message */}
|
|
644
|
+
{errorMessage && (
|
|
645
|
+
<p className={`text-[11px] mb-2 break-all ${flash === 'warning' ? 'text-amber-400' : 'text-red-400'}`}>{errorMessage}</p>
|
|
646
|
+
)}
|
|
647
|
+
|
|
648
|
+
{/* Bottom row */}
|
|
649
|
+
{(bottomLogos.length > 0 || bottomStats.length > 0 || updatedAt) && (() => {
|
|
650
|
+
const dateNode = updatedAt ? (
|
|
651
|
+
<Tooltip content={{ description: onDateClick ? `Last updated ${formatFullDate(updatedAt)} \u00b7 Click to sort by date` : `Last updated ${formatFullDate(updatedAt)}` }} position="top">
|
|
652
|
+
<span
|
|
653
|
+
className={`flex items-center gap-1 text-[10px] text-neutral-500 whitespace-nowrap${onDateClick ? ' cursor-pointer hover:brightness-125 transition-all' : ''}`}
|
|
654
|
+
onClick={onDateClick ? (e: MouseEvent) => { e.stopPropagation(); onDateClick() } : undefined}
|
|
655
|
+
>
|
|
656
|
+
<Clock className="w-3 h-3" />
|
|
657
|
+
{formatRelativeTime(updatedAt)}
|
|
658
|
+
</span>
|
|
659
|
+
</Tooltip>
|
|
660
|
+
) : null
|
|
661
|
+
|
|
662
|
+
const hasStats = bottomStats.length > 0
|
|
663
|
+
|
|
664
|
+
return (
|
|
665
|
+
<div className="mt-auto pt-1 flex items-end">
|
|
666
|
+
<div className={`${hasStats ? 'w-1/3' : 'w-2/3'} flex flex-wrap gap-1.5 content-end`}>
|
|
667
|
+
{bottomLogos}
|
|
668
|
+
</div>
|
|
669
|
+
{hasStats && (
|
|
670
|
+
<div className="w-1/3 flex justify-center items-end gap-2">
|
|
671
|
+
{bottomStats}
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
<div className="w-1/3 flex justify-end items-end">
|
|
675
|
+
{dateNode}
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
)
|
|
679
|
+
})()}
|
|
680
|
+
|
|
681
|
+
{showScopeConfirm && (() => {
|
|
682
|
+
// Use custom renderScopeConfirm if provided, otherwise show a basic confirm
|
|
683
|
+
if ('renderScopeConfirm' in props && props.renderScopeConfirm) {
|
|
684
|
+
return (
|
|
685
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
686
|
+
{props.renderScopeConfirm({ isOpen: true, onClose: () => setShowScopeConfirm(false), onConfirm: props.onInstall, name })}
|
|
687
|
+
</div>
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
// Basic fallback: just install on confirm
|
|
691
|
+
return (
|
|
692
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
693
|
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onClick={() => setShowScopeConfirm(false)}>
|
|
694
|
+
<div className="bg-neutral-800 border border-neutral-700 rounded-lg p-4 max-w-sm" onClick={(e) => e.stopPropagation()}>
|
|
695
|
+
<h3 className="text-sm font-medium text-neutral-200 mb-2">{ALREADY_AT_USER}</h3>
|
|
696
|
+
<p className="text-xs text-neutral-400 mb-4">
|
|
697
|
+
<strong className="text-neutral-300">{name}</strong> is already installed at user level and available to all projects. Do you also want to install it at project level?
|
|
698
|
+
</p>
|
|
699
|
+
<div className="flex justify-end gap-2">
|
|
700
|
+
<button type="button" onClick={() => setShowScopeConfirm(false)} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors cursor-pointer">Cancel</button>
|
|
701
|
+
<button type="button" onClick={() => { setShowScopeConfirm(false); props.onInstall() }} className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-500 transition-colors cursor-pointer">Install to project</button>
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
)
|
|
707
|
+
})()}
|
|
708
|
+
</div>
|
|
709
|
+
)
|
|
710
|
+
}
|