@svadmin/ui 0.46.0 → 0.48.0
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.
- package/dist/components/AboutSettings.svelte +21 -96
- package/dist/components/AdminApp.svelte +20 -0
- package/dist/components/AdminApp.svelte.d.ts +5 -1
- package/dist/components/ApiSettings.svelte +153 -246
- package/dist/components/AppearanceSettings.svelte +36 -165
- package/dist/components/AutoForm.svelte +21 -3
- package/dist/components/DetailDrawer.svelte +74 -0
- package/dist/components/DetailDrawer.svelte.d.ts +16 -0
- package/dist/components/FieldRenderer.svelte +70 -2
- package/dist/components/FieldRenderer.svelte.d.ts +2 -0
- package/dist/components/IntegrationsSettings.svelte +22 -61
- package/dist/components/Layout.svelte +15 -1
- package/dist/components/NotificationsSettings.svelte +22 -103
- package/dist/components/PasswordInput.svelte +4 -0
- package/dist/components/PasswordInput.svelte.d.ts +1 -0
- package/dist/components/PermissionMatrix.svelte +17 -8
- package/dist/components/RowActions.svelte +154 -0
- package/dist/components/RowActions.svelte.d.ts +26 -0
- package/dist/components/SecuritySettings.svelte +165 -140
- package/dist/components/SecuritySettings.svelte.d.ts +1 -2
- package/dist/components/SettingsPage.svelte +12 -2
- package/dist/components/account/CompanyProfilePage.svelte +10 -4
- package/dist/components/account/SettingsEnterprisePage.svelte +227 -23
- package/dist/components/account/SettingsSidebarPage.svelte +12 -1
- package/dist/components/account/UserProfilePage.svelte +5 -4
- package/dist/components/content/ApiKeyList.svelte +30 -5
- package/dist/components/content/ApiKeyList.svelte.d.ts +3 -0
- package/dist/components/content/WorkspaceLayout.svelte +7 -4
- package/dist/components/content/WorkspaceLayout.svelte.d.ts +1 -0
- package/dist/components/ui/sheet/sheet.svelte +122 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +5 -2
|
@@ -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}
|
|
@@ -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
|
|
22
|
-
{ id: 'chatops', name: 'ChatOps', description: 'Send operational alerts and audit summaries to workspace channels.', connected: false, Icon: MessageCircle
|
|
23
|
-
{ id: 'identity-cloud', name: 'Identity Cloud', description: 'Configure SSO and workspace identity handoff.', connected: true, Icon: Cloud
|
|
24
|
-
{ id: 'webhooks', name: 'Webhooks', description: 'Register outbound webhooks for internal tools and monitors.', connected: false, Icon: Plug
|
|
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
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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>
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
let { children, title = 'Admin', menu, siteUrl, routeMode = 'auto' }: { children: Snippet; title?: string; menu?: MenuItem[]; siteUrl?: string; routeMode?: 'hash' | 'path' | 'auto' } = $props();
|
|
29
29
|
const layoutId = $props.id();
|
|
30
30
|
const layoutScope = `svadmin-layout-${layoutId}`;
|
|
31
|
+
const mainContentId = `${layoutScope}-main`;
|
|
31
32
|
const chatScope = `${layoutScope}-chat`;
|
|
32
33
|
const adminContext = captureAdminContext();
|
|
33
34
|
|
|
@@ -68,6 +69,10 @@
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function focusMainContent() {
|
|
73
|
+
document.getElementById(mainContentId)?.focus();
|
|
74
|
+
}
|
|
75
|
+
|
|
71
76
|
let collapsed = $state(false);
|
|
72
77
|
|
|
73
78
|
// Swipe gesture for mobile menu
|
|
@@ -150,6 +155,15 @@
|
|
|
150
155
|
</div>
|
|
151
156
|
{:else}
|
|
152
157
|
<div data-svadmin-layout-scope={layoutScope} class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
data-svadmin-skip-link={mainContentId}
|
|
161
|
+
class="absolute left-3 top-3 z-[100] -translate-y-16 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground shadow-sm transition-transform focus:translate-y-0 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
|
162
|
+
onclick={focusMainContent}
|
|
163
|
+
>
|
|
164
|
+
{i18n.t('common.skipToMainContent')}
|
|
165
|
+
</button>
|
|
166
|
+
|
|
153
167
|
<!-- Desktop sidebar -->
|
|
154
168
|
<div class="hidden md:block">
|
|
155
169
|
<Sidebar {collapsed} {identity} {title} {menu} {routeMode} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
|
|
@@ -201,7 +215,7 @@
|
|
|
201
215
|
|
|
202
216
|
<!-- Content area: responsive padding + centered max-width container
|
|
203
217
|
so wide screens don't stretch content indefinitely (avoids sparse layouts) -->
|
|
204
|
-
<main data-svadmin-main class="flex-1 overflow-y-auto bg-muted/30 px-4 py-5 sm:px-5 md:px-7.5 md:py-7">
|
|
218
|
+
<main id={mainContentId} tabindex="-1" data-svadmin-main class="flex-1 overflow-y-auto bg-muted/30 px-4 py-5 sm:px-5 md:px-7.5 md:py-7">
|
|
205
219
|
<div class="mx-auto w-full max-w-[1600px]">
|
|
206
220
|
{#key getPath()}
|
|
207
221
|
<div in:fly={{ x: 20, duration: 150 }} out:fade={{ duration: 80 }}>
|
|
@@ -1,117 +1,36 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { useNotification } from '@svadmin/core';
|
|
2
3
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
-
import { Bell, Mail, Smartphone } from '@lucide/svelte';
|
|
4
|
-
import * as Card from './ui/card/index.js';
|
|
5
4
|
import { Button } from './ui/button/index.js';
|
|
6
|
-
import { Label } from './ui/label/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
8
|
|
|
9
9
|
const i18n = useTranslation();
|
|
10
|
-
|
|
10
|
+
const notification = useNotification();
|
|
11
11
|
let emailAlerts = $state({ security: true, activity: false, reports: true });
|
|
12
12
|
let pushAlerts = $state({ security: true, activity: true, reports: false });
|
|
13
13
|
let smsAlerts = $state({ security: true });
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function saveSettings() {
|
|
17
|
-
isSaved = true;
|
|
18
|
-
setTimeout(() => {
|
|
19
|
-
isSaved = false;
|
|
20
|
-
}, 3000);
|
|
21
|
-
}
|
|
14
|
+
function saveSettings() { notification.success(i18n.t('common.autoSaved'), 3000); }
|
|
22
15
|
</script>
|
|
23
16
|
|
|
24
17
|
<div class="space-y-6">
|
|
25
|
-
<div class="flex flex-col justify-between gap-3 sm:flex-row sm:items-start">
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
18
|
+
<div class="flex flex-col justify-between gap-3 sm:flex-row sm:items-start"><div><h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.notifications')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.notificationsDescription')}</p></div><Button onclick={saveSettings}>{i18n.t('common.save')}</Button></div>
|
|
19
|
+
<SettingsGroup title={i18n.t('notifications.emailTitle')} description={i18n.t('notifications.emailDescription')}>
|
|
20
|
+
<div class="divide-y divide-border">
|
|
21
|
+
<SettingsFieldRow label={i18n.t('notifications.securityAlerts')} description={i18n.t('notifications.securityAlertsDesc')}>{#snippet control()}<Switch id="email-security" bind:checked={emailAlerts.security} />{/snippet}</SettingsFieldRow>
|
|
22
|
+
<SettingsFieldRow label={i18n.t('notifications.activityLogs')} description={i18n.t('notifications.activityLogsDesc')}>{#snippet control()}<Switch id="email-activity" bind:checked={emailAlerts.activity} />{/snippet}</SettingsFieldRow>
|
|
23
|
+
<SettingsFieldRow label={i18n.t('notifications.systemAlerts')} description={i18n.t('notifications.systemAlertsDesc')}>{#snippet control()}<Switch id="email-reports" bind:checked={emailAlerts.reports} />{/snippet}</SettingsFieldRow>
|
|
29
24
|
</div>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<Card.CardContent class="space-y-4">
|
|
42
|
-
<div class="flex items-center justify-between gap-4 border-b py-2">
|
|
43
|
-
<div class="space-y-1">
|
|
44
|
-
<Label for="email-security">{i18n.t('notifications.securityAlerts')}</Label>
|
|
45
|
-
<p class="text-xs text-muted-foreground">{i18n.t('notifications.securityAlertsDesc')}</p>
|
|
46
|
-
</div>
|
|
47
|
-
<Switch id="email-security" bind:checked={emailAlerts.security} />
|
|
48
|
-
</div>
|
|
49
|
-
<div class="flex items-center justify-between gap-4 border-b py-2">
|
|
50
|
-
<div class="space-y-1">
|
|
51
|
-
<Label for="email-activity">{i18n.t('notifications.activityLogs')}</Label>
|
|
52
|
-
<p class="text-xs text-muted-foreground">{i18n.t('notifications.activityLogsDesc')}</p>
|
|
53
|
-
</div>
|
|
54
|
-
<Switch id="email-activity" bind:checked={emailAlerts.activity} />
|
|
55
|
-
</div>
|
|
56
|
-
<div class="flex items-center justify-between gap-4 py-2">
|
|
57
|
-
<div class="space-y-1">
|
|
58
|
-
<Label for="email-reports">{i18n.t('notifications.systemAlerts')}</Label>
|
|
59
|
-
<p class="text-xs text-muted-foreground">{i18n.t('notifications.systemAlertsDesc')}</p>
|
|
60
|
-
</div>
|
|
61
|
-
<Switch id="email-reports" bind:checked={emailAlerts.reports} />
|
|
62
|
-
</div>
|
|
63
|
-
</Card.CardContent>
|
|
64
|
-
</Card.Card>
|
|
65
|
-
|
|
66
|
-
<Card.Card>
|
|
67
|
-
<Card.CardHeader>
|
|
68
|
-
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
69
|
-
<Bell class="h-4 w-4 text-muted-foreground" />
|
|
70
|
-
{i18n.t('notifications.pushTitle')}
|
|
71
|
-
</Card.CardTitle>
|
|
72
|
-
<Card.CardDescription>{i18n.t('notifications.pushDescription')}</Card.CardDescription>
|
|
73
|
-
</Card.CardHeader>
|
|
74
|
-
<Card.CardContent class="space-y-4">
|
|
75
|
-
<div class="flex items-center justify-between gap-4 border-b py-2">
|
|
76
|
-
<div class="space-y-1">
|
|
77
|
-
<Label for="push-security">{i18n.t('notifications.securityAlerts')}</Label>
|
|
78
|
-
<p class="text-xs text-muted-foreground">{i18n.t('notifications.securityPushDesc')}</p>
|
|
79
|
-
</div>
|
|
80
|
-
<Switch id="push-security" bind:checked={pushAlerts.security} />
|
|
81
|
-
</div>
|
|
82
|
-
<div class="flex items-center justify-between gap-4 border-b py-2">
|
|
83
|
-
<div class="space-y-1">
|
|
84
|
-
<Label for="push-activity">{i18n.t('notifications.activityLogs')}</Label>
|
|
85
|
-
<p class="text-xs text-muted-foreground">{i18n.t('notifications.activityPushDesc')}</p>
|
|
86
|
-
</div>
|
|
87
|
-
<Switch id="push-activity" bind:checked={pushAlerts.activity} />
|
|
88
|
-
</div>
|
|
89
|
-
<div class="flex items-center justify-between gap-4 py-2">
|
|
90
|
-
<div class="space-y-1">
|
|
91
|
-
<Label for="push-reports">{i18n.t('notifications.systemAlerts')}</Label>
|
|
92
|
-
<p class="text-xs text-muted-foreground">{i18n.t('notifications.systemPushDesc')}</p>
|
|
93
|
-
</div>
|
|
94
|
-
<Switch id="push-reports" bind:checked={pushAlerts.reports} />
|
|
95
|
-
</div>
|
|
96
|
-
</Card.CardContent>
|
|
97
|
-
</Card.Card>
|
|
98
|
-
|
|
99
|
-
<Card.Card>
|
|
100
|
-
<Card.CardHeader>
|
|
101
|
-
<Card.CardTitle class="flex items-center gap-2 text-base">
|
|
102
|
-
<Smartphone class="h-4 w-4 text-muted-foreground" />
|
|
103
|
-
{i18n.t('notifications.smsTitle')}
|
|
104
|
-
</Card.CardTitle>
|
|
105
|
-
<Card.CardDescription>{i18n.t('notifications.smsDescription')}</Card.CardDescription>
|
|
106
|
-
</Card.CardHeader>
|
|
107
|
-
<Card.CardContent>
|
|
108
|
-
<div class="flex items-center justify-between gap-4 py-2">
|
|
109
|
-
<div class="space-y-1">
|
|
110
|
-
<Label for="sms-security">{i18n.t('notifications.criticalSecurity')}</Label>
|
|
111
|
-
<p class="text-xs text-muted-foreground">{i18n.t('notifications.criticalSecurityDesc')}</p>
|
|
112
|
-
</div>
|
|
113
|
-
<Switch id="sms-security" bind:checked={smsAlerts.security} />
|
|
114
|
-
</div>
|
|
115
|
-
</Card.CardContent>
|
|
116
|
-
</Card.Card>
|
|
25
|
+
</SettingsGroup>
|
|
26
|
+
<SettingsGroup title={i18n.t('notifications.pushTitle')} description={i18n.t('notifications.pushDescription')}>
|
|
27
|
+
<div class="divide-y divide-border">
|
|
28
|
+
<SettingsFieldRow label={i18n.t('notifications.securityAlerts')} description={i18n.t('notifications.securityPushDesc')}>{#snippet control()}<Switch id="push-security" bind:checked={pushAlerts.security} />{/snippet}</SettingsFieldRow>
|
|
29
|
+
<SettingsFieldRow label={i18n.t('notifications.activityLogs')} description={i18n.t('notifications.activityPushDesc')}>{#snippet control()}<Switch id="push-activity" bind:checked={pushAlerts.activity} />{/snippet}</SettingsFieldRow>
|
|
30
|
+
<SettingsFieldRow label={i18n.t('notifications.systemAlerts')} description={i18n.t('notifications.systemPushDesc')}>{#snippet control()}<Switch id="push-reports" bind:checked={pushAlerts.reports} />{/snippet}</SettingsFieldRow>
|
|
31
|
+
</div>
|
|
32
|
+
</SettingsGroup>
|
|
33
|
+
<SettingsGroup title={i18n.t('notifications.smsTitle')} description={i18n.t('notifications.smsDescription')}>
|
|
34
|
+
<SettingsFieldRow label={i18n.t('notifications.criticalSecurity')} description={i18n.t('notifications.criticalSecurityDesc')}>{#snippet control()}<Switch id="sms-security" bind:checked={smsAlerts.security} />{/snippet}</SettingsFieldRow>
|
|
35
|
+
</SettingsGroup>
|
|
117
36
|
</div>
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
placeholder?: string;
|
|
16
16
|
autocomplete?: string;
|
|
17
17
|
showStrength?: boolean;
|
|
18
|
+
disabled?: boolean;
|
|
18
19
|
class?: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
placeholder = '••••••••',
|
|
26
27
|
autocomplete = 'current-password',
|
|
27
28
|
showStrength = false,
|
|
29
|
+
disabled = false,
|
|
28
30
|
class: className = '',
|
|
29
31
|
}: Props = $props();
|
|
30
32
|
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
bind:value
|
|
67
69
|
class="pl-9 pr-9"
|
|
68
70
|
autocomplete={autocomplete as any}
|
|
71
|
+
{disabled}
|
|
69
72
|
/>
|
|
70
73
|
<TooltipButton
|
|
71
74
|
tooltip={showPassword ? i18n.t('common.hidePassword') : i18n.t('common.showPassword')}
|
|
@@ -74,6 +77,7 @@
|
|
|
74
77
|
type="button"
|
|
75
78
|
class="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 z-[1]"
|
|
76
79
|
onclick={(e) => { e.preventDefault(); showPassword = !showPassword; }}
|
|
80
|
+
{disabled}
|
|
77
81
|
tabindex={-1}
|
|
78
82
|
>
|
|
79
83
|
{#if showPassword}
|