@camstack/addon-admin-ui 0.1.1

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 (122) hide show
  1. package/index.html +22 -0
  2. package/package.json +69 -0
  3. package/public/brand/logo-dark.svg +16 -0
  4. package/public/brand/logo-horizontal-dark.svg +21 -0
  5. package/public/brand/logo-horizontal-light.svg +21 -0
  6. package/public/brand/logo-light.svg +16 -0
  7. package/public/brand/logo-wide-dark.svg +24 -0
  8. package/public/brand/logo-wide-light.svg +24 -0
  9. package/public/favicon.svg +8 -0
  10. package/public/vendor/react-jsx-runtime.mjs +24 -0
  11. package/public/vendor/react.mjs +16 -0
  12. package/src/App.tsx +71 -0
  13. package/src/components/addons/AddonCard.tsx +339 -0
  14. package/src/components/addons/AddonUploadZone.tsx +307 -0
  15. package/src/components/addons/CapabilityBadge.tsx +55 -0
  16. package/src/components/addons/CapabilityMap.tsx +133 -0
  17. package/src/components/addons/UpdatesList.tsx +119 -0
  18. package/src/components/agents/AgentCard.tsx +281 -0
  19. package/src/components/agents/AgentLogs.tsx +231 -0
  20. package/src/components/agents/ProcessList.tsx +127 -0
  21. package/src/components/agents/ProcessTree.tsx +369 -0
  22. package/src/components/agents/TaskList.tsx +68 -0
  23. package/src/components/cameras/CameraCard.tsx +60 -0
  24. package/src/components/cameras/LiveEventsPanel.tsx +91 -0
  25. package/src/components/cameras/ProviderSection.tsx +50 -0
  26. package/src/components/cameras/StreamArea.tsx +107 -0
  27. package/src/components/cameras/tabs/AddonsTab.tsx +113 -0
  28. package/src/components/cameras/tabs/CameraEventsTab.tsx +129 -0
  29. package/src/components/cameras/tabs/PipelineTab.tsx +118 -0
  30. package/src/components/cameras/tabs/StreamsTab.tsx +114 -0
  31. package/src/components/dashboard/BlockPicker.tsx +54 -0
  32. package/src/components/dashboard/BlockWrapper.tsx +97 -0
  33. package/src/components/dashboard/DashboardGrid.tsx +160 -0
  34. package/src/components/dashboard/block-registry.ts +15 -0
  35. package/src/components/dashboard/blocks/PipelineStagesBlock.tsx +39 -0
  36. package/src/components/dashboard/blocks/StorageBlock.tsx +66 -0
  37. package/src/components/dashboard/blocks/SystemStatusBlock.tsx +67 -0
  38. package/src/components/dashboard/blocks/index.ts +32 -0
  39. package/src/components/device/DeviceHeader.tsx +116 -0
  40. package/src/components/device/FloatingPanel.tsx +132 -0
  41. package/src/components/device/FloatingPanelManager.tsx +167 -0
  42. package/src/components/device/PanelContent.tsx +196 -0
  43. package/src/components/device/QuickConfigWizard.tsx +507 -0
  44. package/src/components/device/tabs/DetectionConfigTab.tsx +96 -0
  45. package/src/components/device/tabs/EventsTab.tsx +19 -0
  46. package/src/components/device/tabs/LogsTab.tsx +22 -0
  47. package/src/components/device/tabs/OverviewTab.tsx +104 -0
  48. package/src/components/device/tabs/ProviderSettingsTab.tsx +34 -0
  49. package/src/components/device/tabs/RecordingTab.tsx +47 -0
  50. package/src/components/device/tabs/ReplTab.tsx +153 -0
  51. package/src/components/device/tabs/TrackTrailTab.tsx +49 -0
  52. package/src/components/device/tabs/ZonesTab.tsx +98 -0
  53. package/src/components/device/zone-editor/ZoneCanvas.tsx +354 -0
  54. package/src/components/device/zone-editor/ZoneForm.tsx +128 -0
  55. package/src/components/device/zone-editor/ZoneList.tsx +150 -0
  56. package/src/components/form-builder/FormBuilder.tsx +135 -0
  57. package/src/components/form-builder/FormField.tsx +732 -0
  58. package/src/components/form-builder/ModelSelector.tsx +239 -0
  59. package/src/components/integrations/AddDeviceDialog.tsx +205 -0
  60. package/src/components/integrations/CompactDeviceCard.tsx +35 -0
  61. package/src/components/integrations/DeviceCard.tsx +29 -0
  62. package/src/components/integrations/DeviceDiscoveryStep.tsx +105 -0
  63. package/src/components/integrations/DeviceGrid.tsx +79 -0
  64. package/src/components/integrations/DeviceGroupHeader.tsx +17 -0
  65. package/src/components/integrations/DiscoveredDeviceCard.tsx +26 -0
  66. package/src/components/integrations/IntegrationCard.tsx +40 -0
  67. package/src/components/integrations/IntegrationWizard.tsx +171 -0
  68. package/src/components/integrations/ProviderConfigForm.tsx +89 -0
  69. package/src/components/integrations/ProviderPicker.tsx +91 -0
  70. package/src/components/integrations/SnapshotPopover.tsx +68 -0
  71. package/src/components/metrics/AgentLoad.tsx +113 -0
  72. package/src/components/metrics/IntegrationUsage.tsx +90 -0
  73. package/src/components/metrics/PipelineStatus.tsx +105 -0
  74. package/src/components/metrics/ProcessResources.tsx +139 -0
  75. package/src/components/pipeline/PhaseSettings.tsx +131 -0
  76. package/src/components/shared/CapabilityBadges.tsx +30 -0
  77. package/src/components/shared/ProviderIcon.tsx +42 -0
  78. package/src/components/shared/StatusBadge.tsx +23 -0
  79. package/src/components/shared/WebRtcPlayer.tsx +211 -0
  80. package/src/components/timeline/EventMarker.tsx +32 -0
  81. package/src/components/timeline/TimelineBar.tsx +131 -0
  82. package/src/components/ui/ConfirmDialog.tsx +115 -0
  83. package/src/components/ui/ToastContainer.tsx +92 -0
  84. package/src/contexts/auth-context.tsx +91 -0
  85. package/src/hooks/useBackendClient.ts +6 -0
  86. package/src/hooks/useTheme.ts +1 -0
  87. package/src/i18n/en.json +164 -0
  88. package/src/i18n/index.ts +29 -0
  89. package/src/i18n/it.json +164 -0
  90. package/src/index.css +63 -0
  91. package/src/layouts/AddonPageLoader.tsx +120 -0
  92. package/src/layouts/AppLayout.tsx +238 -0
  93. package/src/layouts/ProtectedRoute.tsx +25 -0
  94. package/src/lib/addon-page-context.ts +29 -0
  95. package/src/lib/backend.ts +16 -0
  96. package/src/main.tsx +21 -0
  97. package/src/pages/AccessDenied.tsx +22 -0
  98. package/src/pages/Cameras.tsx +127 -0
  99. package/src/pages/Dashboard.tsx +6 -0
  100. package/src/pages/DeviceDetail.tsx +175 -0
  101. package/src/pages/IntegrationDetail.tsx +224 -0
  102. package/src/pages/Integrations.tsx +330 -0
  103. package/src/pages/Login.tsx +106 -0
  104. package/src/pages/Metrics.tsx +18 -0
  105. package/src/pages/PipelineConfig.tsx +282 -0
  106. package/src/pages/Showroom.tsx +351 -0
  107. package/src/pages/Timeline.tsx +269 -0
  108. package/src/pages/system/Addons.tsx +525 -0
  109. package/src/pages/system/Agents.tsx +362 -0
  110. package/src/pages/system/Logs.tsx +131 -0
  111. package/src/pages/system/Models.tsx +102 -0
  112. package/src/pages/system/Processes.tsx +129 -0
  113. package/src/pages/system/Repl.tsx +148 -0
  114. package/src/pages/system/Settings.tsx +168 -0
  115. package/src/pages/system/Users.tsx +174 -0
  116. package/src/server/addon.ts +54 -0
  117. package/src/types/config-ui.ts +210 -0
  118. package/src/types/dashboard.ts +39 -0
  119. package/tsconfig.json +29 -0
  120. package/tsconfig.server.json +16 -0
  121. package/tsup.config.ts +20 -0
  122. package/vite.config.ts +68 -0
@@ -0,0 +1,732 @@
1
+ import { useState, useCallback, useRef } from 'react'
2
+ import { Eye, EyeOff, X } from 'lucide-react'
3
+ import type {
4
+ ConfigField,
5
+ ConfigGroupField,
6
+ ConfigInfoField,
7
+ ConfigMultiSelectField,
8
+ ConfigNumberField,
9
+ ConfigBooleanField,
10
+ ConfigSelectField,
11
+ ConfigColorField,
12
+ ConfigTextAreaField,
13
+ ConfigSliderField,
14
+ ConfigTagsField,
15
+ ConfigPasswordField,
16
+ ConfigTextField,
17
+ ConfigModelSelectorField,
18
+ } from '../../types/config-ui'
19
+ import { ModelSelector } from './ModelSelector'
20
+ import type { TranslationFn } from './FormBuilder'
21
+ import { resolveLabel } from './FormBuilder'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Props
25
+ // ---------------------------------------------------------------------------
26
+
27
+ interface FormFieldProps {
28
+ field: ConfigField
29
+ values: Record<string, unknown>
30
+ onChange: (key: string, value: unknown) => void
31
+ disabled?: boolean
32
+ translationFn?: TranslationFn
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const INPUT_CLASS =
40
+ '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 disabled:cursor-not-allowed'
41
+
42
+ const LABEL_CLASS = 'block text-xs font-medium text-foreground mb-1'
43
+ const DESC_CLASS = 'text-[10px] text-foreground-subtle mt-0.5'
44
+
45
+ function FieldWrapper({
46
+ label,
47
+ description,
48
+ required,
49
+ span,
50
+ children,
51
+ translationFn,
52
+ }: {
53
+ label?: string
54
+ description?: string
55
+ required?: boolean
56
+ span?: number
57
+ children: React.ReactNode
58
+ translationFn?: TranslationFn
59
+ }) {
60
+ const colSpanClass =
61
+ span === 2
62
+ ? 'col-span-2'
63
+ : span === 3
64
+ ? 'col-span-3'
65
+ : span === 4
66
+ ? 'col-span-4'
67
+ : 'col-span-1'
68
+
69
+ const resolvedLabel = resolveLabel(label, translationFn)
70
+ const resolvedDescription = resolveLabel(description, translationFn)
71
+
72
+ return (
73
+ <div className={colSpanClass}>
74
+ {resolvedLabel !== undefined && (
75
+ <label className={LABEL_CLASS}>
76
+ {resolvedLabel}
77
+ {required && <span className="text-danger ml-0.5">*</span>}
78
+ </label>
79
+ )}
80
+ {children}
81
+ {resolvedDescription && <p className={DESC_CLASS}>{resolvedDescription}</p>}
82
+ </div>
83
+ )
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Field renderers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function TextField({
91
+ field,
92
+ value,
93
+ onChange,
94
+ disabled,
95
+ translationFn,
96
+ }: {
97
+ field: ConfigTextField
98
+ value: unknown
99
+ onChange: (v: unknown) => void
100
+ disabled?: boolean
101
+ translationFn?: TranslationFn
102
+ }) {
103
+ return (
104
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
105
+ <input
106
+ type={field.inputType ?? 'text'}
107
+ className={INPUT_CLASS}
108
+ value={value === undefined || value === null ? '' : String(value)}
109
+ placeholder={field.placeholder}
110
+ maxLength={field.maxLength}
111
+ pattern={field.pattern}
112
+ disabled={disabled || field.disabled}
113
+ onChange={(e) => onChange(e.target.value)}
114
+ />
115
+ </FieldWrapper>
116
+ )
117
+ }
118
+
119
+ function NumberField({
120
+ field,
121
+ value,
122
+ onChange,
123
+ disabled,
124
+ translationFn,
125
+ }: {
126
+ field: ConfigNumberField
127
+ value: unknown
128
+ onChange: (v: unknown) => void
129
+ disabled?: boolean
130
+ translationFn?: TranslationFn
131
+ }) {
132
+ return (
133
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
134
+ <div className="flex items-center gap-1">
135
+ <input
136
+ type="number"
137
+ className={INPUT_CLASS}
138
+ value={value === undefined || value === null ? '' : String(value)}
139
+ placeholder={field.placeholder}
140
+ min={field.min}
141
+ max={field.max}
142
+ step={field.step}
143
+ disabled={disabled || field.disabled}
144
+ onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
145
+ />
146
+ {field.unit && (
147
+ <span className="text-xs text-foreground-subtle whitespace-nowrap">{field.unit}</span>
148
+ )}
149
+ </div>
150
+ </FieldWrapper>
151
+ )
152
+ }
153
+
154
+ function BooleanField({
155
+ field,
156
+ value,
157
+ onChange,
158
+ disabled,
159
+ translationFn,
160
+ }: {
161
+ field: ConfigBooleanField
162
+ value: unknown
163
+ onChange: (v: unknown) => void
164
+ disabled?: boolean
165
+ translationFn?: TranslationFn
166
+ }) {
167
+ const checked = Boolean(value)
168
+ const isDisabled = disabled || field.disabled
169
+
170
+ if (field.style === 'checkbox') {
171
+ return (
172
+ <FieldWrapper label={undefined} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
173
+ <label className="flex items-center gap-2 cursor-pointer select-none">
174
+ <input
175
+ type="checkbox"
176
+ className="h-3.5 w-3.5 rounded border-border accent-primary"
177
+ checked={checked}
178
+ disabled={isDisabled}
179
+ onChange={(e) => onChange(e.target.checked)}
180
+ />
181
+ <span className="text-xs font-medium text-foreground">{resolveLabel(field.label, translationFn)}</span>
182
+ {field.required && <span className="text-danger">*</span>}
183
+ </label>
184
+ </FieldWrapper>
185
+ )
186
+ }
187
+
188
+ // Default: toggle switch
189
+ return (
190
+ <FieldWrapper label={undefined} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
191
+ <div className="flex items-center justify-between">
192
+ <span className={LABEL_CLASS + ' mb-0'}>
193
+ {resolveLabel(field.label, translationFn)}
194
+ {field.required && <span className="text-danger ml-0.5">*</span>}
195
+ </span>
196
+ <button
197
+ type="button"
198
+ role="switch"
199
+ aria-checked={checked}
200
+ disabled={isDisabled}
201
+ onClick={() => !isDisabled && onChange(!checked)}
202
+ className={[
203
+ 'relative inline-flex h-4 w-8 shrink-0 items-center rounded-full transition-colors duration-150',
204
+ checked ? 'bg-primary' : 'bg-foreground-subtle/30',
205
+ isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
206
+ ].join(' ')}
207
+ >
208
+ <span
209
+ className={[
210
+ 'inline-block h-3 w-3 rounded-full bg-white shadow transition-transform duration-150',
211
+ checked ? 'translate-x-4' : 'translate-x-0.5',
212
+ ].join(' ')}
213
+ />
214
+ </button>
215
+ </div>
216
+ </FieldWrapper>
217
+ )
218
+ }
219
+
220
+ function SelectField({
221
+ field,
222
+ value,
223
+ onChange,
224
+ disabled,
225
+ translationFn,
226
+ }: {
227
+ field: ConfigSelectField
228
+ value: unknown
229
+ onChange: (v: unknown) => void
230
+ disabled?: boolean
231
+ translationFn?: TranslationFn
232
+ }) {
233
+ return (
234
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
235
+ <select
236
+ className={INPUT_CLASS}
237
+ value={value === undefined || value === null ? '' : String(value)}
238
+ disabled={disabled || field.disabled}
239
+ onChange={(e) => onChange(e.target.value)}
240
+ >
241
+ {!field.required && <option value="">— Select —</option>}
242
+ {field.options.map((opt) => (
243
+ <option key={opt.value} value={opt.value}>
244
+ {opt.label}
245
+ </option>
246
+ ))}
247
+ </select>
248
+ </FieldWrapper>
249
+ )
250
+ }
251
+
252
+ function MultiSelectField({
253
+ field,
254
+ value,
255
+ onChange,
256
+ disabled,
257
+ translationFn,
258
+ }: {
259
+ field: ConfigMultiSelectField
260
+ value: unknown
261
+ onChange: (v: unknown) => void
262
+ disabled?: boolean
263
+ translationFn?: TranslationFn
264
+ }) {
265
+ const selected = Array.isArray(value) ? (value as string[]) : []
266
+
267
+ const toggle = (optValue: string) => {
268
+ if (selected.includes(optValue)) {
269
+ onChange(selected.filter((v) => v !== optValue))
270
+ } else {
271
+ if (field.maxItems !== undefined && selected.length >= field.maxItems) return
272
+ onChange([...selected, optValue])
273
+ }
274
+ }
275
+
276
+ return (
277
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
278
+ <div className="flex flex-wrap gap-2">
279
+ {field.options.map((opt) => {
280
+ const checked = selected.includes(opt.value)
281
+ return (
282
+ <label
283
+ key={opt.value}
284
+ className={[
285
+ 'flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs cursor-pointer select-none transition-colors',
286
+ checked
287
+ ? 'border-primary bg-primary/10 text-primary'
288
+ : 'border-border bg-background text-foreground hover:bg-surface',
289
+ (disabled || field.disabled) ? 'opacity-50 cursor-not-allowed' : '',
290
+ ].join(' ')}
291
+ >
292
+ <input
293
+ type="checkbox"
294
+ className="sr-only"
295
+ checked={checked}
296
+ disabled={disabled || field.disabled}
297
+ onChange={() => toggle(opt.value)}
298
+ />
299
+ {opt.label}
300
+ </label>
301
+ )
302
+ })}
303
+ </div>
304
+ </FieldWrapper>
305
+ )
306
+ }
307
+
308
+ function PasswordField({
309
+ field,
310
+ value,
311
+ onChange,
312
+ disabled,
313
+ translationFn,
314
+ }: {
315
+ field: ConfigPasswordField
316
+ value: unknown
317
+ onChange: (v: unknown) => void
318
+ disabled?: boolean
319
+ translationFn?: TranslationFn
320
+ }) {
321
+ const [show, setShow] = useState(false)
322
+ const showToggle = field.showToggle !== false
323
+
324
+ return (
325
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
326
+ <div className="relative">
327
+ <input
328
+ type={show ? 'text' : 'password'}
329
+ className={INPUT_CLASS + (showToggle ? ' pr-9' : '')}
330
+ value={value === undefined || value === null ? '' : String(value)}
331
+ placeholder={field.placeholder}
332
+ disabled={disabled || field.disabled}
333
+ onChange={(e) => onChange(e.target.value)}
334
+ />
335
+ {showToggle && (
336
+ <button
337
+ type="button"
338
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-foreground-subtle hover:text-foreground"
339
+ tabIndex={-1}
340
+ onClick={() => setShow((s) => !s)}
341
+ >
342
+ {show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
343
+ </button>
344
+ )}
345
+ </div>
346
+ </FieldWrapper>
347
+ )
348
+ }
349
+
350
+ function TextAreaField({
351
+ field,
352
+ value,
353
+ onChange,
354
+ disabled,
355
+ translationFn,
356
+ }: {
357
+ field: ConfigTextAreaField
358
+ value: unknown
359
+ onChange: (v: unknown) => void
360
+ disabled?: boolean
361
+ translationFn?: TranslationFn
362
+ }) {
363
+ return (
364
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
365
+ <textarea
366
+ className={INPUT_CLASS + ' resize-y min-h-[60px]'}
367
+ value={value === undefined || value === null ? '' : String(value)}
368
+ placeholder={field.placeholder}
369
+ rows={field.rows ?? 3}
370
+ maxLength={field.maxLength}
371
+ disabled={disabled || field.disabled}
372
+ onChange={(e) => onChange(e.target.value)}
373
+ />
374
+ </FieldWrapper>
375
+ )
376
+ }
377
+
378
+ function SliderField({
379
+ field,
380
+ value,
381
+ onChange,
382
+ disabled,
383
+ translationFn,
384
+ }: {
385
+ field: ConfigSliderField
386
+ value: unknown
387
+ onChange: (v: unknown) => void
388
+ disabled?: boolean
389
+ translationFn?: TranslationFn
390
+ }) {
391
+ const numVal = value === undefined || value === null ? field.min : Number(value)
392
+ const showValue = field.showValue !== false
393
+
394
+ return (
395
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
396
+ <div className="flex items-center gap-3">
397
+ <input
398
+ type="range"
399
+ className="flex-1 h-1 accent-primary cursor-pointer disabled:opacity-50"
400
+ min={field.min}
401
+ max={field.max}
402
+ step={field.step ?? 1}
403
+ value={numVal}
404
+ disabled={disabled || field.disabled}
405
+ onChange={(e) => onChange(Number(e.target.value))}
406
+ />
407
+ {showValue && (
408
+ <span className="text-xs text-foreground tabular-nums min-w-[2.5rem] text-right">
409
+ {numVal}
410
+ {field.unit ? ` ${field.unit}` : ''}
411
+ </span>
412
+ )}
413
+ </div>
414
+ </FieldWrapper>
415
+ )
416
+ }
417
+
418
+ function TagsField({
419
+ field,
420
+ value,
421
+ onChange,
422
+ disabled,
423
+ translationFn,
424
+ }: {
425
+ field: ConfigTagsField
426
+ value: unknown
427
+ onChange: (v: unknown) => void
428
+ disabled?: boolean
429
+ translationFn?: TranslationFn
430
+ }) {
431
+ const tags = Array.isArray(value) ? (value as string[]) : []
432
+ const [input, setInput] = useState('')
433
+ const inputRef = useRef<HTMLInputElement>(null)
434
+
435
+ const addTag = useCallback(
436
+ (tag: string) => {
437
+ const trimmed = tag.trim()
438
+ if (!trimmed || tags.includes(trimmed)) return
439
+ if (field.maxTags !== undefined && tags.length >= field.maxTags) return
440
+ onChange([...tags, trimmed])
441
+ setInput('')
442
+ },
443
+ [tags, field.maxTags, onChange],
444
+ )
445
+
446
+ const removeTag = (tag: string) => onChange(tags.filter((t) => t !== tag))
447
+
448
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
449
+ if (e.key === 'Enter' || e.key === ',') {
450
+ e.preventDefault()
451
+ addTag(input)
452
+ } else if (e.key === 'Backspace' && input === '' && tags.length > 0) {
453
+ removeTag(tags[tags.length - 1]!)
454
+ }
455
+ }
456
+
457
+ const isDisabled = disabled || field.disabled
458
+
459
+ return (
460
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
461
+ <div
462
+ className={[
463
+ 'flex flex-wrap gap-1.5 rounded-md border border-border bg-background px-2 py-1.5 min-h-[38px] cursor-text',
464
+ 'focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30',
465
+ isDisabled ? 'opacity-50 cursor-not-allowed' : '',
466
+ ].join(' ')}
467
+ onClick={() => !isDisabled && inputRef.current?.focus()}
468
+ >
469
+ {tags.map((tag) => (
470
+ <span
471
+ key={tag}
472
+ className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-1.5 py-0.5 text-xs text-primary"
473
+ >
474
+ {tag}
475
+ {!isDisabled && (
476
+ <button type="button" onClick={() => removeTag(tag)} className="hover:text-danger">
477
+ <X className="h-2.5 w-2.5" />
478
+ </button>
479
+ )}
480
+ </span>
481
+ ))}
482
+ <input
483
+ ref={inputRef}
484
+ type="text"
485
+ className="flex-1 min-w-[80px] bg-transparent text-sm text-foreground outline-none placeholder:text-foreground-disabled"
486
+ value={input}
487
+ placeholder={tags.length === 0 ? (field.placeholder ?? 'Add tags…') : ''}
488
+ disabled={isDisabled}
489
+ list={`tags-suggestions-${field.key}`}
490
+ onChange={(e) => setInput(e.target.value)}
491
+ onKeyDown={handleKeyDown}
492
+ onBlur={() => { if (input.trim()) addTag(input) }}
493
+ />
494
+ {field.suggestions && (
495
+ <datalist id={`tags-suggestions-${field.key}`}>
496
+ {field.suggestions.map((s) => (
497
+ <option key={s} value={s} />
498
+ ))}
499
+ </datalist>
500
+ )}
501
+ </div>
502
+ </FieldWrapper>
503
+ )
504
+ }
505
+
506
+ function ColorField({
507
+ field,
508
+ value,
509
+ onChange,
510
+ disabled,
511
+ translationFn,
512
+ }: {
513
+ field: ConfigColorField
514
+ value: unknown
515
+ onChange: (v: unknown) => void
516
+ disabled?: boolean
517
+ translationFn?: TranslationFn
518
+ }) {
519
+ const strVal = value === undefined || value === null ? '#000000' : String(value)
520
+
521
+ return (
522
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
523
+ <div className="flex items-center gap-2">
524
+ <input
525
+ type="color"
526
+ className="h-9 w-9 rounded-md border border-border bg-background cursor-pointer p-0.5 disabled:opacity-50"
527
+ value={strVal}
528
+ disabled={disabled || field.disabled}
529
+ onChange={(e) => onChange(e.target.value)}
530
+ />
531
+ <input
532
+ type="text"
533
+ className={INPUT_CLASS + ' flex-1 font-mono'}
534
+ value={strVal}
535
+ placeholder="#000000"
536
+ disabled={disabled || field.disabled}
537
+ onChange={(e) => onChange(e.target.value)}
538
+ />
539
+ </div>
540
+ {field.presets && field.presets.length > 0 && (
541
+ <div className="flex gap-1.5 mt-1.5">
542
+ {field.presets.map((preset) => (
543
+ <button
544
+ key={preset}
545
+ type="button"
546
+ className="h-5 w-5 rounded-md border border-border hover:scale-110 transition-transform disabled:opacity-50"
547
+ style={{ backgroundColor: preset }}
548
+ disabled={disabled || field.disabled}
549
+ title={preset}
550
+ onClick={() => onChange(preset)}
551
+ />
552
+ ))}
553
+ </div>
554
+ )}
555
+ </FieldWrapper>
556
+ )
557
+ }
558
+
559
+ function GroupField({
560
+ field,
561
+ values,
562
+ onChange,
563
+ disabled,
564
+ translationFn,
565
+ }: {
566
+ field: ConfigGroupField
567
+ values: Record<string, unknown>
568
+ onChange: (key: string, value: unknown) => void
569
+ disabled?: boolean
570
+ translationFn?: TranslationFn
571
+ }) {
572
+ const [collapsed, setCollapsed] = useState(field.defaultCollapsed ?? false)
573
+ const isAccordion = field.style === 'accordion'
574
+
575
+ const colSpanClass =
576
+ field.span === 2
577
+ ? 'col-span-2'
578
+ : field.span === 3
579
+ ? 'col-span-3'
580
+ : field.span === 4
581
+ ? 'col-span-4'
582
+ : 'col-span-1'
583
+
584
+ return (
585
+ <div className={colSpanClass}>
586
+ <div className="rounded-lg border border-border bg-surface/50 overflow-hidden">
587
+ <div
588
+ className={[
589
+ 'px-3 py-2 flex items-center justify-between',
590
+ isAccordion ? 'cursor-pointer hover:bg-surface' : '',
591
+ ].join(' ')}
592
+ onClick={isAccordion ? () => setCollapsed((c) => !c) : undefined}
593
+ >
594
+ <span className="text-xs font-semibold text-foreground">{resolveLabel(field.label, translationFn)}</span>
595
+ {isAccordion && (
596
+ <span className="text-foreground-subtle text-xs">{collapsed ? '▸' : '▾'}</span>
597
+ )}
598
+ </div>
599
+ {!collapsed && (
600
+ <div className="px-3 pb-3 grid grid-cols-2 gap-x-4 gap-y-3">
601
+ {field.fields.map((subField) => (
602
+ <FormField
603
+ key={subField.key}
604
+ field={subField}
605
+ values={values}
606
+ onChange={onChange}
607
+ disabled={disabled}
608
+ translationFn={translationFn}
609
+ />
610
+ ))}
611
+ </div>
612
+ )}
613
+ </div>
614
+ </div>
615
+ )
616
+ }
617
+
618
+ function ModelSelectorFieldRenderer({
619
+ field,
620
+ value,
621
+ onChange,
622
+ disabled,
623
+ translationFn,
624
+ }: {
625
+ field: ConfigModelSelectorField
626
+ value: unknown
627
+ onChange: (v: unknown) => void
628
+ disabled?: boolean
629
+ translationFn?: TranslationFn
630
+ }) {
631
+ return (
632
+ <FieldWrapper label={field.label} description={field.description} required={field.required} span={field.span} translationFn={translationFn}>
633
+ <ModelSelector field={field} value={value} onChange={onChange} disabled={disabled} />
634
+ </FieldWrapper>
635
+ )
636
+ }
637
+
638
+ function SeparatorField({ span }: { span?: number }) {
639
+ const colSpanClass =
640
+ span === 2
641
+ ? 'col-span-2'
642
+ : span === 3
643
+ ? 'col-span-3'
644
+ : span === 4
645
+ ? 'col-span-4'
646
+ : 'col-span-full'
647
+
648
+ return (
649
+ <div className={colSpanClass}>
650
+ <hr className="border-border" />
651
+ </div>
652
+ )
653
+ }
654
+
655
+ const INFO_VARIANT_STYLES: Record<string, { border: string; bg: string; text: string }> = {
656
+ info: { border: 'border-info', bg: 'bg-info/5', text: 'text-info' },
657
+ warning: { border: 'border-warning', bg: 'bg-warning/5', text: 'text-warning' },
658
+ success: { border: 'border-success', bg: 'bg-success/5', text: 'text-success' },
659
+ danger: { border: 'border-danger', bg: 'bg-danger/5', text: 'text-danger' },
660
+ }
661
+
662
+ function InfoBox({ field }: { field: ConfigInfoField }) {
663
+ const variant = field.variant ?? 'info'
664
+ const styles = INFO_VARIANT_STYLES[variant] ?? INFO_VARIANT_STYLES['info']!
665
+
666
+ return (
667
+ <div className="col-span-full">
668
+ <div className={`rounded-md border-l-4 px-3 py-2.5 ${styles.border} ${styles.bg}`}>
669
+ {field.label && (
670
+ <p className={`text-xs font-semibold mb-0.5 ${styles.text}`}>{field.label}</p>
671
+ )}
672
+ <p className="text-xs text-foreground-subtle leading-relaxed">{field.content}</p>
673
+ </div>
674
+ </div>
675
+ )
676
+ }
677
+
678
+ // ---------------------------------------------------------------------------
679
+ // Main FormField component
680
+ // ---------------------------------------------------------------------------
681
+
682
+ export function FormField({ field, values, onChange, disabled, translationFn }: FormFieldProps) {
683
+ // Evaluate showWhen condition
684
+ if ('showWhen' in field && field.showWhen) {
685
+ const condition = field.showWhen
686
+ const condVal = values[condition.field]
687
+
688
+ let visible = true
689
+ if (condition.equals !== undefined) visible = condVal === condition.equals
690
+ else if (condition.notEquals !== undefined) visible = condVal !== condition.notEquals
691
+ else if (condition.in !== undefined) visible = condition.in.includes(condVal)
692
+ else if (condition.notIn !== undefined) visible = !condition.notIn.includes(condVal)
693
+
694
+ if (!visible) return null
695
+ }
696
+
697
+ const value = values[field.key]
698
+ const handleChange = (v: unknown) => onChange(field.key, v)
699
+
700
+ switch (field.type) {
701
+ case 'text':
702
+ return <TextField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
703
+ case 'number':
704
+ return <NumberField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
705
+ case 'boolean':
706
+ return <BooleanField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
707
+ case 'select':
708
+ return <SelectField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
709
+ case 'multiselect':
710
+ return <MultiSelectField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
711
+ case 'password':
712
+ return <PasswordField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
713
+ case 'textarea':
714
+ return <TextAreaField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
715
+ case 'slider':
716
+ return <SliderField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
717
+ case 'tags':
718
+ return <TagsField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
719
+ case 'color':
720
+ return <ColorField field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
721
+ case 'group':
722
+ return <GroupField field={field} values={values} onChange={onChange} disabled={disabled} translationFn={translationFn} />
723
+ case 'separator':
724
+ return <SeparatorField />
725
+ case 'info':
726
+ return <InfoBox field={field} />
727
+ case 'model-selector':
728
+ return <ModelSelectorFieldRenderer field={field} value={value} onChange={handleChange} disabled={disabled} translationFn={translationFn} />
729
+ default:
730
+ return null
731
+ }
732
+ }