@camstack/addon-admin-ui 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/assets/index-DjELGD4R.css +1 -0
  2. package/dist/assets/index-w55PwKyu.js +598 -0
  3. package/{index.html → dist/index.html} +3 -1
  4. package/dist/server/addon.d.ts +11 -0
  5. package/dist/server/addon.js +50 -0
  6. package/dist/server/addon.js.map +1 -0
  7. package/package.json +4 -1
  8. package/src/App.tsx +0 -71
  9. package/src/components/addons/AddonCard.tsx +0 -355
  10. package/src/components/addons/AddonUploadZone.tsx +0 -69
  11. package/src/components/addons/CapabilityBadge.tsx +0 -55
  12. package/src/components/addons/CapabilityMap.tsx +0 -133
  13. package/src/components/addons/UpdatesList.tsx +0 -108
  14. package/src/components/agents/AgentCard.tsx +0 -281
  15. package/src/components/agents/AgentLogs.tsx +0 -231
  16. package/src/components/agents/ProcessList.tsx +0 -127
  17. package/src/components/agents/ProcessTree.tsx +0 -369
  18. package/src/components/agents/TaskList.tsx +0 -68
  19. package/src/components/cameras/CameraCard.tsx +0 -60
  20. package/src/components/cameras/LiveEventsPanel.tsx +0 -91
  21. package/src/components/cameras/ProviderSection.tsx +0 -50
  22. package/src/components/cameras/StreamArea.tsx +0 -107
  23. package/src/components/cameras/tabs/AddonsTab.tsx +0 -113
  24. package/src/components/cameras/tabs/CameraEventsTab.tsx +0 -129
  25. package/src/components/cameras/tabs/PipelineTab.tsx +0 -118
  26. package/src/components/cameras/tabs/StreamsTab.tsx +0 -114
  27. package/src/components/dashboard/BlockPicker.tsx +0 -54
  28. package/src/components/dashboard/BlockWrapper.tsx +0 -97
  29. package/src/components/dashboard/DashboardGrid.tsx +0 -160
  30. package/src/components/dashboard/block-registry.ts +0 -15
  31. package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +0 -39
  32. package/src/components/dashboard/blocks/StorageBlock.tsx +0 -66
  33. package/src/components/dashboard/blocks/SystemStatusBlock.tsx +0 -67
  34. package/src/components/dashboard/blocks/index.ts +0 -32
  35. package/src/components/device/DeviceHeader.tsx +0 -116
  36. package/src/components/device/FloatingPanel.tsx +0 -132
  37. package/src/components/device/FloatingPanelManager.tsx +0 -167
  38. package/src/components/device/PanelContent.tsx +0 -196
  39. package/src/components/device/QuickConfigWizard.tsx +0 -507
  40. package/src/components/device/tabs/DetectionConfigTab.tsx +0 -96
  41. package/src/components/device/tabs/EventsTab.tsx +0 -19
  42. package/src/components/device/tabs/LogsTab.tsx +0 -22
  43. package/src/components/device/tabs/OverviewTab.tsx +0 -104
  44. package/src/components/device/tabs/ProviderSettingsTab.tsx +0 -34
  45. package/src/components/device/tabs/RecordingTab.tsx +0 -47
  46. package/src/components/device/tabs/ReplTab.tsx +0 -153
  47. package/src/components/device/tabs/TrackTrailTab.tsx +0 -49
  48. package/src/components/device/tabs/ZonesTab.tsx +0 -98
  49. package/src/components/device/zone-editor/ZoneCanvas.tsx +0 -354
  50. package/src/components/device/zone-editor/ZoneForm.tsx +0 -128
  51. package/src/components/device/zone-editor/ZoneList.tsx +0 -150
  52. package/src/components/form-builder/FormBuilder.tsx +0 -135
  53. package/src/components/form-builder/FormField.tsx +0 -732
  54. package/src/components/form-builder/ModelSelector.tsx +0 -239
  55. package/src/components/integrations/AddDeviceDialog.tsx +0 -205
  56. package/src/components/integrations/CompactDeviceCard.tsx +0 -35
  57. package/src/components/integrations/DeviceCard.tsx +0 -29
  58. package/src/components/integrations/DeviceDiscoveryStep.tsx +0 -105
  59. package/src/components/integrations/DeviceGrid.tsx +0 -79
  60. package/src/components/integrations/DeviceGroupHeader.tsx +0 -17
  61. package/src/components/integrations/DiscoveredDeviceCard.tsx +0 -26
  62. package/src/components/integrations/IntegrationCard.tsx +0 -40
  63. package/src/components/integrations/IntegrationWizard.tsx +0 -172
  64. package/src/components/integrations/ProviderConfigForm.tsx +0 -89
  65. package/src/components/integrations/ProviderPicker.tsx +0 -91
  66. package/src/components/integrations/SnapshotPopover.tsx +0 -68
  67. package/src/components/metrics/AgentLoad.tsx +0 -105
  68. package/src/components/metrics/IntegrationUsage.tsx +0 -73
  69. package/src/components/metrics/PipelineStatus.tsx +0 -74
  70. package/src/components/metrics/ProcessResources.tsx +0 -123
  71. package/src/components/pipeline/PhaseSettings.tsx +0 -131
  72. package/src/components/shared/CapabilityBadges.tsx +0 -30
  73. package/src/components/shared/ProviderIcon.tsx +0 -42
  74. package/src/components/shared/StatusBadge.tsx +0 -23
  75. package/src/components/shared/WebRtcPlayer.tsx +0 -211
  76. package/src/components/timeline/EventMarker.tsx +0 -32
  77. package/src/components/timeline/TimelineBar.tsx +0 -131
  78. package/src/components/ui/ConfirmDialog.tsx +0 -115
  79. package/src/components/ui/ToastContainer.tsx +0 -92
  80. package/src/contexts/auth-context.tsx +0 -91
  81. package/src/hooks/useBackendClient.ts +0 -6
  82. package/src/hooks/useTheme.ts +0 -1
  83. package/src/i18n/en.json +0 -164
  84. package/src/i18n/index.ts +0 -29
  85. package/src/i18n/it.json +0 -164
  86. package/src/index.css +0 -63
  87. package/src/layouts/AddonPageLoader.tsx +0 -120
  88. package/src/layouts/AppLayout.tsx +0 -254
  89. package/src/layouts/ProtectedRoute.tsx +0 -25
  90. package/src/lib/addon-page-context.ts +0 -29
  91. package/src/lib/backend.ts +0 -16
  92. package/src/main.tsx +0 -21
  93. package/src/pages/AccessDenied.tsx +0 -22
  94. package/src/pages/Cameras.tsx +0 -127
  95. package/src/pages/Dashboard.tsx +0 -6
  96. package/src/pages/DeviceDetail.tsx +0 -175
  97. package/src/pages/IntegrationDetail.tsx +0 -222
  98. package/src/pages/Integrations.tsx +0 -333
  99. package/src/pages/Login.tsx +0 -106
  100. package/src/pages/Metrics.tsx +0 -18
  101. package/src/pages/PipelineConfig.tsx +0 -282
  102. package/src/pages/Showroom.tsx +0 -351
  103. package/src/pages/Timeline.tsx +0 -269
  104. package/src/pages/system/Addons.tsx +0 -396
  105. package/src/pages/system/Agents.tsx +0 -362
  106. package/src/pages/system/Logs.tsx +0 -131
  107. package/src/pages/system/Models.tsx +0 -102
  108. package/src/pages/system/Processes.tsx +0 -129
  109. package/src/pages/system/Repl.tsx +0 -148
  110. package/src/pages/system/Settings.tsx +0 -168
  111. package/src/pages/system/Users.tsx +0 -174
  112. package/src/server/addon.ts +0 -54
  113. package/src/types/config-ui.ts +0 -28
  114. package/src/types/dashboard.ts +0 -39
  115. package/tsconfig.json +0 -29
  116. package/tsconfig.server.json +0 -16
  117. package/tsup.config.ts +0 -20
  118. package/vite.config.ts +0 -68
  119. /package/{public → dist}/brand/logo-dark.svg +0 -0
  120. /package/{public → dist}/brand/logo-horizontal-dark.svg +0 -0
  121. /package/{public → dist}/brand/logo-horizontal-light.svg +0 -0
  122. /package/{public → dist}/brand/logo-light.svg +0 -0
  123. /package/{public → dist}/brand/logo-wide-dark.svg +0 -0
  124. /package/{public → dist}/brand/logo-wide-light.svg +0 -0
  125. /package/{public → dist}/favicon.svg +0 -0
  126. /package/{public → dist}/vendor/react-jsx-runtime.mjs +0 -0
  127. /package/{public → dist}/vendor/react.mjs +0 -0
@@ -1,269 +0,0 @@
1
- import { useState, useMemo } from 'react'
2
- import { useQuery } from '@tanstack/react-query'
3
- import { Film, Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
4
- import { useTranslation } from 'react-i18next'
5
- import { useBackendClient } from '../hooks/useBackendClient'
6
- import { TimelineBar, type RecordingSegment, type TimelineEvent } from '../components/timeline/TimelineBar'
7
-
8
- // ---------------------------------------------------------------------------
9
- // Class → colour mapping
10
- // ---------------------------------------------------------------------------
11
- const CLASS_COLORS: Record<string, string> = {
12
- person: '#f59e0b',
13
- vehicle: '#3b82f6',
14
- car: '#3b82f6',
15
- face: '#a855f7',
16
- plate: '#ec4899',
17
- animal: '#10b981',
18
- motion: '#ef4444',
19
- unknown: '#6b7280',
20
- }
21
-
22
- function colorForClass(cls: string): string {
23
- return CLASS_COLORS[cls.toLowerCase()] ?? CLASS_COLORS['unknown']!
24
- }
25
-
26
- // ---------------------------------------------------------------------------
27
- // Date helpers
28
- // ---------------------------------------------------------------------------
29
- function startOfDay(date: Date): number {
30
- const d = new Date(date)
31
- d.setHours(0, 0, 0, 0)
32
- return d.getTime()
33
- }
34
-
35
- function formatDateInput(ms: number): string {
36
- return new Date(ms).toISOString().slice(0, 10)
37
- }
38
-
39
- function parseDateInput(value: string): number {
40
- return new Date(value + 'T00:00:00').getTime()
41
- }
42
-
43
- // ---------------------------------------------------------------------------
44
- // Placeholder segments (shown when no real API data exists)
45
- // ---------------------------------------------------------------------------
46
- function buildPlaceholderSegments(dayStartMs: number): readonly RecordingSegment[] {
47
- return [
48
- { startMs: dayStartMs + 0 * 3_600_000, endMs: dayStartMs + 4 * 3_600_000 },
49
- { startMs: dayStartMs + 6 * 3_600_000, endMs: dayStartMs + 10 * 3_600_000 },
50
- { startMs: dayStartMs + 12 * 3_600_000, endMs: dayStartMs + 16 * 3_600_000 },
51
- { startMs: dayStartMs + 18 * 3_600_000, endMs: dayStartMs + 23 * 3_600_000 },
52
- ]
53
- }
54
-
55
- function buildPlaceholderEvents(dayStartMs: number): readonly TimelineEvent[] {
56
- const classes = ['person', 'vehicle', 'face', 'motion']
57
- return Array.from({ length: 24 }, (_, i) => ({
58
- id: `placeholder-${i}`,
59
- timestampMs: dayStartMs + Math.floor(Math.random() * 24 * 3_600_000),
60
- label: classes[i % classes.length]!,
61
- color: colorForClass(classes[i % classes.length]!),
62
- })).sort((a, b) => a.timestampMs - b.timestampMs)
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Component
67
- // ---------------------------------------------------------------------------
68
- export function TimelinePage() {
69
- const { t } = useTranslation()
70
- const client = useBackendClient()
71
-
72
- const today = startOfDay(new Date())
73
- const [selectedDayMs, setSelectedDayMs] = useState(today)
74
- const [selectedCameraId, setSelectedCameraId] = useState<string | null>(null)
75
- const [playheadMs, setPlayheadMs] = useState(today + 12 * 3_600_000)
76
-
77
- const dayStartMs = selectedDayMs
78
- const dayEndMs = selectedDayMs + 24 * 3_600_000
79
-
80
- // Fetch devices to populate camera tabs
81
- const { data: devices, isLoading: devicesLoading } = useQuery({
82
- queryKey: ['devices'],
83
- queryFn: () => client.listDevices(),
84
- staleTime: 30_000,
85
- })
86
-
87
- const deviceList = (devices ?? []) as unknown as Array<Record<string, unknown>>
88
-
89
- const cameras = useMemo(() => {
90
- const list = deviceList.filter((d) => {
91
- const type = String(d.type ?? '').toLowerCase()
92
- const caps = (d.capabilities ?? []) as string[]
93
- return (
94
- type === 'camera' ||
95
- type.includes('camera') ||
96
- caps.some((c) => c.toLowerCase().includes('video') || c.toLowerCase().includes('stream'))
97
- )
98
- })
99
- return list.map((d) => ({ id: String(d.id), name: String(d.name ?? d.id) }))
100
- }, [deviceList])
101
-
102
- // Auto-select first camera
103
- const activeCameraId = selectedCameraId ?? cameras[0]?.id ?? null
104
-
105
- // Fetch real events for the selected camera on the selected day
106
- const { data: rawEvents } = useQuery({
107
- queryKey: ['events', activeCameraId, dayStartMs],
108
- queryFn: (): Promise<unknown> =>
109
- activeCameraId ? client.getEvents(activeCameraId, { limit: 200 }) : Promise.resolve(null),
110
- enabled: !!activeCameraId,
111
- staleTime: 60_000,
112
- })
113
-
114
- const timelineEvents: readonly TimelineEvent[] = useMemo(() => {
115
- const raw = rawEvents as unknown
116
- // getEvents returns { events: [...], total: n } or an array depending on router version
117
- const evtsArr: unknown[] =
118
- Array.isArray(raw)
119
- ? (raw as unknown[])
120
- : raw != null && typeof raw === 'object' && Array.isArray((raw as Record<string, unknown>)['events'])
121
- ? ((raw as Record<string, unknown>)['events'] as unknown[])
122
- : []
123
- const evts = evtsArr as Array<Record<string, unknown>>
124
- if (evts.length === 0) {
125
- return activeCameraId ? buildPlaceholderEvents(dayStartMs) : []
126
- }
127
- return evts.map((ev, idx) => {
128
- const cls = String((ev.class ?? ev.type ?? ev.label ?? 'unknown')).toLowerCase()
129
- const ts = typeof ev.timestamp === 'number' ? ev.timestamp : dayStartMs
130
- return {
131
- id: String(ev.id ?? idx),
132
- timestampMs: ts,
133
- label: cls,
134
- color: colorForClass(cls),
135
- }
136
- })
137
- }, [rawEvents, activeCameraId, dayStartMs])
138
-
139
- // Use placeholder segments (no real segment API exposed yet)
140
- const segments: readonly RecordingSegment[] = useMemo(
141
- () => (activeCameraId ? buildPlaceholderSegments(dayStartMs) : []),
142
- [activeCameraId, dayStartMs],
143
- )
144
-
145
- function navigateDay(delta: number) {
146
- setSelectedDayMs((prev) => prev + delta * 24 * 3_600_000)
147
- setPlayheadMs((prev) => prev + delta * 24 * 3_600_000)
148
- }
149
-
150
- return (
151
- <div className="flex flex-col h-full p-6 gap-5 overflow-hidden">
152
- {/* ── Header ── */}
153
- <div className="flex items-center justify-between gap-4 shrink-0">
154
- <h1 className="text-lg font-semibold text-foreground">{t('nav.timeline', 'Timeline')}</h1>
155
-
156
- {/* Date navigation */}
157
- <div className="flex items-center gap-2">
158
- <button
159
- onClick={() => navigateDay(-1)}
160
- className="p-1 rounded hover:bg-surface-hover text-foreground-subtle hover:text-foreground transition-colors"
161
- >
162
- <ChevronLeft className="h-4 w-4" />
163
- </button>
164
- <div className="relative flex items-center">
165
- <Calendar className="absolute left-2 h-3.5 w-3.5 text-foreground-subtle pointer-events-none" />
166
- <input
167
- type="date"
168
- value={formatDateInput(selectedDayMs)}
169
- onChange={(e) => {
170
- if (e.target.value) {
171
- const ms = parseDateInput(e.target.value)
172
- setSelectedDayMs(ms)
173
- setPlayheadMs(ms + 12 * 3_600_000)
174
- }
175
- }}
176
- className="rounded-lg border border-border bg-surface pl-7 pr-3 py-1.5 text-xs text-foreground focus:outline-none focus:border-primary/50"
177
- />
178
- </div>
179
- <button
180
- onClick={() => navigateDay(1)}
181
- className="p-1 rounded hover:bg-surface-hover text-foreground-subtle hover:text-foreground transition-colors"
182
- disabled={selectedDayMs >= today}
183
- >
184
- <ChevronRight className="h-4 w-4" />
185
- </button>
186
- </div>
187
- </div>
188
-
189
- {/* ── Camera selector tabs ── */}
190
- <div className="flex gap-1.5 shrink-0 overflow-x-auto pb-1">
191
- {devicesLoading && (
192
- <div className="h-8 w-48 rounded-lg bg-surface animate-pulse" />
193
- )}
194
- {!devicesLoading && cameras.length === 0 && (
195
- <span className="text-xs text-foreground-subtle">No cameras found</span>
196
- )}
197
- {cameras.map((cam) => {
198
- const active = cam.id === activeCameraId
199
- return (
200
- <button
201
- key={cam.id}
202
- onClick={() => setSelectedCameraId(cam.id)}
203
- className={`shrink-0 rounded-lg px-3 py-1.5 text-[12px] font-medium transition-all border ${
204
- active
205
- ? 'bg-primary/12 text-primary border-primary/30'
206
- : 'bg-surface text-foreground-subtle border-border hover:bg-surface-hover hover:text-foreground'
207
- }`}
208
- >
209
- {cam.name}
210
- </button>
211
- )
212
- })}
213
- </div>
214
-
215
- {/* ── Video player placeholder ── */}
216
- <div className="relative rounded-xl border border-border bg-black shrink-0 overflow-hidden" style={{ aspectRatio: '16/9', maxHeight: '45vh' }}>
217
- <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-foreground-subtle">
218
- <Film className="h-12 w-12 opacity-20" />
219
- <p className="text-sm opacity-50">
220
- {activeCameraId
221
- ? `${cameras.find((c) => c.id === activeCameraId)?.name ?? activeCameraId} — seek to play`
222
- : 'Select a camera'}
223
- </p>
224
- </div>
225
-
226
- {/* Playhead timestamp overlay */}
227
- <div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-[11px] text-white font-mono">
228
- {new Date(playheadMs).toLocaleTimeString()}
229
- </div>
230
- </div>
231
-
232
- {/* ── Timeline scrub bar ── */}
233
- <div className="rounded-xl border border-border bg-surface px-4 pt-3 pb-4 shrink-0">
234
- {/* Legend */}
235
- <div className="flex items-center gap-4 mb-3">
236
- <span className="text-[10px] font-semibold text-foreground-subtle uppercase tracking-wider">Timeline</span>
237
- <div className="flex items-center gap-1.5">
238
- <span className="inline-block h-2 w-4 rounded-sm bg-success/60" />
239
- <span className="text-[10px] text-foreground-subtle">Recording</span>
240
- </div>
241
- {Array.from(new Set(timelineEvents.map((e) => e.label))).slice(0, 5).map((cls) => (
242
- <div key={cls} className="flex items-center gap-1.5">
243
- <span
244
- className="inline-block h-2.5 w-2.5 rounded-full"
245
- style={{ backgroundColor: colorForClass(cls) }}
246
- />
247
- <span className="text-[10px] text-foreground-subtle capitalize">{cls}</span>
248
- </div>
249
- ))}
250
- </div>
251
-
252
- {activeCameraId ? (
253
- <TimelineBar
254
- startMs={dayStartMs}
255
- endMs={dayEndMs}
256
- segments={segments}
257
- events={timelineEvents}
258
- playheadMs={playheadMs}
259
- onSeek={setPlayheadMs}
260
- />
261
- ) : (
262
- <div className="h-8 rounded bg-surface-hover flex items-center justify-center text-xs text-foreground-subtle">
263
- Select a camera to view timeline
264
- </div>
265
- )}
266
- </div>
267
- </div>
268
- )
269
- }
@@ -1,396 +0,0 @@
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'
5
- import type { AddonListItem, AgentInfo } from '../../components/addons/AddonCard'
6
- import { AddonCard } from '../../components/addons/AddonCard'
7
- import { VersionBadge } from '@camstack/ui'
8
- import { AddonUploadZone } from '../../components/addons/AddonUploadZone'
9
- import { useBackendClient } from '../../hooks/useBackendClient'
10
-
11
- // ---------------------------------------------------------------------------
12
- // Types — inferred from tRPC addons.list return type
13
- // ---------------------------------------------------------------------------
14
-
15
- type RawAddonListItem = Awaited<ReturnType<BackendClient['listAddons']>>[number]
16
- type RawAddonManifest = RawAddonListItem['manifest']
17
-
18
- // ---------------------------------------------------------------------------
19
- // Normalise raw addon to typed AddonListItem, deriving group from ID
20
- // ---------------------------------------------------------------------------
21
-
22
- function deriveGroup(manifest: RawAddonManifest, source?: string): AddonListItem['group'] {
23
- if (source === 'core' || manifest.packageName === '@camstack/core') return 'core'
24
- const pkg = manifest.packageName.toLowerCase()
25
- if (pkg.includes('provider')) return 'provider'
26
- if (pkg.includes('page') || pkg.includes('benchmark')) return 'page'
27
- return 'addon'
28
- }
29
-
30
- function normaliseAddon(raw: RawAddonListItem): AddonListItem | null {
31
- if (!raw?.manifest) return null
32
- return {
33
- manifest: {
34
- ...raw.manifest,
35
- capabilities: raw.manifest.capabilities ?? [],
36
- },
37
- enabled: true,
38
- hasConfigSchema: raw.hasConfigSchema ?? false,
39
- group: deriveGroup(raw.manifest, raw.source),
40
- source: raw.source,
41
- installedOn: [],
42
- }
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // Group definitions
47
- // ---------------------------------------------------------------------------
48
-
49
- // Groups removed — flat list with package grouping only
50
-
51
- // ---------------------------------------------------------------------------
52
- // Installed tab
53
- // ---------------------------------------------------------------------------
54
-
55
- // ---------------------------------------------------------------------------
56
- // Package group — collapsible header for addons from the same npm package
57
- // ---------------------------------------------------------------------------
58
-
59
- interface PackageGroup {
60
- packageName: string
61
- displayName: string
62
- version: string
63
- addons: AddonListItem[]
64
- }
65
-
66
-
67
- function groupByPackage(addons: AddonListItem[]): Array<PackageGroup | AddonListItem> {
68
- const packageMap = new Map<string, PackageGroup>()
69
- const ungrouped: AddonListItem[] = []
70
-
71
- for (const addon of addons) {
72
- const pkgName = addon.manifest.packageName
73
- if (!pkgName) {
74
- ungrouped.push(addon)
75
- continue
76
- }
77
- const existing = packageMap.get(pkgName)
78
- if (existing) {
79
- existing.addons.push(addon)
80
- } else {
81
- packageMap.set(pkgName, {
82
- packageName: pkgName,
83
- displayName: (addon.manifest as any).packageDisplayName ?? addon.manifest.name,
84
- version: addon.manifest.packageVersion,
85
- addons: [addon],
86
- })
87
- }
88
- }
89
-
90
- const result: Array<PackageGroup | AddonListItem> = []
91
- for (const group of packageMap.values()) {
92
- // Only create a collapsible group if the package contains 2+ addons
93
- if (group.addons.length >= 2) {
94
- result.push(group)
95
- } else {
96
- result.push(group.addons[0]!)
97
- }
98
- }
99
- result.push(...ungrouped)
100
- return result
101
- }
102
-
103
- function isPackageGroup(item: PackageGroup | AddonListItem): item is PackageGroup {
104
- return 'packageName' in item && 'addons' in item
105
- }
106
-
107
- function PackageGroupHeader({ group, agents }: { group: PackageGroup; agents: AgentInfo[] }) {
108
- const [expanded, setExpanded] = useState(false)
109
-
110
- return (
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 */}
137
- <button
138
- type="button"
139
- onClick={() => setExpanded((e) => !e)}
140
- className="w-full flex items-center justify-center border-t border-border py-1 hover:bg-surface-hover/50 transition-colors"
141
- >
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
- )}
147
- </button>
148
-
149
- {/* Expanded content */}
150
- {expanded && (
151
- <div className="border-t border-border px-3 py-2 space-y-2">
152
- {group.addons.map((addon) => (
153
- <AddonCard key={addon.manifest.id} addon={addon} agents={agents} hideVersion />
154
- ))}
155
- </div>
156
- )}
157
- </div>
158
- )
159
- }
160
-
161
- // ---------------------------------------------------------------------------
162
- // Installed tab
163
- // ---------------------------------------------------------------------------
164
-
165
- interface InstalledTabProps {
166
- addons: AddonListItem[]
167
- agents: AgentInfo[]
168
- onRefresh: () => void
169
- isRefreshing: boolean
170
- }
171
-
172
- function InstalledTab({ addons, agents, onRefresh, isRefreshing }: InstalledTabProps) {
173
- const client = useBackendClient()
174
- const queryClient = useQueryClient()
175
- const [searchInput, setSearchInput] = useState('')
176
- const deferredSearch = useDeferredValue(searchInput)
177
- const [showSearch, setShowSearch] = useState(false)
178
-
179
- // Search npm for available addons — uses deferred (debounced) value
180
- const { data: searchResults, isFetching: isSearching } = useQuery({
181
- queryKey: ['addon-search', deferredSearch],
182
- queryFn: () => client.trpc.bridgeAddons.searchAvailable.query({ query: deferredSearch || undefined }),
183
- enabled: showSearch,
184
- staleTime: 60_000,
185
- })
186
-
187
- // Install addon mutation
188
- const installMutation = useMutation({
189
- mutationFn: (packageName: string) => client.trpc.bridgeAddons.installPackage.mutate({ packageName }),
190
- onSuccess: (_data, packageName) => {
191
- queryClient.invalidateQueries({ queryKey: ['addons'] })
192
- queryClient.invalidateQueries({ queryKey: ['addon-search'] })
193
- queryClient.invalidateQueries({ queryKey: ['addon-pages'] })
194
- queryClient.invalidateQueries({ queryKey: ['capabilities'] })
195
- console.log(`[Addons] Installed ${packageName}`)
196
- },
197
- onError: (err, packageName) => {
198
- console.error(`[Addons] Install failed for ${packageName}:`, err)
199
- },
200
- })
201
-
202
- const packageGrouped = groupByPackage(addons)
203
-
204
- // Filter search results: hide already installed
205
- const availableAddons = (searchResults ?? []).filter((r) => !r.installed)
206
-
207
- return (
208
- <div className="space-y-6">
209
- {/* Search bar + actions */}
210
- <div className="flex gap-2">
211
- <div className="relative flex-1">
212
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-foreground-subtle" />
213
- <input
214
- type="text"
215
- value={searchInput}
216
- onChange={(e) => { setSearchInput(e.target.value); setShowSearch(true) }}
217
- onFocus={() => setShowSearch(true)}
218
- placeholder="Search addons by name or capability..."
219
- className="w-full rounded-md border border-border bg-background pl-9 pr-3 py-2 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-1 focus:ring-primary"
220
- />
221
- {isSearching && (
222
- <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-foreground-subtle animate-spin" />
223
- )}
224
- </div>
225
- <AddonUploadZone
226
- onUploadSuccess={() => {
227
- queryClient.invalidateQueries({ queryKey: ['addons'] })
228
- onRefresh()
229
- }}
230
- />
231
- <button
232
- type="button"
233
- onClick={onRefresh}
234
- disabled={isRefreshing}
235
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-surface hover:bg-surface-hover border border-border disabled:opacity-50 transition-colors"
236
- >
237
- <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
238
- Refresh
239
- </button>
240
- </div>
241
-
242
- {/* Search results dropdown */}
243
- {showSearch && availableAddons.length > 0 && (
244
- <div className="rounded-lg border border-border bg-surface overflow-hidden">
245
- <div className="px-3 py-2 text-[10px] uppercase tracking-wide text-foreground-subtle border-b border-border flex justify-between">
246
- <span>Available on npm ({availableAddons.length})</span>
247
- <button type="button" onClick={() => setShowSearch(false)} className="text-foreground-subtle hover:text-foreground">
248
- close
249
- </button>
250
- </div>
251
- <div className="max-h-64 overflow-auto divide-y divide-border">
252
- {availableAddons.map((addon) => (
253
- <div key={addon.name} className="flex items-center gap-3 px-3 py-2.5 hover:bg-surface-hover transition-colors">
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">
255
- {addon.name.replace('@camstack/addon-', '').charAt(0).toUpperCase()}
256
- </div>
257
- <div className="flex-1 min-w-0">
258
- <div className="flex items-center gap-2">
259
- <span className="text-xs font-semibold truncate">{addon.name}</span>
260
- <span className="text-[10px] text-foreground-subtle">v{addon.version}</span>
261
- </div>
262
- <div className="text-[10px] text-foreground-subtle truncate">{addon.description}</div>
263
- {addon.keywords && addon.keywords.length > 0 && (
264
- <div className="flex gap-1 mt-0.5 flex-wrap">
265
- {addon.keywords.filter((k) => k !== 'camstack' && k !== 'addon' && k !== 'camstack-addon').slice(0, 5).map((k) => (
266
- <span key={k} className="text-[9px] px-1.5 py-0 rounded bg-primary/5 text-foreground-subtle">{k}</span>
267
- ))}
268
- </div>
269
- )}
270
- </div>
271
- <button
272
- type="button"
273
- onClick={() => installMutation.mutate(addon.name)}
274
- disabled={installMutation.isPending}
275
- className="flex items-center gap-1 px-2.5 py-1 text-[10px] font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors shrink-0"
276
- >
277
- {installMutation.isPending ? (
278
- <Loader2 className="h-3 w-3 animate-spin" />
279
- ) : (
280
- <Download className="h-3 w-3" />
281
- )}
282
- Install
283
- </button>
284
- </div>
285
- ))}
286
- </div>
287
- </div>
288
- )}
289
-
290
- {/* Show "all installed" if search returned only installed results */}
291
- {showSearch && searchResults && availableAddons.length === 0 && searchResults.length > 0 && (
292
- <div className="text-xs text-foreground-subtle flex items-center gap-1.5 px-1">
293
- <Check className="h-3.5 w-3.5 text-green-500" />
294
- All matching addons are already installed
295
- </div>
296
- )}
297
-
298
- {/* Installed addons grouped */}
299
- {addons.length === 0 && (
300
- <div className="text-xs text-foreground-subtle">No addons installed</div>
301
- )}
302
-
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>
312
- </div>
313
- )
314
- }
315
-
316
- // ---------------------------------------------------------------------------
317
- // Main page
318
- // ---------------------------------------------------------------------------
319
-
320
- export function AddonsPage() {
321
- const client = useBackendClient()
322
- const queryClient = useQueryClient()
323
-
324
- const { data: rawAddons, isLoading, isError } = useQuery({
325
- queryKey: ['addons', 'list'],
326
- queryFn: () => client.listAddons(),
327
- })
328
-
329
- // Fetch agents for the "Add agent" dropdown in AddonCard
330
- const { data: rawAgents = [] } = useQuery({
331
- queryKey: ['agents', 'list'],
332
- queryFn: () => client.listAgents(),
333
- staleTime: 30_000,
334
- })
335
-
336
- const agents: AgentInfo[] = useMemo(
337
- () =>
338
- rawAgents.map((raw) => ({
339
- id: raw.info.id,
340
- name: raw.info.name ?? raw.info.id,
341
- isHub: raw.isHub,
342
- })),
343
- [rawAgents],
344
- )
345
-
346
- const reloadMutation = useMutation({
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()
351
- },
352
- onSuccess: () => {
353
- queryClient.invalidateQueries({ queryKey: ['addons'] })
354
- queryClient.invalidateQueries({ queryKey: ['updates'] })
355
- },
356
- })
357
-
358
- const addonList: AddonListItem[] = (rawAddons ?? []).map(normaliseAddon).filter((a): a is AddonListItem => a !== null)
359
-
360
- return (
361
- <div className="p-6 space-y-5">
362
- {/* Header */}
363
- <div className="flex items-center justify-between">
364
- <div>
365
- <h1 className="text-lg font-semibold text-foreground">Addons</h1>
366
- <p className="text-xs text-foreground-subtle mt-0.5">
367
- Manage installed addons and explore capability providers.
368
- </p>
369
- </div>
370
- {!isLoading && !isError && (
371
- <span className="text-[10px] rounded-full bg-primary/10 text-primary px-2 py-0.5 font-medium">
372
- {addonList.length} installed
373
- </span>
374
- )}
375
- </div>
376
-
377
- {/* Loading / error states */}
378
- {isLoading && (
379
- <div className="text-xs text-foreground-subtle animate-pulse">Loading…</div>
380
- )}
381
- {isError && (
382
- <div className="text-xs text-danger">Failed to load addons</div>
383
- )}
384
-
385
- {/* Installed addons */}
386
- {!isLoading && !isError && (
387
- <InstalledTab
388
- addons={addonList}
389
- agents={agents}
390
- onRefresh={() => reloadMutation.mutate()}
391
- isRefreshing={reloadMutation.isPending}
392
- />
393
- )}
394
- </div>
395
- )
396
- }