@camstack/addon-admin-ui 0.1.1 → 0.1.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.
@@ -1,44 +1,25 @@
1
- import { useState, useDeferredValue, useMemo } from 'react'
2
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
- import { Package, Download, RefreshCw, Map as MapIcon, Search, Check, Loader2, ChevronDown, ChevronUp } from 'lucide-react'
4
- import { useBackendClient } from '../../hooks/useBackendClient'
5
- import { AddonCard } from '../../components/addons/AddonCard'
1
+ import type { BackendClient } from '@camstack/sdk'
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3
+ import { Check, ChevronDown, ChevronUp, Download, Loader2, Package, RefreshCw, Search } from 'lucide-react'
4
+ import { useDeferredValue, useMemo, useState } from 'react'
6
5
  import type { AddonListItem, AgentInfo } from '../../components/addons/AddonCard'
6
+ import { AddonCard } from '../../components/addons/AddonCard'
7
+ import { VersionBadge } from '@camstack/ui'
7
8
  import { AddonUploadZone } from '../../components/addons/AddonUploadZone'
8
- import { UpdatesList } from '../../components/addons/UpdatesList'
9
- import type { UpdateEntry } from '../../components/addons/UpdatesList'
10
- import { CapabilityMap } from '../../components/addons/CapabilityMap'
11
- import type { CapabilityEntry } from '../../components/addons/CapabilityMap'
9
+ import { useBackendClient } from '../../hooks/useBackendClient'
12
10
 
13
11
  // ---------------------------------------------------------------------------
14
- // Types
12
+ // Types — inferred from tRPC addons.list return type
15
13
  // ---------------------------------------------------------------------------
16
14
 
17
- interface RawAddonManifest {
18
- id: string
19
- name: string
20
- version: string
21
- description?: string
22
- capabilities: readonly string[]
23
- requiredFeatures?: readonly string[]
24
- group?: 'core' | 'addon' | 'provider' | 'page'
25
- packageName: string
26
- packageVersion: string
27
- removable?: boolean
28
- }
29
-
30
- interface RawAddonListItem {
31
- manifest: RawAddonManifest
32
- enabled: boolean
33
- hasConfigSchema: boolean
34
- }
15
+ type RawAddonListItem = Awaited<ReturnType<BackendClient['listAddons']>>[number]
16
+ type RawAddonManifest = RawAddonListItem['manifest']
35
17
 
36
18
  // ---------------------------------------------------------------------------
37
19
  // Normalise raw addon to typed AddonListItem, deriving group from ID
38
20
  // ---------------------------------------------------------------------------
39
21
 
40
22
  function deriveGroup(manifest: RawAddonManifest, source?: string): AddonListItem['group'] {
41
- if (manifest.group) return manifest.group
42
23
  if (source === 'core' || manifest.packageName === '@camstack/core') return 'core'
43
24
  const pkg = manifest.packageName.toLowerCase()
44
25
  if (pkg.includes('provider')) return 'provider'
@@ -53,92 +34,19 @@ function normaliseAddon(raw: RawAddonListItem): AddonListItem | null {
53
34
  ...raw.manifest,
54
35
  capabilities: raw.manifest.capabilities ?? [],
55
36
  },
56
- enabled: raw.enabled ?? false,
37
+ enabled: true,
57
38
  hasConfigSchema: raw.hasConfigSchema ?? false,
58
- group: deriveGroup(raw.manifest, (raw as any).source),
59
- source: (raw as any).source,
39
+ group: deriveGroup(raw.manifest, raw.source),
40
+ source: raw.source,
60
41
  installedOn: [],
61
42
  }
62
43
  }
63
44
 
64
- // ---------------------------------------------------------------------------
65
- // Derive capabilities from addon manifests
66
- // ---------------------------------------------------------------------------
67
-
68
- function deriveCapabilities(addons: AddonListItem[]): CapabilityEntry[] {
69
- const capMap = new Map<string, CapabilityEntry>()
70
-
71
- for (const addon of addons) {
72
- const capabilities = addon.manifest.capabilities ?? []
73
- for (const cap of capabilities) {
74
- const capName = typeof cap === 'string' ? cap : (cap as { name: string }).name
75
- const capMode = typeof cap === 'string' ? 'collection' : ((cap as { mode?: string }).mode ?? 'collection')
76
-
77
- if (!capMap.has(capName)) {
78
- capMap.set(capName, {
79
- name: capName,
80
- mode: capMode as CapabilityEntry['mode'],
81
- providers: [],
82
- })
83
- }
84
-
85
- const entry = capMap.get(capName)!
86
- entry.providers.push({
87
- addonId: addon.manifest.id,
88
- addonName: addon.manifest.name,
89
- active: addon.enabled,
90
- })
91
- }
92
- }
93
-
94
- return Array.from(capMap.values())
95
- }
96
-
97
45
  // ---------------------------------------------------------------------------
98
46
  // Group definitions
99
47
  // ---------------------------------------------------------------------------
100
48
 
101
- const GROUPS: { key: AddonListItem['group']; label: string }[] = [
102
- { key: 'core', label: 'Core (Builtins)' },
103
- { key: 'addon', label: 'Addon Packages' },
104
- { key: 'provider', label: 'Providers' },
105
- { key: 'page', label: 'Addon Pages' },
106
- ]
107
-
108
- // ---------------------------------------------------------------------------
109
- // Tab bar
110
- // ---------------------------------------------------------------------------
111
-
112
- type Tab = 'installed' | 'updates' | 'map'
113
-
114
- const TABS: { id: Tab; label: string; icon: React.ElementType }[] = [
115
- { id: 'installed', label: 'Installed', icon: Package },
116
- { id: 'updates', label: 'Updates', icon: RefreshCw },
117
- { id: 'map', label: 'Capability Map', icon: MapIcon },
118
- ]
119
-
120
- function TabBar({ active, onChange }: { active: Tab; onChange: (tab: Tab) => void }) {
121
- return (
122
- <div className="flex gap-1 border-b border-border pb-0">
123
- {TABS.map(({ id, label, icon: Icon }) => (
124
- <button
125
- key={id}
126
- type="button"
127
- onClick={() => onChange(id)}
128
- className={[
129
- 'flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
130
- active === id
131
- ? 'border-primary text-primary'
132
- : 'border-transparent text-foreground-subtle hover:text-foreground',
133
- ].join(' ')}
134
- >
135
- <Icon className="h-3.5 w-3.5" />
136
- {label}
137
- </button>
138
- ))}
139
- </div>
140
- )
141
- }
49
+ // Groups removed flat list with package grouping only
142
50
 
143
51
  // ---------------------------------------------------------------------------
144
52
  // Installed tab
@@ -150,10 +58,12 @@ function TabBar({ active, onChange }: { active: Tab; onChange: (tab: Tab) => voi
150
58
 
151
59
  interface PackageGroup {
152
60
  packageName: string
61
+ displayName: string
153
62
  version: string
154
63
  addons: AddonListItem[]
155
64
  }
156
65
 
66
+
157
67
  function groupByPackage(addons: AddonListItem[]): Array<PackageGroup | AddonListItem> {
158
68
  const packageMap = new Map<string, PackageGroup>()
159
69
  const ungrouped: AddonListItem[] = []
@@ -170,6 +80,7 @@ function groupByPackage(addons: AddonListItem[]): Array<PackageGroup | AddonList
170
80
  } else {
171
81
  packageMap.set(pkgName, {
172
82
  packageName: pkgName,
83
+ displayName: (addon.manifest as any).packageDisplayName ?? addon.manifest.name,
173
84
  version: addon.manifest.packageVersion,
174
85
  addons: [addon],
175
86
  })
@@ -198,27 +109,48 @@ function PackageGroupHeader({ group, agents }: { group: PackageGroup; agents: Ag
198
109
 
199
110
  return (
200
111
  <div className="rounded-lg border border-border bg-surface overflow-hidden">
112
+ {/* Header — same layout as AddonCard */}
113
+ <div className="flex items-start gap-3 px-4 py-3">
114
+ <div className="flex-shrink-0 h-9 w-9 rounded-lg flex items-center justify-center bg-purple-500/20 text-purple-300">
115
+ <Package className="h-4 w-4" />
116
+ </div>
117
+
118
+ <div className="flex-1 min-w-0">
119
+ <div className="flex items-center gap-2">
120
+ <span className="text-sm font-semibold text-foreground truncate">{group.displayName}</span>
121
+ <span className="text-[10px] text-foreground-subtle font-mono shrink-0">({group.packageName})</span>
122
+ <span className="text-[10px] rounded-full bg-primary/10 text-primary px-2 py-0.5 font-medium shrink-0 inline-flex items-center gap-1">
123
+ <Package className="h-3 w-3" />
124
+ {group.addons.length}
125
+ </span>
126
+ <span className="ml-auto shrink-0">
127
+ <VersionBadge version={group.version} />
128
+ </span>
129
+ </div>
130
+ {group.addons[0]?.manifest.description && (
131
+ <p className="text-[10px] text-foreground-subtle mt-0.5 line-clamp-1">{group.addons[0].manifest.description}</p>
132
+ )}
133
+ </div>
134
+ </div>
135
+
136
+ {/* Expand handle — bottom edge tab */}
201
137
  <button
202
138
  type="button"
203
139
  onClick={() => setExpanded((e) => !e)}
204
- className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-hover/50 transition-colors text-left"
140
+ className="w-full flex items-center justify-center border-t border-border py-1 hover:bg-surface-hover/50 transition-colors"
205
141
  >
206
- <Package className="h-4 w-4 text-primary shrink-0" />
207
- <div className="flex-1 min-w-0 flex items-center gap-2 flex-wrap">
208
- <span className="text-xs font-semibold text-foreground truncate">{group.packageName}</span>
209
- <span className="text-[10px] rounded-full bg-primary/10 text-primary px-2 py-0.5 font-medium shrink-0">
210
- v{group.version}
211
- </span>
212
- <span className="text-[10px] rounded-full bg-surface-hover border border-border px-2 py-0.5 font-medium shrink-0">
213
- {group.addons.length} addons
214
- </span>
215
- </div>
216
- {expanded ? <ChevronUp className="h-4 w-4 text-foreground-subtle shrink-0" /> : <ChevronDown className="h-4 w-4 text-foreground-subtle shrink-0" />}
142
+ {expanded ? (
143
+ <ChevronUp className="h-3.5 w-3.5 text-foreground-subtle" />
144
+ ) : (
145
+ <ChevronDown className="h-3.5 w-3.5 text-foreground-subtle" />
146
+ )}
217
147
  </button>
148
+
149
+ {/* Expanded content */}
218
150
  {expanded && (
219
151
  <div className="border-t border-border px-3 py-2 space-y-2">
220
152
  {group.addons.map((addon) => (
221
- <AddonCard key={addon.manifest.id} addon={addon} agents={agents} />
153
+ <AddonCard key={addon.manifest.id} addon={addon} agents={agents} hideVersion />
222
154
  ))}
223
155
  </div>
224
156
  )}
@@ -267,14 +199,10 @@ function InstalledTab({ addons, agents, onRefresh, isRefreshing }: InstalledTabP
267
199
  },
268
200
  })
269
201
 
270
- const grouped = GROUPS.map(({ key, label }) => ({
271
- key,
272
- label,
273
- items: addons.filter((a) => a.group === key),
274
- })).filter((g) => g.items.length > 0)
202
+ const packageGrouped = groupByPackage(addons)
275
203
 
276
204
  // Filter search results: hide already installed
277
- const availableAddons = (searchResults ?? []).filter((r: any) => !r.installed)
205
+ const availableAddons = (searchResults ?? []).filter((r) => !r.installed)
278
206
 
279
207
  return (
280
208
  <div className="space-y-6">
@@ -294,6 +222,12 @@ function InstalledTab({ addons, agents, onRefresh, isRefreshing }: InstalledTabP
294
222
  <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-foreground-subtle animate-spin" />
295
223
  )}
296
224
  </div>
225
+ <AddonUploadZone
226
+ onUploadSuccess={() => {
227
+ queryClient.invalidateQueries({ queryKey: ['addons'] })
228
+ onRefresh()
229
+ }}
230
+ />
297
231
  <button
298
232
  type="button"
299
233
  onClick={onRefresh}
@@ -315,7 +249,7 @@ function InstalledTab({ addons, agents, onRefresh, isRefreshing }: InstalledTabP
315
249
  </button>
316
250
  </div>
317
251
  <div className="max-h-64 overflow-auto divide-y divide-border">
318
- {availableAddons.map((addon: any) => (
252
+ {availableAddons.map((addon) => (
319
253
  <div key={addon.name} className="flex items-center gap-3 px-3 py-2.5 hover:bg-surface-hover transition-colors">
320
254
  <div className="w-7 h-7 rounded-md bg-primary/10 flex items-center justify-center text-primary text-[10px] font-bold shrink-0">
321
255
  {addon.name.replace('@camstack/addon-', '').charAt(0).toUpperCase()}
@@ -326,9 +260,9 @@ function InstalledTab({ addons, agents, onRefresh, isRefreshing }: InstalledTabP
326
260
  <span className="text-[10px] text-foreground-subtle">v{addon.version}</span>
327
261
  </div>
328
262
  <div className="text-[10px] text-foreground-subtle truncate">{addon.description}</div>
329
- {addon.keywords?.length > 0 && (
263
+ {addon.keywords && addon.keywords.length > 0 && (
330
264
  <div className="flex gap-1 mt-0.5 flex-wrap">
331
- {addon.keywords.filter((k: string) => k !== 'camstack' && k !== 'addon' && k !== 'camstack-addon').slice(0, 5).map((k: string) => (
265
+ {addon.keywords.filter((k) => k !== 'camstack' && k !== 'addon' && k !== 'camstack-addon').slice(0, 5).map((k) => (
332
266
  <span key={k} className="text-[9px] px-1.5 py-0 rounded bg-primary/5 text-foreground-subtle">{k}</span>
333
267
  ))}
334
268
  </div>
@@ -361,39 +295,20 @@ function InstalledTab({ addons, agents, onRefresh, isRefreshing }: InstalledTabP
361
295
  </div>
362
296
  )}
363
297
 
364
- {/* Upload zone — below search results */}
365
- <AddonUploadZone
366
- onUploadSuccess={() => {
367
- queryClient.invalidateQueries({ queryKey: ['addons'] })
368
- onRefresh()
369
- }}
370
- />
371
-
372
298
  {/* Installed addons grouped */}
373
299
  {addons.length === 0 && (
374
300
  <div className="text-xs text-foreground-subtle">No addons installed</div>
375
301
  )}
376
302
 
377
- {grouped.map(({ key, label, items }) => {
378
- const packageGrouped = groupByPackage(items)
379
- return (
380
- <div key={key} className="space-y-2">
381
- <h3 className="text-xs font-semibold text-foreground-subtle uppercase tracking-wide flex items-center gap-2">
382
- {label}
383
- <span className="rounded-full bg-surface-hover border border-border px-2 py-0.5 text-[10px] normal-case font-normal">
384
- {items.length}
385
- </span>
386
- </h3>
387
- {packageGrouped.map((item) =>
388
- isPackageGroup(item) ? (
389
- <PackageGroupHeader key={item.packageName} group={item} agents={agents} />
390
- ) : (
391
- <AddonCard key={item.manifest.id} addon={item} agents={agents} />
392
- ),
393
- )}
394
- </div>
395
- )
396
- })}
303
+ <div className="space-y-2">
304
+ {packageGrouped.map((item) =>
305
+ isPackageGroup(item) ? (
306
+ <PackageGroupHeader key={item.packageName} group={item} agents={agents} />
307
+ ) : (
308
+ <AddonCard key={item.manifest.id} addon={item} agents={agents} />
309
+ ),
310
+ )}
311
+ </div>
397
312
  </div>
398
313
  )
399
314
  }
@@ -405,11 +320,10 @@ function InstalledTab({ addons, agents, onRefresh, isRefreshing }: InstalledTabP
405
320
  export function AddonsPage() {
406
321
  const client = useBackendClient()
407
322
  const queryClient = useQueryClient()
408
- const [activeTab, setActiveTab] = useState<Tab>('installed')
409
323
 
410
- const { data: rawAddons, isLoading, isError } = useQuery<RawAddonListItem[]>({
324
+ const { data: rawAddons, isLoading, isError } = useQuery({
411
325
  queryKey: ['addons', 'list'],
412
- queryFn: () => client.listAddons() as unknown as Promise<RawAddonListItem[]>,
326
+ queryFn: () => client.listAddons(),
413
327
  })
414
328
 
415
329
  // Fetch agents for the "Add agent" dropdown in AddonCard
@@ -421,62 +335,36 @@ export function AddonsPage() {
421
335
 
422
336
  const agents: AgentInfo[] = useMemo(
423
337
  () =>
424
- (rawAgents as unknown as Record<string, unknown>[]).map((raw) => {
425
- const info = (raw.info ?? raw) as Record<string, unknown>
426
- return {
427
- id: String(info.id ?? ''),
428
- name: String(info.name ?? info.id ?? 'Unknown'),
429
- isHub: Boolean(raw.isHub ?? raw.hub ?? false),
430
- }
431
- }),
338
+ rawAgents.map((raw) => ({
339
+ id: raw.info.id,
340
+ name: raw.info.name ?? raw.info.id,
341
+ isHub: raw.isHub,
342
+ })),
432
343
  [rawAgents],
433
344
  )
434
345
 
435
- const { data: updates = [], isFetching: isChecking } = useQuery({
436
- queryKey: ['updates'],
437
- queryFn: () => client.trpc.update.listUpdates.query(),
438
- staleTime: 60_000,
439
- })
440
-
441
346
  const reloadMutation = useMutation({
442
- mutationFn: () => client.trpc.bridgeAddons.reloadPackages.mutate(),
443
- onSuccess: () => {
444
- queryClient.invalidateQueries({ queryKey: ['addons'] })
347
+ mutationFn: async () => {
348
+ // Force refresh update cache from npm + reload addon packages
349
+ await client.trpc.update.forceRefresh.mutate()
350
+ await client.trpc.bridgeAddons.reloadPackages.mutate()
445
351
  },
446
- })
447
-
448
- const updatePackageMutation = useMutation({
449
- mutationFn: (name: string) => client.trpc.update.updatePackage.mutate({ name }),
450
352
  onSuccess: () => {
451
- queryClient.invalidateQueries({ queryKey: ['updates'] })
452
353
  queryClient.invalidateQueries({ queryKey: ['addons'] })
354
+ queryClient.invalidateQueries({ queryKey: ['updates'] })
453
355
  },
454
356
  })
455
357
 
456
358
  const addonList: AddonListItem[] = (rawAddons ?? []).map(normaliseAddon).filter((a): a is AddonListItem => a !== null)
457
359
 
458
- function handleCheckUpdates() {
459
- queryClient.invalidateQueries({ queryKey: ['updates'] })
460
- }
461
-
462
- function handleUpdate(id: string) {
463
- updatePackageMutation.mutate(id)
464
- }
465
-
466
- function handleUpdateAll() {
467
- for (const u of updates) {
468
- updatePackageMutation.mutate((u as unknown as UpdateEntry).id)
469
- }
470
- }
471
-
472
360
  return (
473
361
  <div className="p-6 space-y-5">
474
362
  {/* Header */}
475
363
  <div className="flex items-center justify-between">
476
364
  <div>
477
- <h1 className="text-lg font-semibold text-foreground">Addons &amp; Updates</h1>
365
+ <h1 className="text-lg font-semibold text-foreground">Addons</h1>
478
366
  <p className="text-xs text-foreground-subtle mt-0.5">
479
- Manage installed addons, check for updates, and explore capability providers.
367
+ Manage installed addons and explore capability providers.
480
368
  </p>
481
369
  </div>
482
370
  {!isLoading && !isError && (
@@ -486,19 +374,16 @@ export function AddonsPage() {
486
374
  )}
487
375
  </div>
488
376
 
489
- {/* Tabs */}
490
- <TabBar active={activeTab} onChange={setActiveTab} />
491
-
492
377
  {/* Loading / error states */}
493
- {isLoading && activeTab === 'installed' && (
378
+ {isLoading && (
494
379
  <div className="text-xs text-foreground-subtle animate-pulse">Loading…</div>
495
380
  )}
496
- {isError && activeTab === 'installed' && (
381
+ {isError && (
497
382
  <div className="text-xs text-danger">Failed to load addons</div>
498
383
  )}
499
384
 
500
- {/* Tab content */}
501
- {activeTab === 'installed' && !isLoading && !isError && (
385
+ {/* Installed addons */}
386
+ {!isLoading && !isError && (
502
387
  <InstalledTab
503
388
  addons={addonList}
504
389
  agents={agents}
@@ -506,20 +391,6 @@ export function AddonsPage() {
506
391
  isRefreshing={reloadMutation.isPending}
507
392
  />
508
393
  )}
509
-
510
- {activeTab === 'updates' && (
511
- <UpdatesList
512
- updates={updates as unknown as UpdateEntry[]}
513
- onUpdate={handleUpdate}
514
- onUpdateAll={handleUpdateAll}
515
- onCheckUpdates={handleCheckUpdates}
516
- isChecking={isChecking}
517
- />
518
- )}
519
-
520
- {activeTab === 'map' && (
521
- <CapabilityMap capabilities={deriveCapabilities(addonList)} />
522
- )}
523
394
  </div>
524
395
  )
525
396
  }