@svadmin/ui 0.31.1 → 0.32.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.
package/dist/app.css CHANGED
@@ -17,8 +17,8 @@
17
17
  }
18
18
 
19
19
  :root {
20
- --radius: 0.5rem;
21
- --background: oklch(0.98 0.002 264);
20
+ --radius: 0.75rem;
21
+ --background: oklch(0.975 0.005 264);
22
22
  --foreground: oklch(0.13 0.02 265);
23
23
  --card: oklch(1 0 0);
24
24
  --card-foreground: oklch(0.13 0.02 265);
@@ -272,7 +272,7 @@
272
272
 
273
273
  [data-slot="card"] {
274
274
  /* Classic Stripe soft shadow: faint ambient + directional drop */
275
- box-shadow: 0 2px 8px -2px rgb(0 0 0 / 0.05), 0 4px 16px -4px rgb(0 0 0 / 0.02);
275
+ box-shadow: 0 2px 5px -1px rgb(0 0 0 / 0.04), 0 1px 1px -1px rgb(0 0 0 / 0.04), 0 0 0 1px rgb(0 0 0 / 0.02);
276
276
  }
277
277
 
278
278
  [data-slot="dialog-content"],
@@ -304,14 +304,15 @@ button {
304
304
  }
305
305
  [data-slot="button"]:active:not(:disabled),
306
306
  button:active:not(:disabled) {
307
- transform: scale(0.97);
307
+ transform: scale(0.98);
308
308
  }
309
309
 
310
310
  /* Subtle glowing focus ring for inputs */
311
+ [data-slot="button"]:focus-visible,
311
312
  [data-slot="input"]:focus-visible,
312
313
  [data-slot="textarea"]:focus-visible,
313
314
  [data-slot="select-trigger"]:focus-visible {
314
- box-shadow: 0 0 0 1px var(--color-background), 0 0 0 3px color-mix(in oklch, var(--color-primary) 30%, transparent) !important;
315
+ box-shadow: 0 0 0 1px var(--color-background), 0 0 0 3px color-mix(in oklch, var(--color-primary) 20%, transparent) !important;
315
316
  border-color: var(--color-primary);
316
317
  }
317
318
 
@@ -338,7 +339,7 @@ button:active:not(:disabled) {
338
339
  }
339
340
  @media (hover: hover) {
340
341
  [data-slot="card"]:hover {
341
- box-shadow: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 8px 24px -6px rgb(0 0 0 / 0.04);
342
+ box-shadow: 0 6px 16px -4px rgb(0 0 0 / 0.06), 0 4px 8px -2px rgb(0 0 0 / 0.04), 0 0 0 1px rgb(0 0 0 / 0.02);
342
343
  transform: translateY(-1px);
343
344
  }
344
345
  }
@@ -13,6 +13,17 @@
13
13
  import AutoTable from './AutoTable.svelte';
14
14
  import AutoForm from './AutoForm.svelte';
15
15
  import ShowPage from './ShowPage.svelte';
16
+
17
+ // Module-level QueryClient — survives component remounts (SPA only)
18
+ let _queryClient: QueryClient | null = null;
19
+ function getOrCreateQueryClient(): QueryClient {
20
+ if (!_queryClient) {
21
+ _queryClient = new QueryClient({
22
+ defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
23
+ });
24
+ }
25
+ return _queryClient;
26
+ }
16
27
  import Toast from './Toast.svelte';
17
28
  import LoginPage from './LoginPage.svelte';
18
29
  import RegisterPage from './RegisterPage.svelte';
@@ -40,7 +51,7 @@
40
51
  /** Theme configuration for dark-first mode, CSS overrides, etc. */
41
52
  themeConfig?: ThemeConfig;
42
53
  dashboard?: Snippet;
43
- loginPage?: Snippet;
54
+ loginPage?: Snippet<[{ onSuccess: () => void }]>;
44
55
  /** Override default components via DI */
45
56
  components?: Partial<ComponentRegistry>;
46
57
  /** Custom multi-level menu configuration */
@@ -78,25 +89,13 @@
78
89
  Skeleton: Skeleton as unknown as ComponentRegistry['Skeleton'],
79
90
  };
80
91
 
81
- // Merge user overrides and set context
82
- $effect(() => {
83
- setComponentRegistry({ ...defaultComponents, ...userComponents });
84
- });
92
+ setComponentRegistry({ ...defaultComponents, ...userComponents });
85
93
 
86
94
  // Resolve router provider (default to hash)
87
95
  const resolvedRouter = $derived(routerProvider ?? createHashRouterProvider());
88
96
  const resolvedRouteMode = $derived(routeMode ?? (routerProvider ? 'auto' : 'hash'));
89
97
 
90
- // Set up initial context synchronously so children can access resources immediately during first render
91
- setDataProvider(dataProvider);
92
- setAuthProvider(authProvider);
93
- setResources(resources);
94
- setRouterProvider(resolvedRouter);
95
- if (locale) setLocale(locale);
96
- if (userThemeConfig) configureTheme(userThemeConfig);
97
- if (defaultTheme) setTheme(defaultTheme);
98
-
99
- // Set up context — use $effect so prop changes are tracked
98
+ // Set up context — use $effect.pre so prop changes are tracked and children can access during first render
100
99
  $effect.pre(() => {
101
100
  setDataProvider(dataProvider);
102
101
  setAuthProvider(authProvider);
@@ -107,14 +106,10 @@
107
106
  if (defaultTheme) setTheme(defaultTheme);
108
107
  });
109
108
 
110
- const queryClient = new QueryClient({
111
- defaultOptions: {
112
- queries: { staleTime: 30_000, retry: 1 },
113
- },
114
- });
109
+ // QueryClient — created once and reused across remounts
110
+ const queryClient = getOrCreateQueryClient();
115
111
 
116
- // Initialize router with provider synchronously
117
- initRouter(resolvedRouter);
112
+ // Initialize router with provider
118
113
  $effect.pre(() => {
119
114
  initRouter(resolvedRouter);
120
115
  });
@@ -169,7 +164,7 @@
169
164
  </div>
170
165
  </div>
171
166
  {:else if route === '/login' && loginPage}
172
- {@render loginPage()}
167
+ {@render loginPage({ onSuccess: () => { isAuthenticated = true; navigate('/'); } })}
173
168
  {:else if route === '/login' && authProvider}
174
169
  <LoginPage {title} onSuccess={() => { isAuthenticated = true; navigate('/'); }} />
175
170
  {:else if route === '/register' && authProvider?.register}
@@ -13,7 +13,9 @@ interface Props {
13
13
  /** Theme configuration for dark-first mode, CSS overrides, etc. */
14
14
  themeConfig?: ThemeConfig;
15
15
  dashboard?: Snippet;
16
- loginPage?: Snippet;
16
+ loginPage?: Snippet<[{
17
+ onSuccess: () => void;
18
+ }]>;
17
19
  /** Override default components via DI */
18
20
  components?: Partial<ComponentRegistry>;
19
21
  /** Custom multi-level menu configuration */
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { t } from '@svadmin/core/i18n';
3
- import { getTheme, setTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme, getColorPresets } from '@svadmin/core';
3
+ import { getTheme, setTheme, getResolvedTheme, getColorThemes, getColorTheme, setColorTheme, getColorPresets } from '@svadmin/core';
4
4
  import { getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
5
5
  import * as Card from './ui/card/index.js';
6
6
  import { Button } from './ui/button/index.js';
@@ -23,6 +23,8 @@
23
23
  let drawerOpen = $state(false);
24
24
  let drawerLog = $state<AuditLog | null>(null);
25
25
 
26
+ let cancelled = $state(false);
27
+
26
28
  async function loadLogs() {
27
29
  if (!authProvider?.getAuditLogs) {
28
30
  error = t('settings.auditNotSupported') ?? 'Audit logs are not supported by the current AuthProvider';
@@ -30,21 +32,26 @@
30
32
  return;
31
33
  }
32
34
  loading = true;
35
+ error = null;
33
36
  try {
34
37
  const result = await authProvider.getAuditLogs({ page, pageSize });
38
+ if (cancelled) return;
35
39
  logs = result.data;
36
40
  total = result.total;
37
41
  } catch (e) {
42
+ if (cancelled) return;
38
43
  toast.error((e as Error).message);
39
44
  } finally {
40
- loading = false;
45
+ if (!cancelled) loading = false;
41
46
  }
42
47
  }
43
48
 
44
49
  $effect(() => {
45
- // Track `page` to re-fetch on pagination — this is the single trigger
46
50
  void page;
51
+ cancelled = true;
52
+ cancelled = false;
47
53
  loadLogs();
54
+ return () => { cancelled = true; };
48
55
  });
49
56
 
50
57
  let totalPages = $derived(Math.ceil(total / pageSize) || 1);
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { useForm, getResource, deriveValidator } from '@svadmin/core';
3
+ import { slide } from 'svelte/transition';
3
4
  import type { FieldDefinition } from '@svadmin/core';
4
5
  import { t } from '@svadmin/core/i18n';
5
6
  import { navigate } from '@svadmin/core/router';
@@ -17,13 +18,14 @@
17
18
  interface Props {
18
19
  resourceName: string;
19
20
  id?: string | number;
20
- mode?: 'create' | 'edit' | 'clone';
21
+ mode?: 'create' | 'edit' | 'clone' | 'show';
21
22
  fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
22
23
  formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
23
24
  headerContent?: Snippet;
24
25
  }
25
26
 
26
27
  let { resourceName, id, mode = 'create', fieldRenderer, formActions, headerContent }: Props = $props();
28
+ const isReadonly = $derived(mode === 'show');
27
29
 
28
30
  // ─── Resource metadata ────────────────────────────────────────────
29
31
  const resource = $derived(getResource(resourceName));
@@ -34,6 +36,7 @@
34
36
  if (f.showInForm === false) return false;
35
37
  if (mode === 'create' && f.showInCreate === false) return false;
36
38
  if (mode === 'edit' && f.showInEdit === false) return false;
39
+ if (mode === 'show' && f.showInShow === false) return false;
37
40
  return true;
38
41
  }));
39
42
 
@@ -71,6 +74,18 @@
71
74
 
72
75
  const validator = $derived(deriveValidator(formFields));
73
76
 
77
+ $effect(() => {
78
+ if (mode !== 'create') return;
79
+ if (!form.isTainted()) {
80
+ for (const key in defaults) {
81
+ if (form.values[key] !== defaults[key]) {
82
+ form.setFieldValue(key, defaults[key]);
83
+ }
84
+ }
85
+ form.untaint();
86
+ }
87
+ });
88
+
74
89
  // ─── useForm: single source of truth for values, errors, tainted ──
75
90
  const form = useForm({
76
91
  get resource() { return resourceName; },
@@ -97,6 +112,8 @@
97
112
  const pageTitle = $derived(
98
113
  mode === 'create'
99
114
  ? `${t('common.create')}${resource.label}`
115
+ : mode === 'show'
116
+ ? `${t('common.detail')}${resource.label}`
100
117
  : `${t('common.edit')}${resource.label}`
101
118
  );
102
119
 
@@ -153,10 +170,12 @@
153
170
  {:else}
154
171
  <form onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-3xl space-y-6">
155
172
  {#if submitError}
156
- <Alert.Root variant="destructive">
157
- <AlertCircle class="h-4 w-4" />
158
- <Alert.Description>{submitError}</Alert.Description>
159
- </Alert.Root>
173
+ <div transition:slide={{ duration: 300, axis: 'y' }} class="svadmin-shake">
174
+ <Alert.Root variant="destructive">
175
+ <AlertCircle class="h-4 w-4" />
176
+ <Alert.Description>{submitError}</Alert.Description>
177
+ </Alert.Root>
178
+ </div>
160
179
  {/if}
161
180
 
162
181
  {#if hasGroups}
@@ -177,10 +196,11 @@
177
196
  {field}
178
197
  value={form.values[field.key]}
179
198
  onchange={(val: unknown) => form.setFieldValue(field.key, val)}
199
+ disabled={isReadonly}
180
200
  />
181
201
  {/if}
182
202
  {#if form.errors[field.key]}
183
- <p class="text-destructive text-[0.8125rem] mt-1">{form.errors[field.key]}</p>
203
+ <p class="text-destructive text-[0.8125rem] mt-1" role="alert" aria-live="polite">{form.errors[field.key]}</p>
184
204
  {/if}
185
205
  </div>
186
206
  {/each}
@@ -199,10 +219,11 @@
199
219
  {field}
200
220
  value={form.values[field.key]}
201
221
  onchange={(val: unknown) => form.setFieldValue(field.key, val)}
222
+ disabled={isReadonly}
202
223
  />
203
224
  {/if}
204
225
  {#if form.errors[field.key]}
205
- <p class="text-destructive text-[0.8125rem] mt-1">{form.errors[field.key]}</p>
226
+ <p class="text-destructive text-[0.8125rem] mt-1" role="alert" aria-live="polite">{form.errors[field.key]}</p>
206
227
  {/if}
207
228
  </div>
208
229
  {/each}
@@ -213,7 +234,7 @@
213
234
  <div class="flex items-center gap-3">
214
235
  {#if formActions}
215
236
  {@render formActions({ isLoading: form.submitting, onSubmit: handleSubmit })}
216
- {:else}
237
+ {:else if !isReadonly}
217
238
  <Button type="submit" disabled={form.submitting}>
218
239
  {#if form.submitting}
219
240
  <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
@@ -3,7 +3,7 @@ import type { Snippet } from 'svelte';
3
3
  interface Props {
4
4
  resourceName: string;
5
5
  id?: string | number;
6
- mode?: 'create' | 'edit' | 'clone';
6
+ mode?: 'create' | 'edit' | 'clone' | 'show';
7
7
  fieldRenderer?: Snippet<[{
8
8
  field: FieldDefinition;
9
9
  value: unknown;
@@ -11,7 +11,7 @@
11
11
  } from '@tanstack/svelte-table';
12
12
  import { getCoreRowModel } from '@tanstack/table-core';
13
13
 
14
- import { useList, useDelete, getResource } from '@svadmin/core';
14
+ import { useList, useDelete, getResource, notify } from '@svadmin/core';
15
15
  import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
16
16
  import { navigate } from '@svadmin/core/router';
17
17
  import { useCan, getAccessControlProvider } from '@svadmin/core';
@@ -146,7 +146,6 @@
146
146
  } as any);
147
147
  }
148
148
  }
149
- // Add popover filters
150
149
  for (const [key, value] of Object.entries(filterValues)) {
151
150
  if (value.trim()) {
152
151
  result.push({ field: key, operator: 'contains', value: value.trim() });
@@ -155,6 +154,12 @@
155
154
  return result;
156
155
  });
157
156
 
157
+ $effect(() => {
158
+ if (Object.values(filterValues).some(v => v.trim())) {
159
+ pagination = { ...pagination, current: 1 };
160
+ }
161
+ });
162
+
158
163
  // ─── Data fetching ────────────────────────────────────────────
159
164
  const listResult = useList({
160
165
  get resource() { return resourceName; },
@@ -222,6 +227,14 @@
222
227
  }
223
228
  });
224
229
 
230
+ // Sync external sorters → TanStack sorting state
231
+ $effect(() => {
232
+ const newSorting = sorters.map(s => ({ id: s.field, desc: s.order === 'desc' }));
233
+ if (JSON.stringify(newSorting) !== JSON.stringify(sorting)) {
234
+ sorting = newSorting;
235
+ }
236
+ });
237
+
225
238
  // ─── Auto-generate columns from resource fields ──────────────
226
239
  const visibleFields = $derived(
227
240
  resource.fields.filter(f => f.showInList !== false)
@@ -229,7 +242,7 @@
229
242
 
230
243
  const columns = $derived<ColumnDef<any, Record<string, unknown>>[]>([
231
244
  // Selection column
232
- ...(selectable && canDelete ? [{
245
+ ...(selectable && (canDelete || batchActions) ? [{
233
246
  id: '_select',
234
247
  header: () => '',
235
248
  cell: () => '',
@@ -291,7 +304,7 @@
291
304
  onColumnOrderChange: (updater: any) => {
292
305
  columnOrder = typeof updater === 'function' ? updater(columnOrder) : updater;
293
306
  },
294
- get enableRowSelection() { return selectable && canDelete; },
307
+ get enableRowSelection() { return selectable && (canDelete || batchActions); },
295
308
  get enableExpanding() { return !!expandedRowRender; },
296
309
  });
297
310
 
@@ -334,7 +347,14 @@
334
347
  const ids = Object.keys(rowSelection);
335
348
  confirmMessage = t('common.batchDeleteConfirm', { count: ids.length });
336
349
  confirmAction = async () => {
337
- await Promise.allSettled(ids.map(id => deleteMutation.mutateAsync({ id, resource: resourceName })));
350
+ const results = await Promise.allSettled(ids.map(id => deleteMutation.mutateAsync({ id, resource: resourceName, successNotification: false })));
351
+ const succeeded = results.filter(r => r.status === 'fulfilled').length;
352
+ const failed = results.filter(r => r.status === 'rejected');
353
+ if (failed.length > 0) {
354
+ notify({ type: 'error', message: t('common.batchDeletePartialFail', { failed: failed.length, total: ids.length }) });
355
+ } else {
356
+ notify({ type: 'success', message: t('common.batchDeleteSuccess', { count: succeeded }) });
357
+ }
338
358
  rowSelection = {};
339
359
  confirmOpen = false;
340
360
  };
@@ -465,7 +485,7 @@
465
485
  value={filterValues[field.key] ?? ''}
466
486
  onchange={(e) => filterValues[field.key] = (e.currentTarget as HTMLSelectElement).value}
467
487
  >
468
- <option value="">全部</option>
488
+ <option value="">{t('common.all')}</option>
469
489
  {#each field.options as opt}
470
490
  <option value={opt.value}>{opt.label}</option>
471
491
  {/each}
@@ -500,7 +520,7 @@
500
520
  </div>
501
521
 
502
522
  <!-- Table (TanStack-powered) -->
503
- <div class="rounded-lg bg-card shadow-sm ring-1 ring-border/10 overflow-hidden" role="region" aria-label="{resource.label} {t('common.list')}">
523
+ <div class="rounded-[24px] bg-card/80 backdrop-blur-3xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] overflow-hidden ring-1 ring-border/30" role="region" aria-label="{resource.label} {t('common.list')}">
504
524
  {#if query.isLoading}
505
525
  <div class="p-4 space-y-3">
506
526
  <div class="flex gap-4 mb-2">
@@ -536,7 +556,7 @@
536
556
  {@const header = col.header as typeof headerGroup.headers[0]}
537
557
  <Table.Head
538
558
  {...dragProps}
539
- class={cn("bg-muted/50 hover:bg-muted/50", dragProps.class)}
559
+ class={cn("bg-transparent hover:bg-muted/10 border-b border-border/20 uppercase tracking-[0.12em] text-[10px] text-muted-foreground/70 font-semibold py-4", dragProps.class)}
540
560
  style={header.getSize() !== 150 ? `width:${header.getSize()}px` : undefined}
541
561
  >
542
562
  {#if header.id === '_select'}
@@ -552,7 +572,7 @@
552
572
  <Button
553
573
  variant="ghost"
554
574
  size="sm"
555
- class="flex items-center gap-1 hover:text-foreground -ml-3 h-auto py-1 px-2"
575
+ class="flex items-center gap-1 hover:text-foreground -ml-2 h-auto py-1 px-2 uppercase tracking-wide text-[0.7rem] font-semibold"
556
576
  onclick={() => header.column.toggleSorting()}
557
577
  >
558
578
  {visibleFields.find(f => f.key === header.id)?.label ?? header.id}
@@ -578,7 +598,7 @@
578
598
  <ContextMenu.Root>
579
599
  <ContextMenu.Trigger>
580
600
  {#snippet child({ props })}
581
- <Table.Row {...props} class="transition-colors border-0 even:bg-muted/20 {row.getIsSelected() ? 'bg-accent/50' : 'hover:bg-muted/40'}">
601
+ <Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {row.getIsSelected() ? 'bg-primary/5' : 'hover:bg-muted/20'}">
582
602
  {#each row.getVisibleCells() as cell}
583
603
  <Table.Cell>
584
604
  {#if cell.column.id === '_select'}
@@ -670,7 +690,7 @@
670
690
  </ContextMenu.Content>
671
691
  </ContextMenu.Root>
672
692
  {#if expandedRowRender && row.getIsExpanded()}
673
- <Table.Row class="bg-muted/30">
693
+ <Table.Row class="bg-muted/10 border-b border-border/10 transition-all">
674
694
  <Table.Cell colspan={row.getVisibleCells().length}>
675
695
  {@render expandedRowRender({ record })}
676
696
  </Table.Cell>
@@ -709,12 +729,12 @@
709
729
  {@const record = row.original}
710
730
  {@const id = record[primaryKey] as string | number}
711
731
  <div
712
- class="rounded-xl shadow-sm ring-1 ring-border/10 bg-card p-4 transition-colors {row.getIsSelected() ? 'ring-2 ring-primary bg-accent/30' : ''}"
732
+ class="rounded-[20px] shadow-[0_4px_20px_rgb(0,0,0,0.03)] ring-1 ring-border/20 bg-card p-5 transition-all {row.getIsSelected() ? 'ring-2 ring-primary/50 bg-primary/5' : ''}"
713
733
  >
714
734
  <!-- Card header: ID + select + actions -->
715
735
  <div class="flex items-center justify-between mb-3">
716
736
  <div class="flex items-center gap-2">
717
- {#if selectable && canDelete}
737
+ {#if selectable && (canDelete || batchActions)}
718
738
  <Checkbox
719
739
  checked={row.getIsSelected()}
720
740
  onCheckedChange={() => row.toggleSelected()}
@@ -16,7 +16,7 @@
16
16
  let { open = $bindable(false), onAskAI }: Props = $props();
17
17
  let searchValue = $state('');
18
18
 
19
- const resources = getResources();
19
+ const resources = $derived(getResources());
20
20
  const hasAI = $derived(!!(getAgentProvider() || getChatProvider()));
21
21
 
22
22
  function close() {
@@ -25,9 +25,23 @@
25
25
  const resolvedTitle = $derived(title ?? t('common.confirmAction'));
26
26
  const resolvedConfirmText = $derived(confirmText ?? t('common.confirm'));
27
27
  const resolvedCancelText = $derived(cancelText ?? t('common.cancel'));
28
+
29
+ let confirmed = $state(false);
30
+
31
+ function handleConfirm() {
32
+ confirmed = true;
33
+ onconfirm();
34
+ }
35
+
36
+ function handleOpenChange(v: boolean) {
37
+ if (!v && !confirmed) {
38
+ oncancel();
39
+ }
40
+ if (!v) confirmed = false;
41
+ }
28
42
  </script>
29
43
 
30
- <AlertDialog.Root bind:open onOpenChange={(v: boolean) => { if (!v) oncancel(); }}>
44
+ <AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
31
45
  <AlertDialog.Content>
32
46
  <AlertDialog.Header>
33
47
  <AlertDialog.Title>{resolvedTitle}</AlertDialog.Title>
@@ -43,7 +57,7 @@
43
57
  </AlertDialog.Cancel>
44
58
  <AlertDialog.Action>
45
59
  {#snippet child({ props })}
46
- <Button variant={variantMap[variant as keyof typeof variantMap]} {...props} onclick={onconfirm}>
60
+ <Button variant={variantMap[variant as keyof typeof variantMap]} {...props} onclick={handleConfirm}>
47
61
  {resolvedConfirmText}
48
62
  </Button>
49
63
  {/snippet}
@@ -20,11 +20,11 @@
20
20
  import type { Snippet } from 'svelte';
21
21
  import { getRichTextEditor } from '../editor-config.svelte.js';
22
22
 
23
- let { field, value, onchange, children } = $props<{
23
+ let { field, value, onchange, disabled, children } = $props<{
24
24
  field: FieldDefinition;
25
25
  value: unknown;
26
26
  onchange: (val: unknown) => void;
27
- /** Optional custom renderer snippet */
27
+ disabled?: boolean;
28
28
  children?: Snippet;
29
29
  }>();
30
30
 
@@ -98,6 +98,7 @@
98
98
  value={strVal}
99
99
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
100
100
  required={field.required}
101
+ {disabled}
101
102
  placeholder={t('field.enterValue', { label: field.label })}
102
103
  />
103
104
 
@@ -108,6 +109,7 @@
108
109
  value={strVal}
109
110
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
110
111
  required={field.required}
112
+ {disabled}
111
113
  placeholder="name@example.com"
112
114
  />
113
115
 
@@ -118,6 +120,7 @@
118
120
  value={strVal}
119
121
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
120
122
  required={field.required}
123
+ {disabled}
121
124
  placeholder="https://"
122
125
  />
123
126
 
@@ -128,6 +131,7 @@
128
131
  value={strVal}
129
132
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
130
133
  required={field.required}
134
+ {disabled}
131
135
  placeholder="+1 (555) 000-0000"
132
136
  />
133
137
 
@@ -139,6 +143,7 @@
139
143
  value={strVal || '#000000'}
140
144
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
141
145
  class="h-10 w-14 cursor-pointer rounded-md border border-input bg-background p-1"
146
+ disabled={disabled}
142
147
  />
143
148
  <Input
144
149
  type="text"
@@ -146,6 +151,7 @@
146
151
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
147
152
  placeholder="#000000"
148
153
  class="max-w-32 font-mono text-sm"
154
+ {disabled}
149
155
  />
150
156
  {#if strVal}
151
157
  <span
@@ -160,8 +166,12 @@
160
166
  id={field.key}
161
167
  type="number"
162
168
  value={String(numVal)}
163
- oninput={(e) => onchange(parseFloat((e.target as HTMLInputElement).value) || 0)}
169
+ oninput={(e) => {
170
+ const v = (e.target as HTMLInputElement).value;
171
+ onchange(v === '' ? null : Number(v));
172
+ }}
164
173
  required={field.required}
174
+ {disabled}
165
175
  />
166
176
 
167
177
  {:else if field.type === 'richtext'}
@@ -173,6 +183,7 @@
173
183
  placeholder={t('field.enterValue', { label: field.label })}
174
184
  preset="full"
175
185
  onchange={(html: string) => onchange(html)}
186
+ {disabled}
176
187
  />
177
188
  {:else}
178
189
  <Textarea
@@ -180,6 +191,7 @@
180
191
  value={strVal}
181
192
  oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
182
193
  required={field.required}
194
+ {disabled}
183
195
  rows={10}
184
196
  placeholder={t('field.enterValue', { label: field.label })}
185
197
  class="resize-y"
@@ -192,6 +204,7 @@
192
204
  value={strVal}
193
205
  oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
194
206
  required={field.required}
207
+ {disabled}
195
208
  rows={4}
196
209
  placeholder={t('field.enterValue', { label: field.label })}
197
210
  class="resize-y"
@@ -206,24 +219,31 @@
206
219
  optionLabel={field.optionLabel ?? 'title'}
207
220
  optionValue={field.optionValue ?? 'id'}
208
221
  placeholder={t('field.selectPlaceholder')}
222
+ {disabled}
209
223
  />
210
224
 
211
225
  {:else if field.type === 'select'}
212
226
  {#if (field.options?.length ?? 0) > 8}
213
- <!-- Many options → use ComboboxField-style search -->
214
- <ComboboxField
227
+ <select
215
228
  id={field.key}
216
- resource=""
217
- value={value as string | number | null}
218
- onchange={(v) => onchange(v)}
219
- searchable={false}
220
- />
229
+ 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"
230
+ value={strVal}
231
+ onchange={(e) => onchange((e.target as HTMLSelectElement).value)}
232
+ required={field.required}
233
+ disabled={disabled}
234
+ >
235
+ <option value="">{t('field.selectPlaceholder')}</option>
236
+ {#each field.options ?? [] as opt}
237
+ <option value={opt.value}>{opt.label}</option>
238
+ {/each}
239
+ </select>
221
240
  {:else}
222
241
  <Select
223
242
  id={field.key}
224
243
  value={strVal}
225
244
  onchange={(e) => onchange((e.target as HTMLSelectElement).value)}
226
245
  required={field.required}
246
+ {disabled}
227
247
  placeholder={t('field.selectPlaceholder')}
228
248
  >
229
249
  {#each field.options ?? [] as opt}
@@ -244,6 +264,7 @@
244
264
  id={`${field.key}-${opt.value}`}
245
265
  checked={multiVal.includes(opt.value)}
246
266
  onCheckedChange={() => toggleMulti(opt.value)}
267
+ disabled={disabled}
247
268
  />
248
269
  {opt.label}
249
270
  </label>
@@ -270,6 +291,7 @@
270
291
  id={field.key}
271
292
  checked={boolVal}
272
293
  onCheckedChange={(v) => onchange(v)}
294
+ disabled={disabled}
273
295
  />
274
296
  <span class="text-sm text-muted-foreground">{boolVal ? t('common.yes') : t('common.no')}</span>
275
297
  </div>
@@ -294,6 +316,7 @@
294
316
  type="text"
295
317
  placeholder={t('field.tagsPlaceholder')}
296
318
  onkeydown={handleTagKeydown}
319
+ {disabled}
297
320
  />
298
321
  </div>
299
322
 
@@ -304,6 +327,7 @@
304
327
  value={strVal}
305
328
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
306
329
  required={field.required}
330
+ {disabled}
307
331
  />
308
332
 
309
333
  {:else if field.type === 'images'}
@@ -317,6 +341,7 @@
317
341
  oninput={(e) => updateImage(i, (e.target as HTMLInputElement).value)}
318
342
  placeholder="https://example.com/image.jpg"
319
343
  class="flex-1"
344
+ {disabled}
320
345
  />
321
346
  {#if url}
322
347
  <img src={url} alt="preview" class="h-9 w-9 rounded object-cover border" />
@@ -342,6 +367,7 @@
342
367
  // keep raw text until valid JSON
343
368
  }
344
369
  }}
370
+ {disabled}
345
371
  rows={6}
346
372
  class="resize-y font-mono text-xs"
347
373
  />
@@ -352,6 +378,7 @@
352
378
  type="text"
353
379
  value={strVal}
354
380
  oninput={(e) => onchange((e.target as HTMLInputElement).value)}
381
+ {disabled}
355
382
  />
356
383
  {/if}
357
384
  </div>
@@ -5,7 +5,7 @@ type $$ComponentProps = {
5
5
  field: FieldDefinition;
6
6
  value: unknown;
7
7
  onchange: (val: unknown) => void;
8
- /** Optional custom renderer snippet */
8
+ disabled?: boolean;
9
9
  children?: Snippet;
10
10
  };
11
11
  declare const FieldRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -26,12 +26,11 @@
26
26
  siteUrl?: string;
27
27
  }>();
28
28
 
29
- // Retrieve optional component overrides from registry
30
- const registry = getComponentRegistry() || {} as any;
31
- const CustomBreadcrumbs = registry.Breadcrumbs;
32
- const CustomThemeToggle = registry.ThemeToggle;
33
- const CustomUserMenu = registry.UserMenu;
34
- const CustomNotificationPanel = registry.NotificationPanel;
29
+ const registry = $derived(getComponentRegistry() || {} as any);
30
+ const CustomBreadcrumbs = $derived(registry.Breadcrumbs);
31
+ const CustomThemeToggle = $derived(registry.ThemeToggle);
32
+ const CustomUserMenu = $derived(registry.UserMenu);
33
+ const CustomNotificationPanel = $derived(registry.NotificationPanel);
35
34
  </script>
36
35
 
37
36
  <header class="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center justify-between bg-card/60 backdrop-blur-md px-4 md:px-6">
@@ -2,7 +2,6 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { fade, fly } from 'svelte/transition';
4
4
  import Sidebar from './Sidebar.svelte';
5
- import Toast from './Toast.svelte';
6
5
  import Header from './Header.svelte';
7
6
  import CommandPalette from './CommandPalette.svelte';
8
7
  import KeyboardShortcuts from './KeyboardShortcuts.svelte';
@@ -168,5 +167,4 @@
168
167
  />
169
168
  <KeyboardShortcuts bind:open={shortcutsOpen} />
170
169
  <ChatDialog />
171
- <Toast />
172
170
  {/if}
@@ -38,7 +38,7 @@
38
38
  socialProviders?: SocialProvider[];
39
39
  }>();
40
40
 
41
- const login = useLogin();
41
+ const login = useLogin({ errorMessage: false });
42
42
  const authProvider = getAuthProvider();
43
43
 
44
44
  let identifier = $state('');
@@ -4,9 +4,11 @@
4
4
 
5
5
  const { menuItems } = useMenu();
6
6
 
7
+ let navigated = false;
7
8
  $effect(() => {
8
- // Automatically navigate to the first defined resource
9
- if (menuItems.length > 0) {
9
+ // Navigate to first resource ONCE — avoid infinite re-triggers
10
+ if (!navigated && menuItems.length > 0) {
11
+ navigated = true;
10
12
  navigate(menuItems[0].route);
11
13
  }
12
14
  });
@@ -15,7 +15,7 @@
15
15
  isGranted,
16
16
 
17
17
  // Interactive states
18
- selectedRole = $bindable(roles[0]?.code || ''),
18
+ selectedRole = $bindable(''),
19
19
  loading = false,
20
20
 
21
21
  // Feedback and alerts (passed down from upper business logic)
@@ -44,6 +44,11 @@
44
44
  onToggle: (role: string, resource: string, action: string, grant: boolean) => void;
45
45
  } = $props();
46
46
 
47
+ $effect(() => {
48
+ if (!selectedRole && roles.length > 0) {
49
+ selectedRole = roles[0].code;
50
+ }
51
+ });
47
52
  </script>
48
53
 
49
54
  <div class="h-full flex flex-col pt-2">
@@ -73,5 +73,14 @@
73
73
  {/each}
74
74
  </Card.Content>
75
75
  </Card.Root>
76
+ {:else}
77
+ <Card.Root class="overflow-hidden border-border/40 shadow-sm">
78
+ <Card.Content class="p-8 text-center">
79
+ <p class="text-muted-foreground">{t('common.noData')}</p>
80
+ {#if query.isError}
81
+ <p class="text-sm text-destructive mt-2">{(query.error as Error)?.message ?? t('common.operationFailed')}</p>
82
+ {/if}
83
+ </Card.Content>
84
+ </Card.Root>
76
85
  {/if}
77
86
  </div>
@@ -4,7 +4,7 @@
4
4
  import { navigate } from '@svadmin/core/router';
5
5
  import { getPath } from '../router-state.svelte.js';
6
6
  import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
7
- import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
7
+ import { toggleTheme, getResolvedTheme, getColorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
8
8
  import { Button } from './ui/button/index.js';
9
9
  import TooltipButton from './TooltipButton.svelte';
10
10
  import * as Tooltip from './ui/tooltip/index.js';
@@ -80,10 +80,11 @@
80
80
  let navItems = $state.raw<NavItem[]>([]);
81
81
 
82
82
  $effect(() => {
83
- // Re-run when locale or resources change
84
83
  const localeVal = getLocale();
84
+ const currentResources = getResources();
85
+ let cancelled = false;
85
86
 
86
- Promise.all(resources.map(async (r) => {
87
+ Promise.all(currentResources.map(async (r) => {
87
88
  try {
88
89
  const { can } = await canAccessAsync(r.name, 'list');
89
90
  return { r, can };
@@ -91,6 +92,7 @@
91
92
  return { r, can: false };
92
93
  }
93
94
  })).then(results => {
95
+ if (cancelled) return;
94
96
  const items: NavItem[] = [{ path: '/', label: t('common.home'), Icon: LayoutDashboard }];
95
97
  for (const { r, can } of results) {
96
98
  if (can) {
@@ -104,6 +106,8 @@
104
106
  }
105
107
  navItems = items;
106
108
  });
109
+
110
+ return () => { cancelled = true; };
107
111
  });
108
112
 
109
113
  /** Toggle between available locales */
@@ -254,9 +258,10 @@
254
258
  {@const active = isActive(item.path)}
255
259
  <a
256
260
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
257
- class="flex items-center gap-3 rounded-lg px-3 {pyClassGroupItem} text-sm font-medium transition-all duration-200
261
+ onclick={(e) => { e.preventDefault(); navigate(item.path); }}
262
+ class="flex items-center gap-3 rounded-xl px-3 {pyClassGroupItem} text-sm font-medium transition-all duration-300
258
263
  {active
259
- ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
264
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20 translate-x-1'
260
265
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
261
266
  >
262
267
  <item.Icon class="h-4 w-4 flex-shrink-0" />
@@ -277,10 +282,11 @@
277
282
  <a
278
283
  {...props}
279
284
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
280
- class="flex items-center justify-center gap-3 rounded-lg px-3 {pyClass} text-sm font-medium transition-all duration-200
285
+ onclick={(e) => { e.preventDefault(); navigate(item.path); }}
286
+ class="flex items-center justify-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-300
281
287
  {active
282
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
283
- : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
288
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20'
289
+ : 'text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground'}"
284
290
  >
285
291
  <item.Icon class="h-4 w-4 flex-shrink-0" />
286
292
  </a>
@@ -293,9 +299,10 @@
293
299
  {:else}
294
300
  <a
295
301
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
296
- class="flex items-center gap-3 rounded-lg px-3 {pyClass} text-sm font-medium transition-all duration-200
302
+ onclick={(e) => { e.preventDefault(); navigate(item.path); }}
303
+ class="flex items-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-300
297
304
  {active
298
- ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
305
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20 translate-x-1'
299
306
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
300
307
  >
301
308
  <item.Icon class="h-4 w-4 flex-shrink-0" />
@@ -324,12 +331,12 @@
324
331
  <span class="flex-1 text-left text-xs">{t('common.toggleTheme')}</span>
325
332
  <span
326
333
  class="h-3.5 w-3.5 rounded-full ring-1 ring-offset-1 ring-offset-sidebar"
327
- style="background-color: {colorThemes.find(c => c.id === getColorTheme())?.color ?? '#6366f1'}; --tw-ring-color: {colorThemes.find(c => c.id === getColorTheme())?.color ?? '#6366f1'}"
334
+ style="background-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}; --tw-ring-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}"
328
335
  ></span>
329
336
  </Button>
330
337
  {#if colorPickerOpen}
331
338
  <div class="absolute bottom-full left-0 mb-1 z-50 w-40 rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95">
332
- {#each colorThemes as ct}
339
+ {#each getColorThemes() as ct}
333
340
  <button
334
341
  class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
335
342
  onclick={() => { setColorTheme(ct.id as typeof ct.id & import('@svadmin/core').ColorTheme); colorPickerOpen = false; }}
@@ -102,7 +102,7 @@
102
102
  rel={isExternal ? 'noopener noreferrer' : undefined}
103
103
  class="flex items-center justify-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
104
104
  {active
105
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
105
+ ? 'bg-card text-sidebar-primary shadow-sm ring-1 ring-foreground/5'
106
106
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
107
107
  >
108
108
  <Icon class="h-4 w-4 flex-shrink-0" />
@@ -121,7 +121,7 @@
121
121
  rel={isExternal ? 'noopener noreferrer' : undefined}
122
122
  class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
123
123
  {active
124
- ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
124
+ ? 'bg-card text-sidebar-primary shadow-sm ring-1 ring-foreground/5 font-semibold before:absolute before:left-0 before:top-2 before:bottom-2 before:w-[3px] before:bg-primary before:rounded-r-md overflow-hidden relative'
125
125
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
126
126
  style="padding-left: {12 + depth * 12}px"
127
127
  >
@@ -7,7 +7,7 @@
7
7
 
8
8
  let {
9
9
  tenants = [],
10
- currentTenantId = $bindable(''),
10
+ currentTenantId = $bindable(undefined as string | undefined),
11
11
  collapsed = false,
12
12
  onSwitch,
13
13
  headerSnippet,
@@ -74,7 +74,7 @@
74
74
  {/if}
75
75
  </div>
76
76
  <span class="flex-1 truncate">{tenant.name}</span>
77
- {#if tenant.id === currentTenantId}
77
+ {#if tenant.id === currentTenant?.id}
78
78
  <Check class="h-4 w-4 text-primary" />
79
79
  {/if}
80
80
  </DropdownMenu.Item>
@@ -89,6 +89,18 @@
89
89
  isListening = false;
90
90
  if (onend) onend();
91
91
  };
92
+
93
+ return () => {
94
+ if (recognition) {
95
+ isListening = false;
96
+ recognition.onend = () => {};
97
+ recognition.stop();
98
+ recognition.onstart = null;
99
+ recognition.onresult = null;
100
+ recognition.onerror = null;
101
+ recognition.onend = null;
102
+ }
103
+ };
92
104
  });
93
105
 
94
106
  function toggleListening(e?: MouseEvent) {
@@ -28,7 +28,7 @@
28
28
  bind:ref
29
29
  data-slot="dialog-content"
30
30
  class={cn(
31
- "bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-lg p-6 text-sm ring-1 duration-100 sm:max-w-lg fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
31
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-6 text-sm ring-1 duration-300 sm:max-w-lg fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none shadow-2xl",
32
32
  className
33
33
  )}
34
34
  {...restProps}
@@ -12,6 +12,6 @@
12
12
  <DialogPrimitive.Overlay
13
13
  bind:ref
14
14
  data-slot="dialog-overlay"
15
- class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
15
+ class={cn("data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-black/20 duration-200 backdrop-blur-sm fixed inset-0 isolate z-50", className)}
16
16
  {...restProps}
17
17
  />
@@ -15,7 +15,8 @@
15
15
  ...restProps
16
16
  }: Props = $props();
17
17
 
18
- let isOpen = $derived(getContext<() => boolean>("svadmin-dropdown-open")?.() ?? true);
18
+ const getOpen = getContext<() => boolean>("svadmin-dropdown-open");
19
+ let isOpen = $derived(getOpen?.() ?? true);
19
20
  </script>
20
21
 
21
22
  {#if isOpen}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.31.1",
3
+ "version": "0.32.1",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -25,7 +25,7 @@
25
25
  "./components/*": "./dist/components/*"
26
26
  },
27
27
  "peerDependencies": {
28
- "@svadmin/core": "^0.22.1",
28
+ "@svadmin/core": "^0.23.1",
29
29
  "@tanstack/svelte-query": "^6.0.0",
30
30
  "highlight.js": "^11.11.1",
31
31
  "isomorphic-dompurify": "^3.7.1",