@svadmin/ui 0.45.0 → 0.47.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 (59) hide show
  1. package/dist/components/AboutSettings.svelte +21 -96
  2. package/dist/components/ApiSettings.svelte +54 -234
  3. package/dist/components/AppearanceSettings.svelte +36 -165
  4. package/dist/components/AuthPageShell.svelte +32 -0
  5. package/dist/components/AuthPageShell.svelte.d.ts +11 -0
  6. package/dist/components/AutoForm.svelte +21 -3
  7. package/dist/components/DetailDrawer.svelte +74 -0
  8. package/dist/components/DetailDrawer.svelte.d.ts +16 -0
  9. package/dist/components/FieldRenderer.svelte +70 -2
  10. package/dist/components/FieldRenderer.svelte.d.ts +2 -0
  11. package/dist/components/ForgotPasswordPage.svelte +4 -68
  12. package/dist/components/IntegrationsSettings.svelte +22 -61
  13. package/dist/components/LoginPage.svelte +33 -124
  14. package/dist/components/NotificationsSettings.svelte +22 -103
  15. package/dist/components/PermissionMatrix.svelte +18 -9
  16. package/dist/components/RegisterPage.svelte +7 -62
  17. package/dist/components/RowActions.svelte +154 -0
  18. package/dist/components/RowActions.svelte.d.ts +26 -0
  19. package/dist/components/SecuritySettings.svelte +18 -58
  20. package/dist/components/SettingsPage.svelte +12 -2
  21. package/dist/components/SvadminLogo.svelte +1 -1
  22. package/dist/components/UpdatePasswordPage.svelte +3 -55
  23. package/dist/components/account/CompanyProfilePage.svelte +19 -7
  24. package/dist/components/account/SettingsEnterprisePage.svelte +25 -4
  25. package/dist/components/account/SettingsPlainPage.svelte +25 -3
  26. package/dist/components/account/SettingsSidebarPage.svelte +31 -2
  27. package/dist/components/account/UserProfilePage.svelte +5 -4
  28. package/dist/components/content/ApiKeyList.svelte +40 -6
  29. package/dist/components/content/ApiKeyList.svelte.d.ts +10 -0
  30. package/dist/components/content/DataState.svelte +25 -9
  31. package/dist/components/content/DataState.svelte.d.ts +2 -0
  32. package/dist/components/content/FileList.svelte +12 -4
  33. package/dist/components/content/FileList.svelte.d.ts +9 -0
  34. package/dist/components/content/FilterToolbar.svelte +10 -2
  35. package/dist/components/content/FilterToolbar.svelte.d.ts +1 -0
  36. package/dist/components/content/MemberList.svelte +13 -5
  37. package/dist/components/content/MemberList.svelte.d.ts +9 -0
  38. package/dist/components/content/MetricBlock.svelte +19 -2
  39. package/dist/components/content/MetricBlock.svelte.d.ts +2 -0
  40. package/dist/components/content/NetworkTable.svelte +9 -3
  41. package/dist/components/content/NetworkTable.svelte.d.ts +9 -0
  42. package/dist/components/content/NetworkUserCard.svelte +47 -7
  43. package/dist/components/content/NetworkUserCard.svelte.d.ts +13 -0
  44. package/dist/components/content/SectionHeader.svelte +3 -2
  45. package/dist/components/content/SectionHeader.svelte.d.ts +1 -0
  46. package/dist/components/content/SecurityEventTable.svelte +14 -5
  47. package/dist/components/content/SecurityEventTable.svelte.d.ts +9 -0
  48. package/dist/components/content/SettingsFieldRow.svelte +28 -0
  49. package/dist/components/content/SettingsFieldRow.svelte.d.ts +11 -0
  50. package/dist/components/content/SettingsGroup.svelte +33 -0
  51. package/dist/components/content/SettingsGroup.svelte.d.ts +12 -0
  52. package/dist/components/content/WorkspaceLayout.svelte +31 -0
  53. package/dist/components/content/WorkspaceLayout.svelte.d.ts +12 -0
  54. package/dist/components/content/index.d.ts +4 -1
  55. package/dist/components/content/index.js +3 -0
  56. package/dist/components/network/UserCardsNFTPage.svelte +25 -6
  57. package/dist/index.d.ts +9 -1
  58. package/dist/index.js +6 -0
  59. package/package.json +4 -1
@@ -23,11 +23,13 @@
23
23
 
24
24
  const i18n = useTranslation();
25
25
 
26
- let { field, value, onchange, disabled, children } = $props<{
26
+ let { field, value, onchange, disabled, invalid = false, errorId, children } = $props<{
27
27
  field: FieldDefinition;
28
28
  value: unknown;
29
29
  onchange: (val: unknown) => void;
30
30
  disabled?: boolean;
31
+ invalid?: boolean;
32
+ errorId?: string;
31
33
  children?: Snippet;
32
34
  }>();
33
35
 
@@ -87,7 +89,7 @@
87
89
  }
88
90
  </script>
89
91
 
90
- <div class="space-y-1.5" data-svadmin-field>
92
+ <div class="space-y-1.5" data-svadmin-field data-svadmin-field-key={field.key}>
91
93
  <Label for={field.key} id="label-{field.key}" data-svadmin-field-label>
92
94
  {field.label}
93
95
  {#if field.required}
@@ -99,15 +101,19 @@
99
101
  {@render children()}
100
102
 
101
103
  {:else if field.type === 'array'}
104
+ <input type="hidden" name={field.key} value={JSON.stringify(Array.isArray(value) ? value : [])} />
102
105
  <ArrayField {field} {value} {onchange} />
103
106
 
104
107
  {:else if field.type === 'text' || field.type === 'image'}
105
108
  <Input
106
109
  id={field.key}
110
+ name={field.key}
107
111
  type="text"
108
112
  value={strVal}
109
113
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
110
114
  required={field.required}
115
+ aria-invalid={invalid || undefined}
116
+ aria-describedby={errorId}
111
117
  {disabled}
112
118
  placeholder={i18n.t('field.enterValue', { label: field.label })}
113
119
  />
@@ -115,10 +121,13 @@
115
121
  {:else if field.type === 'email'}
116
122
  <Input
117
123
  id={field.key}
124
+ name={field.key}
118
125
  type="email"
119
126
  value={strVal}
120
127
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
121
128
  required={field.required}
129
+ aria-invalid={invalid || undefined}
130
+ aria-describedby={errorId}
122
131
  {disabled}
123
132
  placeholder="name@example.com"
124
133
  />
@@ -126,10 +135,13 @@
126
135
  {:else if field.type === 'url'}
127
136
  <Input
128
137
  id={field.key}
138
+ name={field.key}
129
139
  type="url"
130
140
  value={strVal}
131
141
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
132
142
  required={field.required}
143
+ aria-invalid={invalid || undefined}
144
+ aria-describedby={errorId}
133
145
  {disabled}
134
146
  placeholder="https://"
135
147
  />
@@ -137,10 +149,13 @@
137
149
  {:else if field.type === 'phone'}
138
150
  <Input
139
151
  id={field.key}
152
+ name={field.key}
140
153
  type="tel"
141
154
  value={strVal}
142
155
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
143
156
  required={field.required}
157
+ aria-invalid={invalid || undefined}
158
+ aria-describedby={errorId}
144
159
  {disabled}
145
160
  placeholder="+1 (555) 000-0000"
146
161
  />
@@ -149,11 +164,14 @@
149
164
  <div class="flex items-center gap-3">
150
165
  <input
151
166
  id={field.key}
167
+ name={field.key}
152
168
  type="color"
153
169
  value={strVal || '#000000'}
154
170
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
155
171
  class="h-10 w-14 cursor-pointer rounded-md border border-input bg-background p-1"
156
172
  disabled={disabled}
173
+ aria-invalid={invalid || undefined}
174
+ aria-describedby={errorId}
157
175
  />
158
176
  <Input
159
177
  type="text"
@@ -174,6 +192,7 @@
174
192
  {:else if field.type === 'number'}
175
193
  <Input
176
194
  id={field.key}
195
+ name={field.key}
177
196
  type="number"
178
197
  value={numVal == null ? '' : String(numVal)}
179
198
  oninput={(e) => {
@@ -181,26 +200,34 @@
181
200
  onchange(v === '' ? null : Number(v));
182
201
  }}
183
202
  required={field.required}
203
+ aria-invalid={invalid || undefined}
204
+ aria-describedby={errorId}
184
205
  {disabled}
185
206
  />
186
207
 
187
208
  {:else if field.type === 'richtext'}
188
209
  {@const EditorComp = getRichTextEditor()}
189
210
  {#if EditorComp}
211
+ <input type="hidden" name={field.key} value={strVal} />
190
212
  <EditorComp
191
213
  id={field.key}
192
214
  value={strVal}
193
215
  placeholder={i18n.t('field.enterValue', { label: field.label })}
194
216
  preset="full"
195
217
  onchange={(html: string) => onchange(html)}
218
+ aria-invalid={invalid || undefined}
219
+ aria-describedby={errorId}
196
220
  {disabled}
197
221
  />
198
222
  {:else}
199
223
  <Textarea
200
224
  id={field.key}
225
+ name={field.key}
201
226
  value={strVal}
202
227
  oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
203
228
  required={field.required}
229
+ aria-invalid={invalid || undefined}
230
+ aria-describedby={errorId}
204
231
  {disabled}
205
232
  rows={10}
206
233
  placeholder={i18n.t('field.enterValue', { label: field.label })}
@@ -211,9 +238,12 @@
211
238
  {:else if field.type === 'textarea'}
212
239
  <Textarea
213
240
  id={field.key}
241
+ name={field.key}
214
242
  value={strVal}
215
243
  oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
216
244
  required={field.required}
245
+ aria-invalid={invalid || undefined}
246
+ aria-describedby={errorId}
217
247
  {disabled}
218
248
  rows={4}
219
249
  placeholder={i18n.t('field.enterValue', { label: field.label })}
@@ -221,6 +251,7 @@
221
251
  />
222
252
 
223
253
  {:else if field.type === 'relation' && field.resource}
254
+ <input type="hidden" name={field.key} value={value == null ? '' : String(value)} />
224
255
  <ComboboxField
225
256
  id={field.key}
226
257
  resource={field.resource}
@@ -229,6 +260,8 @@
229
260
  optionLabel={field.optionLabel ?? 'title'}
230
261
  optionValue={field.optionValue ?? 'id'}
231
262
  placeholder={i18n.t('field.selectPlaceholder')}
263
+ aria-invalid={invalid || undefined}
264
+ aria-describedby={errorId}
232
265
  {disabled}
233
266
  />
234
267
 
@@ -236,10 +269,13 @@
236
269
  {#if (field.options?.length ?? 0) > 8}
237
270
  <select
238
271
  id={field.key}
272
+ name={field.key}
239
273
  class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
240
274
  value={strVal}
241
275
  onchange={(e) => onchange((e.target as HTMLSelectElement).value)}
242
276
  required={field.required}
277
+ aria-invalid={invalid || undefined}
278
+ aria-describedby={errorId}
243
279
  disabled={disabled}
244
280
  >
245
281
  <option value="">{i18n.t('field.selectPlaceholder')}</option>
@@ -250,9 +286,12 @@
250
286
  {:else}
251
287
  <Select
252
288
  id={field.key}
289
+ name={field.key}
253
290
  value={strVal}
254
291
  onchange={(e) => onchange((e.target as HTMLSelectElement).value)}
255
292
  required={field.required}
293
+ aria-invalid={invalid || undefined}
294
+ aria-describedby={errorId}
256
295
  {disabled}
257
296
  placeholder={i18n.t('field.selectPlaceholder')}
258
297
  >
@@ -263,10 +302,15 @@
263
302
  {/if}
264
303
 
265
304
  {:else if field.type === 'multiselect'}
305
+ {#each multiVal as selectedValue, selectedIndex (`${selectedValue}-${selectedIndex}`)}
306
+ <input type="hidden" name={`${field.key}[]`} value={String(selectedValue)} />
307
+ {/each}
266
308
  <div
267
309
  class="space-y-2 rounded-lg border border-input p-3 max-h-48 overflow-y-auto"
268
310
  role="group"
269
311
  aria-labelledby="label-{field.key}"
312
+ aria-describedby={errorId}
313
+ data-invalid={invalid || undefined}
270
314
  >
271
315
  {#each field.options ?? [] as opt, _i (_i)}
272
316
  <label class="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5 transition-colors">
@@ -274,6 +318,8 @@
274
318
  id={`${field.key}-${opt.value}`}
275
319
  checked={multiVal.includes(opt.value)}
276
320
  onCheckedChange={() => toggleMulti(opt.value)}
321
+ aria-invalid={invalid || undefined}
322
+ aria-describedby={errorId}
277
323
  disabled={disabled}
278
324
  />
279
325
  {opt.label}
@@ -296,17 +342,23 @@
296
342
  {/if}
297
343
 
298
344
  {:else if field.type === 'boolean'}
345
+ <input type="hidden" name={field.key} value={String(boolVal)} />
299
346
  <div class="flex items-center gap-2 pt-1">
300
347
  <Switch
301
348
  id={field.key}
302
349
  checked={boolVal}
303
350
  onCheckedChange={(v) => onchange(v)}
351
+ aria-invalid={invalid || undefined}
352
+ aria-describedby={errorId}
304
353
  disabled={disabled}
305
354
  />
306
355
  <span class="text-sm text-muted-foreground">{boolVal ? i18n.t('common.yes') : i18n.t('common.no')}</span>
307
356
  </div>
308
357
 
309
358
  {:else if field.type === 'tags'}
359
+ {#each tagsVal as tag, tagIndex (`${tag}-${tagIndex}`)}
360
+ <input type="hidden" name={`${field.key}[]`} value={tag} />
361
+ {/each}
310
362
  <div class="space-y-2">
311
363
  <div class="flex flex-wrap gap-1.5">
312
364
  {#each tagsVal as tag, i (i)}
@@ -326,6 +378,8 @@
326
378
  type="text"
327
379
  placeholder={i18n.t('field.tagsPlaceholder')}
328
380
  onkeydown={handleTagKeydown}
381
+ aria-invalid={invalid || undefined}
382
+ aria-describedby={errorId}
329
383
  {disabled}
330
384
  />
331
385
  </div>
@@ -333,14 +387,20 @@
333
387
  {:else if field.type === 'date'}
334
388
  <Input
335
389
  id={field.key}
390
+ name={field.key}
336
391
  type="date"
337
392
  value={strVal}
338
393
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
339
394
  required={field.required}
395
+ aria-invalid={invalid || undefined}
396
+ aria-describedby={errorId}
340
397
  {disabled}
341
398
  />
342
399
 
343
400
  {:else if field.type === 'images'}
401
+ {#each imagesVal as url, imageIndex (imageIndex)}
402
+ <input type="hidden" name={`${field.key}[]`} value={url} />
403
+ {/each}
344
404
  <div class="space-y-2">
345
405
  {#each imagesVal as url, i (i)}
346
406
  <div class="flex items-center gap-2">
@@ -351,6 +411,8 @@
351
411
  oninput={(e) => updateImage(i, (e.target as HTMLInputElement).value)}
352
412
  placeholder="https://example.com/image.jpg"
353
413
  class="flex-1"
414
+ aria-invalid={invalid || undefined}
415
+ aria-describedby={errorId}
354
416
  {disabled}
355
417
  />
356
418
  {#if url}
@@ -369,6 +431,7 @@
369
431
  {:else if field.type === 'json'}
370
432
  <Textarea
371
433
  id={field.key}
434
+ name={field.key}
372
435
  value={jsonEditText}
373
436
  onfocus={() => { jsonEditing = true; }}
374
437
  onblur={() => {
@@ -385,6 +448,8 @@
385
448
  }
386
449
  }}
387
450
  {disabled}
451
+ aria-invalid={invalid || undefined}
452
+ aria-describedby={errorId}
388
453
  rows={6}
389
454
  class="resize-y font-mono text-xs"
390
455
  />
@@ -392,9 +457,12 @@
392
457
  {:else}
393
458
  <Input
394
459
  id={field.key}
460
+ name={field.key}
395
461
  type="text"
396
462
  value={strVal}
397
463
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
464
+ aria-invalid={invalid || undefined}
465
+ aria-describedby={errorId}
398
466
  {disabled}
399
467
  />
400
468
  {/if}
@@ -6,6 +6,8 @@ type $$ComponentProps = {
6
6
  value: unknown;
7
7
  onchange: (val: unknown) => void;
8
8
  disabled?: boolean;
9
+ invalid?: boolean;
10
+ errorId?: string;
9
11
  children?: Snippet;
10
12
  };
11
13
  declare const FieldRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -6,7 +6,7 @@
6
6
  import { Input } from './ui/input/index.js';
7
7
  import { Label } from './ui/label/index.js';
8
8
  import * as Alert from './ui/alert/index.js';
9
- import SvadminLogo from './SvadminLogo.svelte';
9
+ import AuthPageShell from './AuthPageShell.svelte';
10
10
  import { User, ArrowLeft, Loader2, AlertCircle } from '@lucide/svelte';
11
11
 
12
12
  const i18n = useTranslation();
@@ -37,65 +37,7 @@
37
37
  }
38
38
  </script>
39
39
 
40
- <div class="min-h-screen flex flex-col lg:flex-row">
41
- <!-- Left brand panel -->
42
- <div class="hidden lg:flex lg:w-[480px] xl:w-[520px] relative overflow-hidden bg-gradient-to-br from-primary/90 via-primary to-primary/80 shrink-0">
43
- <div class="absolute inset-0">
44
- <div class="absolute top-[12%] left-[8%] w-28 h-28 rounded-3xl border border-white/10 bg-white/5 rotate-12 backdrop-blur-sm"></div>
45
- <div class="absolute top-[55%] left-[55%] w-40 h-40 rounded-full border border-white/10 bg-white/5 -rotate-6 backdrop-blur-sm"></div>
46
- <div class="absolute bottom-[15%] left-[15%] w-24 h-24 rounded-2xl border border-white/10 bg-white/5 -rotate-12 backdrop-blur-sm"></div>
47
- <div class="absolute top-[30%] right-[10%] w-16 h-16 rounded-xl border border-white/10 bg-white/5 rotate-45 backdrop-blur-sm"></div>
48
- <div class="absolute inset-0 opacity-[0.03]" style="background-image: radial-gradient(circle, white 1px, transparent 1px); background-size: 24px 24px;"></div>
49
- </div>
50
-
51
- <div class="relative z-10 flex flex-col justify-between p-10 xl:p-12 w-full">
52
- <div class="flex items-center gap-3">
53
- <SvadminLogo size={36} onDark />
54
- <span class="text-lg font-semibold text-white tracking-tight">{title}</span>
55
- </div>
56
-
57
- <div class="space-y-4">
58
- <h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
59
- {i18n.t('auth.forgotPassword')}
60
- </h2>
61
- <p class="text-base text-white/70 leading-relaxed max-w-[320px]">
62
- {i18n.t('auth.forgotPasswordDescription')}
63
- </p>
64
- </div>
65
-
66
- <div class="flex items-center gap-3">
67
- <div class="h-1 w-8 rounded-full bg-white/30"></div>
68
- <div class="h-1 w-4 rounded-full bg-white/15"></div>
69
- <div class="h-1 w-2 rounded-full bg-white/10"></div>
70
- </div>
71
- </div>
72
- </div>
73
-
74
- <!-- Right form panel -->
75
- <div class="flex-1 flex items-center justify-center bg-background p-6 sm:p-8 lg:p-12">
76
- <div class="w-full max-w-[400px]">
77
- <div class="mb-8 flex items-center gap-3 lg:hidden">
78
- <SvadminLogo size={36} />
79
- <span class="text-lg font-semibold tracking-tight">{title}</span>
80
- </div>
81
-
82
- <div class="space-y-1 mb-8">
83
- <h1 class="text-2xl font-semibold tracking-tight">
84
- {#if sent}
85
- {i18n.t('auth.resetLinkSentTitle')}
86
- {:else}
87
- {i18n.t('auth.forgotPassword')}
88
- {/if}
89
- </h1>
90
- <p class="text-sm text-muted-foreground">
91
- {#if sent}
92
- {i18n.t('auth.resetLinkSentDescription')}
93
- {:else}
94
- {i18n.t('auth.forgotPasswordDescription')}
95
- {/if}
96
- </p>
97
- </div>
98
-
40
+ <AuthPageShell brand={title} title={sent ? i18n.t('auth.resetLinkSentTitle') : i18n.t('auth.forgotPassword')} description={sent ? i18n.t('auth.resetLinkSentDescription') : i18n.t('auth.forgotPasswordDescription')}>
99
41
  {#if sent}
100
42
  <div class="space-y-4">
101
43
  <Button variant="outline" class="w-full" onclick={() => adminContext.navigate('/login')}>
@@ -135,17 +77,11 @@
135
77
  </Button>
136
78
  </form>
137
79
 
138
- <div class="flex items-center justify-center mt-8 pt-6 border-t">
80
+ <div class="mt-6 flex items-center justify-center border-t pt-5">
139
81
  <Button variant="link" class="text-sm h-auto p-0 font-medium inline-flex items-center gap-1" onclick={() => adminContext.navigate('/login')}>
140
82
  <ArrowLeft class="h-3 w-3" />
141
83
  {i18n.t('auth.backToLogin')}
142
84
  </Button>
143
85
  </div>
144
86
  {/if}
145
-
146
- <p class="text-xs text-muted-foreground/60 mt-8 text-center">
147
- Powered by {title}
148
- </p>
149
- </div>
150
- </div>
151
- </div>
87
+ </AuthPageShell>
@@ -1,73 +1,34 @@
1
1
  <script lang="ts">
2
2
  import { useTranslation } from '@svadmin/core/i18n';
3
-
4
- import { AlertCircle, Cloud, FolderGit, MessageCircle, Plug } from '@lucide/svelte';
3
+ import { Cloud, FolderGit, MessageCircle, Plug } from '@lucide/svelte';
5
4
  import type { Component } from 'svelte';
6
- import * as Card from './ui/card/index.js';
7
5
  import { Switch } from './ui/switch/index.js';
6
+ import SettingsGroup from './content/SettingsGroup.svelte';
7
+ import SettingsFieldRow from './content/SettingsFieldRow.svelte';
8
+ import StatusBadge from './content/StatusBadge.svelte';
8
9
 
9
10
  const i18n = useTranslation();
10
-
11
- interface Integration {
12
- id: string;
13
- name: string;
14
- description: string;
15
- connected: boolean;
16
- Icon: Component;
17
- tone: string;
18
- }
19
-
11
+ interface Integration { id: string; name: string; description: string; connected: boolean; Icon: Component; }
20
12
  let integrations = $state<Integration[]>([
21
- { id: 'source-control', name: 'Source Control', description: 'Sync repository metadata, deployments, and commit activity.', connected: true, Icon: FolderGit, tone: 'bg-zinc-900 text-white' },
22
- { id: 'chatops', name: 'ChatOps', description: 'Send operational alerts and audit summaries to workspace channels.', connected: false, Icon: MessageCircle, tone: 'bg-violet-700 text-white' },
23
- { id: 'identity-cloud', name: 'Identity Cloud', description: 'Configure SSO and workspace identity handoff.', connected: true, Icon: Cloud, tone: 'bg-blue-600 text-white' },
24
- { id: 'webhooks', name: 'Webhooks', description: 'Register outbound webhooks for internal tools and monitors.', connected: false, Icon: Plug, tone: 'bg-emerald-700 text-white' },
13
+ { id: 'source-control', name: 'Source Control', description: 'Sync repository metadata, deployments, and commit activity.', connected: true, Icon: FolderGit },
14
+ { id: 'chatops', name: 'ChatOps', description: 'Send operational alerts and audit summaries to workspace channels.', connected: false, Icon: MessageCircle },
15
+ { id: 'identity-cloud', name: 'Identity Cloud', description: 'Configure SSO and workspace identity handoff.', connected: true, Icon: Cloud },
16
+ { id: 'webhooks', name: 'Webhooks', description: 'Register outbound webhooks for internal tools and monitors.', connected: false, Icon: Plug },
25
17
  ]);
26
-
27
- function toggleConnection(id: string, connected: boolean) {
28
- integrations = integrations.map((item) => (item.id === id ? { ...item, connected } : item));
29
- }
18
+ function toggleConnection(id: string, connected: boolean) { integrations = integrations.map((item) => item.id === id ? { ...item, connected } : item); }
30
19
  </script>
31
20
 
32
21
  <div class="space-y-6">
33
- <div>
34
- <h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.integrations')}</h2>
35
- <p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.integrationsDescription')}</p>
36
- </div>
37
-
38
- <div class="grid gap-4">
39
- {#each integrations as integration (integration.id)}
40
- <Card.Card>
41
- <Card.CardContent class="p-5">
42
- <div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
43
- <div class="flex items-start gap-4">
44
- <div class="shrink-0 rounded-lg p-3 {integration.tone}">
45
- <integration.Icon class="h-5 w-5" />
46
- </div>
47
- <div class="space-y-1">
48
- <div class="flex flex-wrap items-center gap-2">
49
- <span class="font-semibold text-foreground">{integration.name}</span>
50
- <span class="rounded-full px-2 py-0.5 text-[10px] font-semibold {integration.connected ? 'bg-green-500/10 text-green-600' : 'bg-muted text-muted-foreground'}">
51
- {integration.connected ? i18n.t('integrations.statusConnected') : i18n.t('integrations.statusDisconnected')}
52
- </span>
53
- </div>
54
- <p class="text-sm text-muted-foreground">{integration.description}</p>
55
- </div>
56
- </div>
57
- <div class="flex items-center gap-3">
58
- <span class="text-xs text-muted-foreground">{integration.connected ? i18n.t('integrations.enabled') : i18n.t('integrations.disabled')}</span>
59
- <Switch checked={integration.connected} onCheckedChange={(checked) => toggleConnection(integration.id, checked)} />
60
- </div>
61
- </div>
62
- </Card.CardContent>
63
- </Card.Card>
64
- {/each}
65
- </div>
66
-
67
- <Card.Card class="border-dashed bg-muted/20">
68
- <Card.CardContent class="flex items-center gap-3 p-5 text-sm text-muted-foreground">
69
- <AlertCircle class="h-4 w-4 shrink-0" />
70
- <span>{i18n.t('integrations.addMoreHint')}</span>
71
- </Card.CardContent>
72
- </Card.Card>
22
+ <div><h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.integrations')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.integrationsDescription')}</p></div>
23
+ <SettingsGroup title={i18n.t('settings.integrations')} description={i18n.t('integrations.addMoreHint')}>
24
+ <div class="divide-y divide-border">
25
+ {#each integrations as integration (integration.id)}
26
+ <SettingsFieldRow label={integration.name} description={integration.description}>
27
+ {#snippet control()}
28
+ <div class="flex items-center gap-3"><StatusBadge status={integration.connected ? 'success' : 'neutral'} label={integration.connected ? i18n.t('integrations.statusConnected') : i18n.t('integrations.statusDisconnected')} /><Switch checked={integration.connected} onCheckedChange={(checked) => toggleConnection(integration.id, checked)} aria-label={integration.name} /></div>
29
+ {/snippet}
30
+ </SettingsFieldRow>
31
+ {/each}
32
+ </div>
33
+ </SettingsGroup>
73
34
  </div>