@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.
@@ -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 { useInstallMarketplaceItem, useMarketplaceInstalled, useMarketplaceItems, useMarketplaceRecommendations } from '@/hooks/useMarketplace';
6
- import type { MarketplaceItemSummary, MarketplaceSort } from '@/api/types';
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
- function buildInstalledSpecSets(records: { pluginSpecs: string[]; skillSpecs: string[] } | undefined): InstalledSpecSets {
24
- return {
25
- plugin: new Set(records?.pluginSpecs ?? []),
26
- skill: new Set(records?.skillSpecs ?? [])
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
- return item.type === 'plugin'
32
- ? sets.plugin.has(item.install.spec)
33
- : sets.skill.has(item.install.spec);
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="grid grid-cols-1 md:grid-cols-3 gap-3">
64
- <div className="md:col-span-2 relative">
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-10 border border-gray-200 rounded-lg pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
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
- <div className="flex gap-2">
75
- <select
76
- className="flex-1 h-10 border border-gray-200 rounded-lg px-3 text-sm bg-white"
77
- value={props.typeFilter}
78
- onChange={(event) => props.onTypeFilterChange(event.target.value as FilterType)}
79
- >
80
- <option value="all">All</option>
81
- <option value="plugin">Plugins</option>
82
- <option value="skill">Skills</option>
83
- </select>
84
- <select
85
- className="flex-1 h-10 border border-gray-200 rounded-lg px-3 text-sm bg-white"
86
- value={props.sort}
87
- onChange={(event) => props.onSortChange(event.target.value as MarketplaceSort)}
88
- >
89
- <option value="relevance">Relevance</option>
90
- <option value="updated">Recently Updated</option>
91
- <option value="downloads">Downloads</option>
92
- </select>
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
- <InstallButton
213
- item={props.item}
214
- installed={props.installed}
215
- installState={props.installState}
216
- onInstall={props.onInstall}
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: requestPage,
278
- pageSize: requestPageSize
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 allItems = itemsQuery.data?.items ?? [];
285
- const items = scope === 'installed'
286
- ? allItems.filter((item) => isInstalled(item, installedSets))
287
- : allItems;
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
- ? items.length
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 (items.length === 0) {
300
- return scope === 'installed' ? 'No installed items on this page' : 'No results';
595
+ if (allItems.length === 0) {
596
+ return 'No results';
301
597
  }
302
- return `Showing ${items.length} / ${total}`;
303
- }, [items.length, itemsQuery.data, scope, total]);
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
- <RecommendationSection
361
- items={recommendations}
362
- loading={recommendationsQuery.isLoading}
363
- installState={installState}
364
- installedSets={installedSets}
365
- onInstall={handleInstall}
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
- {items.map((item) => (
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 && items.length === 0 && (
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
+ }