@camstack/addon-admin-ui 0.1.2 → 0.1.4

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-BoVZEQ1j.js +598 -0
  2. package/dist/assets/index-DwSc8ann.css +1 -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,239 +0,0 @@
1
- import { useState } from 'react'
2
- import { Info, ExternalLink, Check, Cpu } from 'lucide-react'
3
- import type { ConfigModelSelectorField, ModelCatalogEntry } from '../../types/config-ui'
4
-
5
- // ---------------------------------------------------------------------------
6
- // Types
7
- // ---------------------------------------------------------------------------
8
-
9
- type TabId = 'catalog' | 'custom'
10
-
11
- // ---------------------------------------------------------------------------
12
- // Sub-components
13
- // ---------------------------------------------------------------------------
14
-
15
- function SegmentedControl({
16
- tabs,
17
- active,
18
- onChange,
19
- }: {
20
- tabs: { id: TabId; label: string }[]
21
- active: TabId
22
- onChange: (id: TabId) => void
23
- }) {
24
- return (
25
- <div className="flex rounded-lg border border-border bg-surface p-0.5 gap-0.5">
26
- {tabs.map((tab) => (
27
- <button
28
- key={tab.id}
29
- type="button"
30
- onClick={() => onChange(tab.id)}
31
- className={[
32
- 'flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors',
33
- active === tab.id
34
- ? 'bg-primary/12 text-primary'
35
- : 'text-foreground-subtle hover:text-foreground hover:bg-surface-hover',
36
- ].join(' ')}
37
- >
38
- {tab.label}
39
- </button>
40
- ))}
41
- </div>
42
- )
43
- }
44
-
45
- function formatSize(entry: ModelCatalogEntry): string {
46
- const formats = entry.formats
47
- if (!formats) return ''
48
- const sizes = Object.values(formats)
49
- .filter((f): f is { url: string; sizeMB: number } => f !== undefined && f.sizeMB > 0)
50
- .map((f) => f.sizeMB)
51
- if (sizes.length === 0) return ''
52
- const min = Math.min(...sizes)
53
- const max = Math.max(...sizes)
54
- if (min === max) return `${min} MB`
55
- return `${min}-${max} MB`
56
- }
57
-
58
- function formatDimensions(entry: ModelCatalogEntry): string {
59
- return `${entry.inputSize.width}x${entry.inputSize.height}`
60
- }
61
-
62
- function formatFormats(entry: ModelCatalogEntry): string[] {
63
- if (!entry.formats) return []
64
- return Object.keys(entry.formats).filter((k) => entry.formats[k as keyof typeof entry.formats] !== undefined)
65
- }
66
-
67
- // ---------------------------------------------------------------------------
68
- // Model catalog card
69
- // ---------------------------------------------------------------------------
70
-
71
- function ModelCard({
72
- entry,
73
- selected,
74
- onSelect,
75
- disabled,
76
- }: {
77
- entry: ModelCatalogEntry
78
- selected: boolean
79
- onSelect: () => void
80
- disabled?: boolean
81
- }) {
82
- const size = formatSize(entry)
83
- const dims = formatDimensions(entry)
84
- const formats = formatFormats(entry)
85
-
86
- return (
87
- <button
88
- type="button"
89
- onClick={onSelect}
90
- disabled={disabled}
91
- className={[
92
- 'w-full text-left rounded-lg border p-3 transition-all',
93
- selected
94
- ? 'border-primary bg-primary/5 ring-1 ring-primary/30'
95
- : 'border-border bg-background hover:border-foreground-subtle/40 hover:bg-surface-hover/50',
96
- disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
97
- ].join(' ')}
98
- >
99
- <div className="flex items-start justify-between gap-2">
100
- <div className="flex-1 min-w-0">
101
- <div className="flex items-center gap-2">
102
- <span className="text-xs font-semibold text-foreground truncate">{entry.name}</span>
103
- {selected && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
104
- </div>
105
- <p className="text-[10px] text-foreground-subtle mt-0.5 line-clamp-2">{entry.description}</p>
106
- </div>
107
- </div>
108
-
109
- {/* Metadata row */}
110
- <div className="flex items-center gap-3 mt-2 flex-wrap">
111
- <span className="inline-flex items-center gap-1 text-[10px] text-foreground-subtle">
112
- <Cpu className="h-3 w-3" />
113
- {dims}
114
- </span>
115
- {size && (
116
- <span className="text-[10px] text-foreground-subtle">{size}</span>
117
- )}
118
- {formats.length > 0 && (
119
- <div className="flex gap-1">
120
- {formats.map((fmt) => (
121
- <span
122
- key={fmt}
123
- className="text-[9px] uppercase rounded bg-surface border border-border px-1 py-0.5 text-foreground-subtle font-mono"
124
- >
125
- {fmt}
126
- </span>
127
- ))}
128
- </div>
129
- )}
130
- {entry.labels.length > 0 && (
131
- <span className="text-[10px] text-foreground-subtle">
132
- {entry.labels.length} label{entry.labels.length !== 1 ? 's' : ''}
133
- </span>
134
- )}
135
- </div>
136
- </button>
137
- )
138
- }
139
-
140
- // ---------------------------------------------------------------------------
141
- // Main ModelSelector component
142
- // ---------------------------------------------------------------------------
143
-
144
- interface ModelSelectorProps {
145
- field: ConfigModelSelectorField
146
- value: unknown
147
- onChange: (v: unknown) => void
148
- disabled?: boolean
149
- }
150
-
151
- export function ModelSelector({ field, value, onChange, disabled }: ModelSelectorProps) {
152
- const catalog = field.catalog ?? []
153
- const hasCatalog = catalog.length > 0
154
- const allowCustomPath = field.allowCustomPath !== false || field.allowCustom === true
155
-
156
- const tabs: { id: TabId; label: string }[] = [
157
- ...(hasCatalog ? [{ id: 'catalog' as TabId, label: `Models (${catalog.length})` }] : []),
158
- ...(allowCustomPath ? [{ id: 'custom' as TabId, label: 'Custom Path' }] : []),
159
- ]
160
-
161
- const [activeTab, setActiveTab] = useState<TabId>(hasCatalog ? 'catalog' : 'custom')
162
-
163
- const currentValue = value === undefined || value === null ? '' : String(value)
164
-
165
- return (
166
- <div className="space-y-3">
167
- {tabs.length > 1 && (
168
- <SegmentedControl tabs={tabs} active={activeTab} onChange={setActiveTab} />
169
- )}
170
-
171
- {/* Catalog tab */}
172
- {activeTab === 'catalog' && hasCatalog && (
173
- <div className="space-y-1.5 max-h-[320px] overflow-y-auto pr-1">
174
- {catalog.map((entry) => (
175
- <ModelCard
176
- key={entry.id}
177
- entry={entry}
178
- selected={currentValue === entry.id}
179
- onSelect={() => onChange(entry.id)}
180
- disabled={disabled || field.disabled}
181
- />
182
- ))}
183
- </div>
184
- )}
185
-
186
- {/* Fallback when no catalog is available */}
187
- {activeTab === 'catalog' && !hasCatalog && (
188
- <div className="rounded-md border border-info/30 bg-info/5 px-3 py-2 flex items-start gap-2">
189
- <Info className="h-3.5 w-3.5 text-info shrink-0 mt-0.5" />
190
- <p className="text-[11px] text-foreground-subtle leading-relaxed">
191
- No predefined models available. Use the Custom Path tab to specify a model file path or URL.
192
- </p>
193
- </div>
194
- )}
195
-
196
- {/* Custom path tab */}
197
- {activeTab === 'custom' && allowCustomPath && (
198
- <div className="space-y-2">
199
- <input
200
- type="text"
201
- className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none disabled:opacity-50"
202
- placeholder="Enter path like /data/models/custom.onnx or https://..."
203
- value={currentValue}
204
- disabled={disabled || field.disabled}
205
- onChange={(e) => onChange(e.target.value)}
206
- />
207
- {field.acceptFormats && field.acceptFormats.length > 0 && (
208
- <p className="text-[10px] text-foreground-subtle">
209
- Accepted formats: {field.acceptFormats.join(', ')}
210
- </p>
211
- )}
212
- </div>
213
- )}
214
-
215
- {/* Footer: compatibility note + conversion guide */}
216
- {(field.compatibilityNote || field.conversionGuideUrl) && (
217
- <div className="flex items-start justify-between gap-3 pt-1">
218
- {field.compatibilityNote && (
219
- <p className="flex items-start gap-1 text-[10px] text-foreground-subtle">
220
- <Info className="h-3 w-3 mt-0.5 shrink-0" />
221
- {field.compatibilityNote}
222
- </p>
223
- )}
224
- {field.conversionGuideUrl && (
225
- <a
226
- href={field.conversionGuideUrl}
227
- target="_blank"
228
- rel="noopener noreferrer"
229
- className="flex items-center gap-1 text-[11px] text-primary hover:underline shrink-0"
230
- >
231
- How to convert your model
232
- <ExternalLink className="h-3 w-3" />
233
- </a>
234
- )}
235
- </div>
236
- )}
237
- </div>
238
- )
239
- }
@@ -1,205 +0,0 @@
1
- import { useState } from 'react'
2
- import { useTranslation } from 'react-i18next'
3
- import { useMutation, useQueryClient } from '@tanstack/react-query'
4
- import { X, Plus, Trash2, ChevronDown } from 'lucide-react'
5
- import { useBackendClient } from '../../hooks/useBackendClient'
6
-
7
- interface AddDeviceDialogProps {
8
- open: boolean
9
- providerId: string
10
- onClose: () => void
11
- }
12
-
13
- export function AddDeviceDialog({ open, providerId, onClose }: AddDeviceDialogProps) {
14
- const { t } = useTranslation()
15
- const client = useBackendClient()
16
- const queryClient = useQueryClient()
17
-
18
- const [name, setName] = useState('')
19
- const [snapshotUrl, setSnapshotUrl] = useState('')
20
- const [streamUrls, setStreamUrls] = useState<string[]>([''])
21
- const [testResults, setTestResults] = useState<Record<number, { codec?: string; resolution?: string; fps?: number; audio?: boolean; error?: string }>>({})
22
-
23
- const createMutation = useMutation({
24
- mutationFn: (config: Record<string, unknown>) => client.createDevice(providerId, config),
25
- onSuccess: () => {
26
- queryClient.invalidateQueries({ queryKey: ['devices'] })
27
- },
28
- })
29
-
30
- const testMutation = useMutation({
31
- mutationFn: async () => {
32
- const results: Record<number, { codec?: string; resolution?: string; fps?: number; error?: string }> = {}
33
- for (let i = 0; i < streamUrls.length; i++) {
34
- const url = streamUrls[i]
35
- if (!url.trim()) continue
36
- try {
37
- // TODO: Replace with dedicated ffprobe endpoint when available
38
- await client.trpc.providerConfig.testConnection.mutate({
39
- type: 'rtsp',
40
- url: url,
41
- })
42
- results[i] = { codec: 'OK', resolution: 'Connected' }
43
- } catch (err) {
44
- results[i] = { error: String(err) }
45
- }
46
- }
47
- setTestResults(results)
48
- },
49
- })
50
-
51
- function addStreamUrl() {
52
- setStreamUrls((prev) => [...prev, ''])
53
- }
54
-
55
- function removeStreamUrl(index: number) {
56
- setStreamUrls((prev) => prev.filter((_, i) => i !== index))
57
- }
58
-
59
- function updateStreamUrl(index: number, value: string) {
60
- setStreamUrls((prev) => prev.map((url, i) => (i === index ? value : url)))
61
- }
62
-
63
- async function handleSave(andNew: boolean) {
64
- const config = {
65
- name,
66
- snapshotUrl: snapshotUrl || undefined,
67
- streams: streamUrls.filter((url) => url.trim()),
68
- }
69
- await createMutation.mutateAsync(config)
70
- if (andNew) {
71
- setName('')
72
- setSnapshotUrl('')
73
- setStreamUrls([''])
74
- } else {
75
- onClose()
76
- }
77
- }
78
-
79
- if (!open) return null
80
-
81
- return (
82
- <div className="fixed inset-0 z-50 flex items-center justify-center">
83
- <div className="absolute inset-0 bg-black/60" onClick={onClose} />
84
- <div className="relative z-10 w-full max-w-md rounded-xl border border-border bg-background shadow-2xl">
85
- <div className="flex items-center justify-between border-b border-border px-5 py-4">
86
- <h2 className="text-sm font-semibold text-foreground">{t('integrations.addDevice')}</h2>
87
- <button onClick={onClose} className="rounded-md p-1 text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors">
88
- <X className="h-4 w-4" />
89
- </button>
90
- </div>
91
-
92
- <div className="px-5 py-4 space-y-3">
93
- <div>
94
- <label className="block text-[11px] font-medium text-foreground mb-1">{t('integrations.name')}</label>
95
- <input
96
- type="text"
97
- value={name}
98
- onChange={(e) => setName(e.target.value)}
99
- placeholder={t('integrations.namePlaceholder')}
100
- className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-primary/50"
101
- />
102
- </div>
103
-
104
- <div>
105
- <label className="block text-[11px] font-medium text-foreground mb-1">{t('integrations.snapshotUrlOptional')}</label>
106
- <input
107
- type="text"
108
- value={snapshotUrl}
109
- onChange={(e) => setSnapshotUrl(e.target.value)}
110
- placeholder="http://192.168.1.100/snapshot.jpg"
111
- className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-primary/50"
112
- />
113
- </div>
114
-
115
- <div>
116
- <div className="flex items-center justify-between mb-1">
117
- <label className="text-[11px] font-medium text-foreground">{t('integrations.streamUrls')}</label>
118
- <button
119
- onClick={addStreamUrl}
120
- className="flex items-center gap-1 text-[10px] text-primary hover:text-primary/80 transition-colors"
121
- >
122
- <Plus className="h-3 w-3" /> {t('integrations.addStream')}
123
- </button>
124
- </div>
125
- <div className="space-y-2">
126
- {streamUrls.map((url, index) => (
127
- <div key={index} className="flex items-center gap-2">
128
- <input
129
- type="text"
130
- value={url}
131
- onChange={(e) => updateStreamUrl(index, e.target.value)}
132
- placeholder="rtsp://192.168.1.100:554/stream1"
133
- className="flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-xs text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-primary/50"
134
- />
135
- {streamUrls.length > 1 && (
136
- <button
137
- onClick={() => removeStreamUrl(index)}
138
- className="rounded-md p-1.5 text-foreground-subtle hover:text-danger hover:bg-danger/10 transition-colors"
139
- >
140
- <Trash2 className="h-3.5 w-3.5" />
141
- </button>
142
- )}
143
- </div>
144
- ))}
145
- </div>
146
- </div>
147
-
148
- {Object.keys(testResults).length > 0 && (
149
- <div className="space-y-1">
150
- {Object.entries(testResults).map(([idx, result]) => (
151
- <div key={idx} className={`rounded-lg border px-3 py-1.5 text-[10px] ${
152
- result.error ? 'border-danger/30 bg-danger/5 text-danger' : 'border-success/30 bg-success/5 text-success'
153
- }`}>
154
- {result.error ? `${t('integrations.stream', { number: Number(idx) + 1 })} ${result.error}` : `${t('integrations.stream', { number: Number(idx) + 1 })} ${result.codec} · ${result.resolution}`}
155
- </div>
156
- ))}
157
- </div>
158
- )}
159
- </div>
160
-
161
- <div className="flex items-center justify-between border-t border-border px-5 py-3">
162
- <button
163
- onClick={onClose}
164
- className="rounded-lg border border-border px-3 py-1.5 text-xs text-foreground-subtle hover:text-foreground transition-colors"
165
- >
166
- {t('integrations.cancel')}
167
- </button>
168
- <div className="flex items-center gap-2">
169
- <button
170
- onClick={() => testMutation.mutate()}
171
- disabled={streamUrls.every((u) => !u.trim()) || testMutation.isPending}
172
- className="rounded-lg border border-border px-3 py-1.5 text-xs text-foreground hover:bg-surface-hover transition-colors disabled:opacity-50"
173
- >
174
- {testMutation.isPending ? t('integrations.testing') : t('integrations.test')}
175
- </button>
176
- <button
177
- onClick={() => handleSave(false)}
178
- disabled={!name.trim() || streamUrls.every((u) => !u.trim()) || createMutation.isPending}
179
- className="rounded-l-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground shadow-sm disabled:opacity-50"
180
- >
181
- {createMutation.isPending ? t('integrations.saving') : t('integrations.save')}
182
- </button>
183
- <div className="relative group">
184
- <button
185
- disabled={!name.trim() || streamUrls.every((u) => !u.trim())}
186
- className="rounded-r-lg border-l border-primary-foreground/20 bg-primary px-2 py-1.5 text-primary-foreground disabled:opacity-50"
187
- >
188
- <ChevronDown className="h-3 w-3" />
189
- </button>
190
- <div className="absolute right-0 bottom-full mb-1 hidden group-hover:block">
191
- <button
192
- onClick={() => handleSave(true)}
193
- disabled={!name.trim() || streamUrls.every((u) => !u.trim())}
194
- className="whitespace-nowrap rounded-lg border border-border bg-surface px-3 py-1.5 text-xs text-foreground shadow-lg hover:bg-surface-hover disabled:opacity-50"
195
- >
196
- {t('integrations.saveAndNew')}
197
- </button>
198
- </div>
199
- </div>
200
- </div>
201
- </div>
202
- </div>
203
- </div>
204
- )
205
- }
@@ -1,35 +0,0 @@
1
- import { useNavigate } from 'react-router-dom'
2
- import { Camera } from 'lucide-react'
3
- import { SnapshotPopover } from './SnapshotPopover'
4
-
5
- interface CompactDeviceCardProps {
6
- id: string
7
- name: string
8
- status: string
9
- snapshotUrl?: string | null
10
- resolution?: string | null
11
- codec?: string | null
12
- fps?: number | null
13
- lastSnapshot?: string | null
14
- }
15
-
16
- export function CompactDeviceCard({ id, name, status, snapshotUrl, resolution, codec, fps, lastSnapshot }: CompactDeviceCardProps) {
17
- const navigate = useNavigate()
18
-
19
- return (
20
- <div
21
- onClick={() => navigate(`/devices/${id}`)}
22
- className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2.5 cursor-pointer hover:border-foreground-subtle/30 transition-colors"
23
- >
24
- <Camera className="h-3.5 w-3.5 text-foreground-subtle flex-shrink-0" />
25
- <span className="text-[11px] font-semibold text-foreground flex-1 truncate">{name}</span>
26
- <SnapshotPopover deviceName={name} snapshotUrl={snapshotUrl} status={status} resolution={resolution} codec={codec} fps={fps} lastSnapshot={lastSnapshot} />
27
- <span
28
- className={`h-1.5 w-1.5 rounded-full flex-shrink-0 ${
29
- status === 'online' ? 'bg-success' : 'bg-danger'
30
- }`}
31
- title={status === 'online' ? 'Online' : 'Offline'}
32
- />
33
- </div>
34
- )
35
- }
@@ -1,29 +0,0 @@
1
- import { useNavigate } from 'react-router-dom'
2
- import { StatusBadge } from '../shared/StatusBadge'
3
- import { CapabilityBadges } from '../shared/CapabilityBadges'
4
-
5
- interface DeviceCardProps {
6
- id: string
7
- name: string
8
- status: string
9
- capabilities: string[]
10
- }
11
-
12
- export function DeviceCard({ id, name, status, capabilities }: DeviceCardProps) {
13
- const navigate = useNavigate()
14
-
15
- return (
16
- <button
17
- onClick={() => navigate(`/devices/${id}`)}
18
- className="flex items-center gap-3 rounded-lg border border-border bg-surface p-3 text-left hover:border-primary/30 hover:shadow-md hover:shadow-black/5 transition-all w-full"
19
- >
20
- <div className="flex-1 min-w-0">
21
- <p className="text-xs font-semibold text-foreground truncate">{name}</p>
22
- <div className="mt-1.5">
23
- <CapabilityBadges capabilities={capabilities} />
24
- </div>
25
- </div>
26
- <StatusBadge status={status} />
27
- </button>
28
- )
29
- }
@@ -1,105 +0,0 @@
1
- import { useState } from 'react'
2
- import { useTranslation } from 'react-i18next'
3
- import { useQuery } from '@tanstack/react-query'
4
- import { Camera, Check } from 'lucide-react'
5
- import { useBackendClient } from '../../hooks/useBackendClient'
6
-
7
- interface DeviceDiscoveryStepProps {
8
- providerId: string
9
- onImport: (externalIds: readonly string[]) => void
10
- onSkip: () => void
11
- }
12
-
13
- export function DeviceDiscoveryStep({ providerId, onImport, onSkip }: DeviceDiscoveryStepProps) {
14
- const { t } = useTranslation()
15
- const client = useBackendClient()
16
- const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
17
-
18
- const { data: discovered, isLoading } = useQuery({
19
- queryKey: ['discover-devices', providerId],
20
- queryFn: () => client.discoverDevices(providerId),
21
- })
22
-
23
- function toggleDevice(externalId: string) {
24
- setSelected((prev) => {
25
- const next = new Set(prev)
26
- if (next.has(externalId)) {
27
- next.delete(externalId)
28
- } else {
29
- next.add(externalId)
30
- }
31
- return next
32
- })
33
- }
34
-
35
- if (isLoading) {
36
- return (
37
- <div className="flex flex-col items-center py-12">
38
- <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
39
- <p className="mt-3 text-xs text-foreground-subtle">{t('integrations.searchingDevices')}</p>
40
- </div>
41
- )
42
- }
43
-
44
- const devices = discovered ?? []
45
-
46
- return (
47
- <div className="space-y-4">
48
- <p className="text-xs text-foreground-subtle">
49
- {devices.length > 0
50
- ? t('integrations.foundDevices', { count: devices.length })
51
- : t('integrations.noDevicesFound')}
52
- </p>
53
-
54
- {devices.length > 0 && (
55
- <div className="space-y-1.5 max-h-64 overflow-y-auto">
56
- {devices.map((device) => {
57
- const id = device.externalId
58
- const name = device.name ?? id
59
- const isSelected = selected.has(id)
60
-
61
- return (
62
- <button
63
- key={id}
64
- onClick={() => toggleDevice(id)}
65
- className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${
66
- isSelected
67
- ? 'border-primary/40 bg-primary/5'
68
- : 'border-border bg-surface hover:border-foreground-subtle/30'
69
- }`}
70
- >
71
- <div
72
- className={`flex h-5 w-5 items-center justify-center rounded border flex-shrink-0 ${
73
- isSelected ? 'border-primary bg-primary' : 'border-border'
74
- }`}
75
- >
76
- {isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
77
- </div>
78
- <Camera className="h-3.5 w-3.5 text-foreground-subtle flex-shrink-0" />
79
- <span className="text-xs font-medium text-foreground truncate">{name}</span>
80
- </button>
81
- )
82
- })}
83
- </div>
84
- )}
85
-
86
- <div className="flex items-center justify-between pt-2">
87
- <button
88
- onClick={onSkip}
89
- className="rounded-lg border border-border px-3 py-1.5 text-xs text-foreground-subtle hover:text-foreground transition-colors"
90
- >
91
- {t('integrations.skip')}
92
- </button>
93
- {devices.length > 0 && (
94
- <button
95
- onClick={() => onImport(Array.from(selected))}
96
- disabled={selected.size === 0}
97
- className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground shadow-sm disabled:opacity-50"
98
- >
99
- {t('integrations.importSelected')} ({selected.size})
100
- </button>
101
- )}
102
- </div>
103
- </div>
104
- )
105
- }