@svadmin/ui 0.46.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.
@@ -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, {}, "">;
@@ -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>
@@ -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
- let isSaved = $state(false);
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
- <div>
27
- <h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.notifications')}</h2>
28
- <p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.notificationsDescription')}</p>
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
- <Button onclick={saveSettings}>{isSaved ? i18n.t('common.autoSaved') : i18n.t('common.save')}</Button>
31
- </div>
32
-
33
- <Card.Card>
34
- <Card.CardHeader>
35
- <Card.CardTitle class="flex items-center gap-2 text-base">
36
- <Mail class="h-4 w-4 text-muted-foreground" />
37
- {i18n.t('notifications.emailTitle')}
38
- </Card.CardTitle>
39
- <Card.CardDescription>{i18n.t('notifications.emailDescription')}</Card.CardDescription>
40
- </Card.CardHeader>
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>
@@ -56,7 +56,7 @@
56
56
 
57
57
  <div class="h-full flex flex-col pt-2">
58
58
  <!-- Header Title -->
59
- <div class="mb-6 flex items-center justify-between">
59
+ <div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
60
60
  <div>
61
61
  <h1 class="text-2xl font-bold text-foreground flex items-center gap-2">
62
62
  <Shield class="w-6 h-6 text-primary" />
@@ -78,10 +78,19 @@
78
78
  {/if}
79
79
  </div>
80
80
 
81
- <div class="flex flex-col md:flex-row gap-6 flex-1 min-h-[500px]">
81
+ <div class="flex flex-col lg:flex-row gap-6 flex-1 min-h-[500px]">
82
+ <div class="w-full rounded-lg border border-border bg-card p-4 shadow-sm lg:hidden">
83
+ <label for="permission-role-select" class="mb-2 block text-sm font-medium text-foreground">{i18n.t('permissions.roles') || 'Roles'}</label>
84
+ <select id="permission-role-select" bind:value={selectedRole} class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" disabled={roles.length === 0}>
85
+ {#each roles as role (role.code)}<option value={role.code}>{role.name}</option>{/each}
86
+ </select>
87
+ {#if roles.length === 0}<p class="mt-2 text-sm text-muted-foreground">{i18n.t('permissions.noRoles') || 'No roles available'}</p>{/if}
88
+ {#if sidebarExtra}<div class="mt-4 border-t border-border pt-4">{@render sidebarExtra()}</div>{/if}
89
+ </div>
90
+
82
91
  <!-- Left: Role Selector Sidebar -->
83
- <div class="w-full md:w-64 flex-shrink-0 bg-card rounded-lg shadow-sm border border-border p-4 flex flex-col">
84
- <h2 class="text-sm font-semibold text-muted-foregroundr mb-4 px-2">{i18n.t('permissions.roles') || 'Roles'}</h2>
92
+ <div class="hidden w-full flex-shrink-0 flex-col rounded-lg border border-border bg-card p-4 shadow-sm lg:flex lg:w-64">
93
+ <h2 class="mb-4 px-2 text-sm font-semibold text-muted-foreground">{i18n.t('permissions.roles') || 'Roles'}</h2>
85
94
  <div class="space-y-1 flex-1 overflow-y-auto">
86
95
  {#each roles as role, _i (_i)}
87
96
  <button
@@ -89,7 +98,7 @@
89
98
  class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 {selectedRole === role.code ? 'bg-primary/10 text-primary font-medium shadow-sm ring-1 ring-primary/20' : 'text-foreground hover:bg-muted'}"
90
99
  >
91
100
  {role.name}
92
- <div class="text-[10px] mt-0.5 opacity-60 font-monoer">{role.code}</div>
101
+ <div class="mt-0.5 font-mono text-[10px] opacity-60">{role.code}</div>
93
102
  </button>
94
103
  {/each}
95
104
  {#if roles.length === 0}
@@ -108,8 +117,8 @@
108
117
  <!-- Right: Matrix Grid -->
109
118
  <div class="flex-1 bg-card rounded-lg shadow-sm border border-border overflow-hidden flex flex-col">
110
119
  <!-- Matrix Header -->
111
- <div class="p-5 border-b border-border bg-muted/30 flex items-center justify-between">
112
- <div class="flex items-center gap-4">
120
+ <div class="p-5 border-b border-border bg-muted/30 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
121
+ <div class="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
113
122
  <div>
114
123
  <h2 class="text-lg font-bold text-foreground">{i18n.t('permissions.currentRole') || 'Current role'}:
115
124
  <span class="text-primary">{roles.find(r => r.code === selectedRole)?.name || (i18n.t('permissions.noneSelected') || 'None selected')}</span>
@@ -156,7 +165,7 @@
156
165
  <!-- Section Grouping Header -->
157
166
  {#if resource.section && (i === 0 || resource.section !== resources[i-1].section)}
158
167
  <tr class="bg-primary/5">
159
- <td colspan="{actions.length + 1}" class="px-4 py-2 text-xs font-bold text-primaryr">{resource.section}</td>
168
+ <td colspan="{actions.length + 1}" class="px-4 py-2 text-xs font-bold text-primary">{resource.section}</td>
160
169
  </tr>
161
170
  {/if}
162
171