@nextclaw/ui 0.5.1 → 0.5.2
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/CHANGELOG.md +6 -0
- package/dist/assets/index-CKTsCtI-.css +1 -0
- package/dist/assets/index-D8W5lAHk.js +337 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/api/marketplace.ts +10 -0
- package/src/api/types.ts +25 -8
- package/src/components/config/ChannelForm.tsx +14 -10
- package/src/components/config/ProviderForm.tsx +17 -16
- package/src/components/config/RuntimeConfig.tsx +33 -27
- package/src/components/doc-browser/DocBrowser.tsx +51 -5
- package/src/components/doc-browser/DocBrowserContext.tsx +33 -10
- package/src/components/marketplace/MarketplacePage.tsx +422 -65
- package/src/hooks/useMarketplace.ts +18 -1
- package/dist/assets/index-B6sMhZ9q.css +0 -1
- package/dist/assets/index-B_-o3-kG.js +0 -337
|
@@ -2,8 +2,9 @@ import { useEffect, useMemo, useState } from 'react';
|
|
|
2
2
|
import { Download, PackageSearch, Sparkles, Store } from 'lucide-react';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import { Tabs } from '@/components/ui/tabs-custom';
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
6
|
+
import { useInstallMarketplaceItem, useManageMarketplaceItem, useMarketplaceInstalled, useMarketplaceItems, useMarketplaceRecommendations } from '@/hooks/useMarketplace';
|
|
7
|
+
import type { MarketplaceInstalledRecord, MarketplaceItemSummary, MarketplaceManageAction, MarketplaceSort } from '@/api/types';
|
|
7
8
|
|
|
8
9
|
const PAGE_SIZE = 12;
|
|
9
10
|
|
|
@@ -15,22 +16,156 @@ type InstallState = {
|
|
|
15
16
|
installingSpec?: string;
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
type ManageState = {
|
|
20
|
+
isPending: boolean;
|
|
21
|
+
targetId?: string;
|
|
22
|
+
action?: MarketplaceManageAction;
|
|
23
|
+
};
|
|
24
|
+
|
|
18
25
|
type InstalledSpecSets = {
|
|
19
26
|
plugin: Set<string>;
|
|
20
27
|
skill: Set<string>;
|
|
21
28
|
};
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
type InstalledRenderEntry = {
|
|
31
|
+
key: string;
|
|
32
|
+
record: MarketplaceInstalledRecord;
|
|
33
|
+
item?: MarketplaceItemSummary;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function normalizeMarketplaceKey(value: string | undefined): string {
|
|
37
|
+
return (value ?? '').trim().toLowerCase();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toLookupKey(type: MarketplaceItemSummary['type'], value: string | undefined): string {
|
|
41
|
+
const normalized = normalizeMarketplaceKey(value);
|
|
42
|
+
return normalized.length > 0 ? `${type}:${normalized}` : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildInstalledSpecSets(
|
|
46
|
+
records: { pluginSpecs: string[]; skillSpecs: string[]; records: MarketplaceInstalledRecord[] } | undefined
|
|
47
|
+
): InstalledSpecSets {
|
|
48
|
+
const plugin = new Set((records?.pluginSpecs ?? []).map((value) => normalizeMarketplaceKey(value)).filter(Boolean));
|
|
49
|
+
const skill = new Set((records?.skillSpecs ?? []).map((value) => normalizeMarketplaceKey(value)).filter(Boolean));
|
|
50
|
+
|
|
51
|
+
for (const record of records?.records ?? []) {
|
|
52
|
+
const target = record.type === 'plugin' ? plugin : skill;
|
|
53
|
+
const specKey = normalizeMarketplaceKey(record.spec);
|
|
54
|
+
if (specKey) {
|
|
55
|
+
target.add(specKey);
|
|
56
|
+
}
|
|
57
|
+
const labelKey = normalizeMarketplaceKey(record.label);
|
|
58
|
+
if (labelKey) {
|
|
59
|
+
target.add(labelKey);
|
|
60
|
+
}
|
|
61
|
+
const idKey = normalizeMarketplaceKey(record.id);
|
|
62
|
+
if (idKey) {
|
|
63
|
+
target.add(idKey);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { plugin, skill };
|
|
28
68
|
}
|
|
29
69
|
|
|
30
70
|
function isInstalled(item: MarketplaceItemSummary, sets: InstalledSpecSets): boolean {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
71
|
+
const target = item.type === 'plugin' ? sets.plugin : sets.skill;
|
|
72
|
+
const candidateKeys = [item.install.spec, item.slug, item.id].map((value) => normalizeMarketplaceKey(value)).filter(Boolean);
|
|
73
|
+
return candidateKeys.some((key) => target.has(key));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildCatalogLookup(items: MarketplaceItemSummary[]): Map<string, MarketplaceItemSummary> {
|
|
77
|
+
const lookup = new Map<string, MarketplaceItemSummary>();
|
|
78
|
+
|
|
79
|
+
for (const item of items) {
|
|
80
|
+
const candidates = [item.install.spec, item.slug, item.id];
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
const lookupKey = toLookupKey(item.type, candidate);
|
|
83
|
+
if (!lookupKey || lookup.has(lookupKey)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
lookup.set(lookupKey, item);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return lookup;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildInstalledRecordLookup(records: MarketplaceInstalledRecord[]): Map<string, MarketplaceInstalledRecord> {
|
|
94
|
+
const lookup = new Map<string, MarketplaceInstalledRecord>();
|
|
95
|
+
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
const candidates = [record.spec, record.label, record.id];
|
|
98
|
+
for (const candidate of candidates) {
|
|
99
|
+
const lookupKey = toLookupKey(record.type, candidate);
|
|
100
|
+
if (!lookupKey || lookup.has(lookupKey)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
lookup.set(lookupKey, record);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return lookup;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findCatalogItemForRecord(
|
|
111
|
+
record: MarketplaceInstalledRecord,
|
|
112
|
+
catalogLookup: Map<string, MarketplaceItemSummary>
|
|
113
|
+
): MarketplaceItemSummary | undefined {
|
|
114
|
+
const bySpec = catalogLookup.get(toLookupKey(record.type, record.spec));
|
|
115
|
+
if (bySpec) {
|
|
116
|
+
return bySpec;
|
|
117
|
+
}
|
|
118
|
+
const byId = catalogLookup.get(toLookupKey(record.type, record.id));
|
|
119
|
+
if (byId) {
|
|
120
|
+
return byId;
|
|
121
|
+
}
|
|
122
|
+
return catalogLookup.get(toLookupKey(record.type, record.label));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findInstalledRecordForItem(
|
|
126
|
+
item: MarketplaceItemSummary,
|
|
127
|
+
installedRecordLookup: Map<string, MarketplaceInstalledRecord>
|
|
128
|
+
): MarketplaceInstalledRecord | undefined {
|
|
129
|
+
const candidates = [item.install.spec, item.slug, item.id];
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
const lookupKey = toLookupKey(item.type, candidate);
|
|
132
|
+
if (!lookupKey) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const record = installedRecordLookup.get(lookupKey);
|
|
136
|
+
if (record) {
|
|
137
|
+
return record;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function matchInstalledSearch(
|
|
144
|
+
record: MarketplaceInstalledRecord,
|
|
145
|
+
item: MarketplaceItemSummary | undefined,
|
|
146
|
+
query: string
|
|
147
|
+
): boolean {
|
|
148
|
+
const normalizedQuery = normalizeMarketplaceKey(query);
|
|
149
|
+
if (!normalizedQuery) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const values = [
|
|
154
|
+
record.id,
|
|
155
|
+
record.spec,
|
|
156
|
+
record.label,
|
|
157
|
+
record.source,
|
|
158
|
+
record.runtimeStatus,
|
|
159
|
+
item?.name,
|
|
160
|
+
item?.slug,
|
|
161
|
+
item?.summary,
|
|
162
|
+
...(item?.tags ?? [])
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
return values
|
|
166
|
+
.map((value) => normalizeMarketplaceKey(value))
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.some((value) => value.includes(normalizedQuery));
|
|
34
169
|
}
|
|
35
170
|
|
|
36
171
|
function TypeBadge({ type }: { type: MarketplaceItemSummary['type'] }) {
|
|
@@ -50,6 +185,54 @@ function InstalledBadge() {
|
|
|
50
185
|
return <span className="text-[11px] px-2 py-1 rounded-full font-semibold bg-indigo-50 text-indigo-600">Installed</span>;
|
|
51
186
|
}
|
|
52
187
|
|
|
188
|
+
function EnabledStateBadge(props: { enabled?: boolean }) {
|
|
189
|
+
if (props.enabled === undefined) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return props.enabled
|
|
194
|
+
? <span className="text-[11px] px-2 py-1 rounded-full font-semibold bg-emerald-50 text-emerald-600">Enabled</span>
|
|
195
|
+
: <span className="text-[11px] px-2 py-1 rounded-full font-semibold bg-amber-50 text-amber-700">Disabled</span>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function RecordActionButtons(props: {
|
|
199
|
+
record: MarketplaceInstalledRecord;
|
|
200
|
+
state: ManageState;
|
|
201
|
+
onAction: (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => void;
|
|
202
|
+
}) {
|
|
203
|
+
const targetId = props.record.id || props.record.spec;
|
|
204
|
+
const busyForRecord = props.state.isPending && props.state.targetId === targetId;
|
|
205
|
+
const canToggle = props.record.type === 'plugin';
|
|
206
|
+
const canUninstallPlugin = props.record.type === 'plugin' && props.record.origin !== 'bundled';
|
|
207
|
+
const canUninstallSkill = props.record.type === 'skill' && props.record.source === 'workspace';
|
|
208
|
+
const canUninstall = canUninstallPlugin || canUninstallSkill;
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div className="flex items-center gap-2">
|
|
212
|
+
{canToggle && (
|
|
213
|
+
<button
|
|
214
|
+
disabled={props.state.isPending}
|
|
215
|
+
onClick={() => props.onAction(props.record.enabled === false ? 'enable' : 'disable', props.record)}
|
|
216
|
+
className="inline-flex items-center h-8 px-3 rounded-lg text-xs font-semibold border border-gray-200 text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
217
|
+
>
|
|
218
|
+
{busyForRecord && props.state.action !== 'uninstall'
|
|
219
|
+
? (props.state.action === 'enable' ? 'Enabling...' : 'Disabling...')
|
|
220
|
+
: (props.record.enabled === false ? 'Enable' : 'Disable')}
|
|
221
|
+
</button>
|
|
222
|
+
)}
|
|
223
|
+
{canUninstall && (
|
|
224
|
+
<button
|
|
225
|
+
disabled={props.state.isPending}
|
|
226
|
+
onClick={() => props.onAction('uninstall', props.record)}
|
|
227
|
+
className="inline-flex items-center h-8 px-3 rounded-lg text-xs font-semibold border border-rose-200 text-rose-600 bg-white hover:bg-rose-50 disabled:opacity-50"
|
|
228
|
+
>
|
|
229
|
+
{busyForRecord && props.state.action === 'uninstall' ? 'Uninstalling...' : 'Uninstall'}
|
|
230
|
+
</button>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
53
236
|
function FilterPanel(props: {
|
|
54
237
|
searchText: string;
|
|
55
238
|
typeFilter: FilterType;
|
|
@@ -60,37 +243,49 @@ function FilterPanel(props: {
|
|
|
60
243
|
}) {
|
|
61
244
|
return (
|
|
62
245
|
<div className="bg-white border border-gray-200 rounded-2xl p-4 mb-5">
|
|
63
|
-
<div className="
|
|
64
|
-
<div className="
|
|
246
|
+
<div className="flex gap-3 items-center">
|
|
247
|
+
<div className="flex-1 min-w-0 relative">
|
|
65
248
|
<PackageSearch className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
|
66
249
|
<input
|
|
67
250
|
value={props.searchText}
|
|
68
251
|
onChange={(event) => props.onSearchTextChange(event.target.value)}
|
|
69
252
|
placeholder="Search by name, slug, tags..."
|
|
70
|
-
className="w-full h-
|
|
253
|
+
className="w-full h-9 border border-gray-200 rounded-lg pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
|
71
254
|
/>
|
|
72
255
|
</div>
|
|
73
256
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
value
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
257
|
+
{/* Segmented type filter */}
|
|
258
|
+
<div className="inline-flex h-9 rounded-lg bg-gray-100 p-0.5 shrink-0">
|
|
259
|
+
{([
|
|
260
|
+
{ value: 'all', label: 'All' },
|
|
261
|
+
{ value: 'plugin', label: 'Plugins' },
|
|
262
|
+
{ value: 'skill', label: 'Skills' },
|
|
263
|
+
] as const).map((opt) => (
|
|
264
|
+
<button
|
|
265
|
+
key={opt.value}
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={() => props.onTypeFilterChange(opt.value)}
|
|
268
|
+
className={cn(
|
|
269
|
+
'px-3 rounded-md text-sm font-medium transition-all whitespace-nowrap',
|
|
270
|
+
props.typeFilter === opt.value
|
|
271
|
+
? 'bg-white text-gray-900 shadow-sm'
|
|
272
|
+
: 'text-gray-500 hover:text-gray-700'
|
|
273
|
+
)}
|
|
274
|
+
>
|
|
275
|
+
{opt.label}
|
|
276
|
+
</button>
|
|
277
|
+
))}
|
|
93
278
|
</div>
|
|
279
|
+
|
|
280
|
+
<Select value={props.sort} onValueChange={(v) => props.onSortChange(v as MarketplaceSort)}>
|
|
281
|
+
<SelectTrigger className="h-9 w-[150px] shrink-0 rounded-lg">
|
|
282
|
+
<SelectValue />
|
|
283
|
+
</SelectTrigger>
|
|
284
|
+
<SelectContent>
|
|
285
|
+
<SelectItem value="relevance">Relevance</SelectItem>
|
|
286
|
+
<SelectItem value="updated">Recently Updated</SelectItem>
|
|
287
|
+
</SelectContent>
|
|
288
|
+
</Select>
|
|
94
289
|
</div>
|
|
95
290
|
</div>
|
|
96
291
|
);
|
|
@@ -178,12 +373,13 @@ function RecommendationSection(props: {
|
|
|
178
373
|
|
|
179
374
|
function MarketplaceItemCard(props: {
|
|
180
375
|
item: MarketplaceItemSummary;
|
|
376
|
+
installedRecord?: MarketplaceInstalledRecord;
|
|
181
377
|
installState: InstallState;
|
|
378
|
+
manageState: ManageState;
|
|
182
379
|
installed: boolean;
|
|
183
380
|
onInstall: (item: MarketplaceItemSummary) => void;
|
|
381
|
+
onManage: (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => void;
|
|
184
382
|
}) {
|
|
185
|
-
const downloads = props.item.metrics?.downloads30d;
|
|
186
|
-
|
|
187
383
|
return (
|
|
188
384
|
<article className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow">
|
|
189
385
|
<div className="flex items-start justify-between gap-2">
|
|
@@ -191,6 +387,7 @@ function MarketplaceItemCard(props: {
|
|
|
191
387
|
<div className="flex items-center gap-2">
|
|
192
388
|
<TypeBadge type={props.item.type} />
|
|
193
389
|
{props.installed && <InstalledBadge />}
|
|
390
|
+
{props.installed && <EnabledStateBadge enabled={props.installedRecord?.enabled} />}
|
|
194
391
|
</div>
|
|
195
392
|
</div>
|
|
196
393
|
|
|
@@ -205,15 +402,59 @@ function MarketplaceItemCard(props: {
|
|
|
205
402
|
</div>
|
|
206
403
|
|
|
207
404
|
<div className="mt-3 text-[11px] text-gray-500">By {props.item.author}</div>
|
|
208
|
-
<div className="mt-1 text-[11px] text-gray-500">{downloads ? `${downloads} downloads / 30d` : 'No metrics'}</div>
|
|
209
405
|
|
|
210
406
|
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between gap-2">
|
|
211
407
|
<code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1 truncate">{props.item.install.spec}</code>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
408
|
+
{props.installed && props.installedRecord ? (
|
|
409
|
+
<RecordActionButtons
|
|
410
|
+
record={props.installedRecord}
|
|
411
|
+
state={props.manageState}
|
|
412
|
+
onAction={props.onManage}
|
|
413
|
+
/>
|
|
414
|
+
) : (
|
|
415
|
+
<InstallButton
|
|
416
|
+
item={props.item}
|
|
417
|
+
installed={props.installed}
|
|
418
|
+
installState={props.installState}
|
|
419
|
+
onInstall={props.onInstall}
|
|
420
|
+
/>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
</article>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function InstalledRecordCard(props: {
|
|
428
|
+
record: MarketplaceInstalledRecord;
|
|
429
|
+
manageState: ManageState;
|
|
430
|
+
onManage: (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => void;
|
|
431
|
+
}) {
|
|
432
|
+
const installedAt = props.record.installedAt ? new Date(props.record.installedAt).toLocaleString() : undefined;
|
|
433
|
+
const sourceHint = props.record.source ? `source: ${props.record.source}` : undefined;
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<article className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-sm transition-shadow" title={sourceHint}>
|
|
437
|
+
<div className="flex items-start justify-between gap-2">
|
|
438
|
+
<h4 className="text-[14px] font-semibold text-gray-900">{props.record.label || props.record.spec}</h4>
|
|
439
|
+
<div className="flex items-center gap-2">
|
|
440
|
+
<TypeBadge type={props.record.type} />
|
|
441
|
+
<InstalledBadge />
|
|
442
|
+
<EnabledStateBadge enabled={props.record.enabled} />
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<p className="text-[12px] text-gray-500 mt-1 min-h-10">Installed locally. This item is not in the current marketplace catalog.</p>
|
|
447
|
+
|
|
448
|
+
<div className="flex flex-wrap gap-1 mt-2">
|
|
449
|
+
{installedAt && <span className="text-[11px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{installedAt}</span>}
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between gap-2">
|
|
453
|
+
<code className="text-[11px] text-gray-500 bg-gray-100 rounded px-2 py-1 truncate" title={sourceHint}>{props.record.spec}</code>
|
|
454
|
+
<RecordActionButtons
|
|
455
|
+
record={props.record}
|
|
456
|
+
state={props.manageState}
|
|
457
|
+
onAction={props.onManage}
|
|
217
458
|
/>
|
|
218
459
|
</div>
|
|
219
460
|
</article>
|
|
@@ -267,46 +508,107 @@ export function MarketplacePage() {
|
|
|
267
508
|
}, [searchText]);
|
|
268
509
|
|
|
269
510
|
const installedQuery = useMarketplaceInstalled();
|
|
270
|
-
const requestPage = scope === 'installed' ? 1 : page;
|
|
271
|
-
const requestPageSize = scope === 'installed' ? 100 : PAGE_SIZE;
|
|
272
511
|
|
|
273
512
|
const itemsQuery = useMarketplaceItems({
|
|
274
513
|
q: query || undefined,
|
|
275
514
|
type: typeFilter === 'all' ? undefined : typeFilter,
|
|
276
515
|
sort,
|
|
277
|
-
page
|
|
278
|
-
pageSize:
|
|
516
|
+
page,
|
|
517
|
+
pageSize: PAGE_SIZE
|
|
279
518
|
});
|
|
280
519
|
const recommendationsQuery = useMarketplaceRecommendations({ scene: 'default', limit: 4 });
|
|
281
520
|
const installMutation = useInstallMarketplaceItem();
|
|
521
|
+
const manageMutation = useManageMarketplaceItem();
|
|
282
522
|
|
|
523
|
+
const installedRecords = useMemo(
|
|
524
|
+
() => installedQuery.data?.records ?? [],
|
|
525
|
+
[installedQuery.data?.records]
|
|
526
|
+
);
|
|
283
527
|
const installedSets = buildInstalledSpecSets(installedQuery.data);
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
528
|
+
const installedRecordLookup = useMemo(
|
|
529
|
+
() => buildInstalledRecordLookup(installedRecords),
|
|
530
|
+
[installedRecords]
|
|
531
|
+
);
|
|
532
|
+
const allItems = useMemo(
|
|
533
|
+
() => itemsQuery.data?.items ?? [],
|
|
534
|
+
[itemsQuery.data?.items]
|
|
535
|
+
);
|
|
536
|
+
const recommendations = useMemo(
|
|
537
|
+
() => recommendationsQuery.data?.items ?? [],
|
|
538
|
+
[recommendationsQuery.data?.items]
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const catalogLookup = useMemo(
|
|
542
|
+
() => buildCatalogLookup([...allItems, ...recommendations]),
|
|
543
|
+
[allItems, recommendations]
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
const installedEntries = useMemo<InstalledRenderEntry[]>(() => {
|
|
547
|
+
const entries = installedRecords
|
|
548
|
+
.filter((record) => (typeFilter === 'all' ? true : record.type === typeFilter))
|
|
549
|
+
.map((record) => {
|
|
550
|
+
const item = findCatalogItemForRecord(record, catalogLookup);
|
|
551
|
+
return {
|
|
552
|
+
key: `${record.type}:${record.spec}:${record.label ?? ''}`,
|
|
553
|
+
record,
|
|
554
|
+
item
|
|
555
|
+
};
|
|
556
|
+
})
|
|
557
|
+
.filter((entry) => matchInstalledSearch(entry.record, entry.item, query));
|
|
558
|
+
|
|
559
|
+
entries.sort((left, right) => {
|
|
560
|
+
const leftTs = left.record.installedAt ? Date.parse(left.record.installedAt) : Number.NaN;
|
|
561
|
+
const rightTs = right.record.installedAt ? Date.parse(right.record.installedAt) : Number.NaN;
|
|
562
|
+
const leftValid = !Number.isNaN(leftTs);
|
|
563
|
+
const rightValid = !Number.isNaN(rightTs);
|
|
564
|
+
|
|
565
|
+
if (leftValid && rightValid && leftTs !== rightTs) {
|
|
566
|
+
return rightTs - leftTs;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return left.record.spec.localeCompare(right.record.spec);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
return entries;
|
|
573
|
+
}, [installedRecords, typeFilter, catalogLookup, query]);
|
|
288
574
|
|
|
289
|
-
const recommendations = recommendationsQuery.data?.items ?? [];
|
|
290
575
|
const total = scope === 'installed'
|
|
291
|
-
?
|
|
576
|
+
? installedEntries.length
|
|
292
577
|
: (itemsQuery.data?.total ?? 0);
|
|
293
578
|
const totalPages = scope === 'installed' ? 1 : (itemsQuery.data?.totalPages ?? 0);
|
|
294
579
|
|
|
295
580
|
const listSummary = useMemo(() => {
|
|
581
|
+
if (scope === 'installed') {
|
|
582
|
+
if (installedQuery.isLoading) {
|
|
583
|
+
return 'Loading...';
|
|
584
|
+
}
|
|
585
|
+
if (installedEntries.length === 0) {
|
|
586
|
+
return 'No installed items';
|
|
587
|
+
}
|
|
588
|
+
const installedTotal = installedQuery.data?.total ?? installedEntries.length;
|
|
589
|
+
return `Showing ${installedEntries.length} / ${installedTotal}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
296
592
|
if (!itemsQuery.data) {
|
|
297
593
|
return 'Loading...';
|
|
298
594
|
}
|
|
299
|
-
if (
|
|
300
|
-
return
|
|
595
|
+
if (allItems.length === 0) {
|
|
596
|
+
return 'No results';
|
|
301
597
|
}
|
|
302
|
-
return `Showing ${
|
|
303
|
-
}, [
|
|
598
|
+
return `Showing ${allItems.length} / ${total}`;
|
|
599
|
+
}, [scope, installedQuery.isLoading, installedQuery.data, installedEntries.length, itemsQuery.data, allItems.length, total]);
|
|
304
600
|
|
|
305
601
|
const installState: InstallState = {
|
|
306
602
|
isPending: installMutation.isPending,
|
|
307
603
|
installingSpec: installMutation.variables?.spec
|
|
308
604
|
};
|
|
309
605
|
|
|
606
|
+
const manageState: ManageState = {
|
|
607
|
+
isPending: manageMutation.isPending,
|
|
608
|
+
targetId: manageMutation.variables?.id || manageMutation.variables?.spec,
|
|
609
|
+
action: manageMutation.variables?.action
|
|
610
|
+
};
|
|
611
|
+
|
|
310
612
|
const tabs = [
|
|
311
613
|
{ id: 'all', label: 'Marketplace' },
|
|
312
614
|
{ id: 'installed', label: 'Installed', count: installedQuery.data?.total ?? 0 }
|
|
@@ -316,7 +618,32 @@ export function MarketplacePage() {
|
|
|
316
618
|
if (installMutation.isPending) {
|
|
317
619
|
return;
|
|
318
620
|
}
|
|
319
|
-
installMutation.mutate({ type: item.type, spec: item.install.spec });
|
|
621
|
+
installMutation.mutate({ type: item.type, spec: item.install.spec, kind: item.install.kind });
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const handleManage = (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
|
|
625
|
+
if (manageMutation.isPending) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const targetId = record.id || record.spec;
|
|
630
|
+
if (!targetId) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (action === 'uninstall') {
|
|
635
|
+
const confirmed = window.confirm(`Confirm ${action} ${targetId}?`);
|
|
636
|
+
if (!confirmed) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
manageMutation.mutate({
|
|
642
|
+
type: record.type,
|
|
643
|
+
action,
|
|
644
|
+
id: targetId,
|
|
645
|
+
spec: record.spec
|
|
646
|
+
});
|
|
320
647
|
};
|
|
321
648
|
|
|
322
649
|
return (
|
|
@@ -357,13 +684,15 @@ export function MarketplacePage() {
|
|
|
357
684
|
}}
|
|
358
685
|
/>
|
|
359
686
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
687
|
+
{scope === 'all' && (
|
|
688
|
+
<RecommendationSection
|
|
689
|
+
items={recommendations}
|
|
690
|
+
loading={recommendationsQuery.isLoading}
|
|
691
|
+
installState={installState}
|
|
692
|
+
installedSets={installedSets}
|
|
693
|
+
onInstall={handleInstall}
|
|
694
|
+
/>
|
|
695
|
+
)}
|
|
367
696
|
|
|
368
697
|
<section>
|
|
369
698
|
<div className="flex items-center justify-between mb-3">
|
|
@@ -371,27 +700,55 @@ export function MarketplacePage() {
|
|
|
371
700
|
<span className="text-[12px] text-gray-500">{listSummary}</span>
|
|
372
701
|
</div>
|
|
373
702
|
|
|
374
|
-
{itemsQuery.isError && (
|
|
703
|
+
{scope === 'all' && itemsQuery.isError && (
|
|
375
704
|
<div className="p-4 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-sm">
|
|
376
705
|
Failed to load marketplace data: {itemsQuery.error.message}
|
|
377
706
|
</div>
|
|
378
707
|
)}
|
|
708
|
+
{scope === 'installed' && installedQuery.isError && (
|
|
709
|
+
<div className="p-4 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 text-sm">
|
|
710
|
+
Failed to load installed items: {installedQuery.error.message}
|
|
711
|
+
</div>
|
|
712
|
+
)}
|
|
379
713
|
|
|
380
714
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
381
|
-
{
|
|
715
|
+
{scope === 'all' && allItems.map((item) => (
|
|
382
716
|
<MarketplaceItemCard
|
|
383
717
|
key={item.id}
|
|
384
718
|
item={item}
|
|
719
|
+
installedRecord={findInstalledRecordForItem(item, installedRecordLookup)}
|
|
385
720
|
installed={isInstalled(item, installedSets)}
|
|
386
721
|
installState={installState}
|
|
722
|
+
manageState={manageState}
|
|
387
723
|
onInstall={handleInstall}
|
|
724
|
+
onManage={handleManage}
|
|
388
725
|
/>
|
|
389
726
|
))}
|
|
727
|
+
|
|
728
|
+
{scope === 'installed' && installedEntries.map((entry) => (
|
|
729
|
+
entry.item
|
|
730
|
+
? (
|
|
731
|
+
<MarketplaceItemCard
|
|
732
|
+
key={`catalog:${entry.key}:${entry.item.id}`}
|
|
733
|
+
item={entry.item}
|
|
734
|
+
installedRecord={entry.record}
|
|
735
|
+
installed
|
|
736
|
+
installState={installState}
|
|
737
|
+
manageState={manageState}
|
|
738
|
+
onInstall={handleInstall}
|
|
739
|
+
onManage={handleManage}
|
|
740
|
+
/>
|
|
741
|
+
)
|
|
742
|
+
: <InstalledRecordCard key={`local:${entry.key}`} record={entry.record} manageState={manageState} onManage={handleManage} />
|
|
743
|
+
))}
|
|
390
744
|
</div>
|
|
391
745
|
|
|
392
|
-
{!itemsQuery.isLoading && !itemsQuery.isError &&
|
|
746
|
+
{scope === 'all' && !itemsQuery.isLoading && !itemsQuery.isError && allItems.length === 0 && (
|
|
393
747
|
<div className="text-[13px] text-gray-500 py-8 text-center">No items found.</div>
|
|
394
748
|
)}
|
|
749
|
+
{scope === 'installed' && !installedQuery.isLoading && !installedQuery.isError && installedEntries.length === 0 && (
|
|
750
|
+
<div className="text-[13px] text-gray-500 py-8 text-center">No installed items found.</div>
|
|
751
|
+
)}
|
|
395
752
|
</section>
|
|
396
753
|
|
|
397
754
|
{scope === 'all' && (
|
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
fetchMarketplaceItems,
|
|
7
7
|
fetchMarketplaceRecommendations,
|
|
8
8
|
installMarketplaceItem,
|
|
9
|
+
manageMarketplaceItem,
|
|
9
10
|
type MarketplaceListParams
|
|
10
11
|
} from '@/api/marketplace';
|
|
11
|
-
import type { MarketplaceInstallRequest, MarketplaceItemType } from '@/api/types';
|
|
12
|
+
import type { MarketplaceInstallRequest, MarketplaceItemType, MarketplaceManageRequest } from '@/api/types';
|
|
12
13
|
|
|
13
14
|
export function useMarketplaceItems(params: MarketplaceListParams) {
|
|
14
15
|
return useQuery({
|
|
@@ -57,3 +58,19 @@ export function useInstallMarketplaceItem() {
|
|
|
57
58
|
}
|
|
58
59
|
});
|
|
59
60
|
}
|
|
61
|
+
|
|
62
|
+
export function useManageMarketplaceItem() {
|
|
63
|
+
const queryClient = useQueryClient();
|
|
64
|
+
|
|
65
|
+
return useMutation({
|
|
66
|
+
mutationFn: (request: MarketplaceManageRequest) => manageMarketplaceItem(request),
|
|
67
|
+
onSuccess: (result) => {
|
|
68
|
+
queryClient.invalidateQueries({ queryKey: ['marketplace-installed'] });
|
|
69
|
+
queryClient.invalidateQueries({ queryKey: ['marketplace-items'] });
|
|
70
|
+
toast.success(result.message || `${result.action} success`);
|
|
71
|
+
},
|
|
72
|
+
onError: (error: Error) => {
|
|
73
|
+
toast.error(error.message || 'Operation failed');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|