@svadmin/ui 0.31.0 → 0.32.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.
Files changed (35) hide show
  1. package/dist/app.css +7 -6
  2. package/dist/components/AdminApp.svelte +19 -24
  3. package/dist/components/AdminApp.svelte.d.ts +3 -1
  4. package/dist/components/AnimatedCounter.svelte +8 -2
  5. package/dist/components/AnomalyBadge.svelte +5 -1
  6. package/dist/components/AuditLogViewer.svelte +9 -2
  7. package/dist/components/AutoForm.svelte +29 -8
  8. package/dist/components/AutoForm.svelte.d.ts +1 -1
  9. package/dist/components/AutoTable.svelte +35 -15
  10. package/dist/components/Breadcrumbs.svelte +3 -3
  11. package/dist/components/ChatDialog.svelte +39 -1
  12. package/dist/components/CommandPalette.svelte +3 -3
  13. package/dist/components/ConfirmDialog.svelte +16 -2
  14. package/dist/components/DraggableHeader.svelte +1 -2
  15. package/dist/components/FieldRenderer.svelte +37 -10
  16. package/dist/components/FieldRenderer.svelte.d.ts +1 -1
  17. package/dist/components/Header.svelte +5 -6
  18. package/dist/components/Layout.svelte +10 -5
  19. package/dist/components/LiveIndicator.svelte +7 -1
  20. package/dist/components/LoginPage.svelte +1 -1
  21. package/dist/components/NavigateToResource.svelte +4 -2
  22. package/dist/components/PermissionMatrix.svelte +6 -1
  23. package/dist/components/ShowPage.svelte +9 -0
  24. package/dist/components/Sidebar.svelte +16 -9
  25. package/dist/components/SidebarItem.svelte +2 -2
  26. package/dist/components/SmartSuggest.svelte +12 -2
  27. package/dist/components/StepsForm.svelte +22 -26
  28. package/dist/components/TenantSwitcher.svelte +2 -2
  29. package/dist/components/VirtualTable.svelte +8 -6
  30. package/dist/components/VoiceInput.svelte +12 -0
  31. package/dist/components/ui/dialog/dialog-content.svelte +1 -1
  32. package/dist/components/ui/dialog/dialog-overlay.svelte +1 -1
  33. package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +2 -1
  34. package/dist/router-state.svelte.js +5 -3
  35. package/package.json +2 -2
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,28 +89,16 @@
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
- if (authProvider) 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
- if (authProvider) setAuthProvider(authProvider);
101
+ setAuthProvider(authProvider);
103
102
  setResources(resources);
104
103
  setRouterProvider(resolvedRouter);
105
104
  if (locale) setLocale(locale);
@@ -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 */
@@ -14,6 +14,7 @@
14
14
 
15
15
  let displayValue = $state('0');
16
16
  let prevTarget = 0;
17
+ let currentRaf: number | null = null;
17
18
 
18
19
  $effect(() => {
19
20
  const target = typeof value === 'number' ? value : parseInt(value, 10);
@@ -35,11 +36,16 @@
35
36
  displayValue = current.toLocaleString();
36
37
 
37
38
  if (progress < 1) {
38
- requestAnimationFrame(tick);
39
+ currentRaf = requestAnimationFrame(tick);
39
40
  }
40
41
  }
41
42
 
42
- requestAnimationFrame(tick);
43
+ if (currentRaf) cancelAnimationFrame(currentRaf);
44
+ currentRaf = requestAnimationFrame(tick);
45
+
46
+ return () => {
47
+ if (currentRaf) cancelAnimationFrame(currentRaf);
48
+ };
43
49
  });
44
50
  </script>
45
51
 
@@ -27,7 +27,11 @@
27
27
  }: Props = $props();
28
28
 
29
29
  // Calculate percentage difference
30
- const diff = $derived(baseline === 0 ? 0 : (value - baseline) / Math.abs(baseline));
30
+ const diff = $derived(
31
+ baseline === 0
32
+ ? (value === 0 ? 0 : (value > 0 ? Infinity : -Infinity))
33
+ : (value - baseline) / Math.abs(baseline)
34
+ );
31
35
  const isAnomaly = $derived(Math.abs(diff) >= threshold);
32
36
 
33
37
  // Determine sentiment (good, bad, neutral)
@@ -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;
@@ -9,9 +9,9 @@
9
9
  type ColumnVisibilityState,
10
10
  type ExpandedState,
11
11
  } from '@tanstack/svelte-table';
12
- import { createCoreRowModel } from '@tanstack/table-core';
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: () => '',
@@ -265,7 +278,7 @@
265
278
  const tbl = createTable({
266
279
  get data() { return query.data?.data ?? []; },
267
280
  get columns() { return columns; },
268
- getCoreRowModel: createCoreRowModel(),
281
+ getCoreRowModel: getCoreRowModel(),
269
282
  manualPagination: true,
270
283
  manualSorting: true,
271
284
  getRowId: (row: any) => String(row[primaryKey]),
@@ -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()}
@@ -26,9 +26,6 @@
26
26
  if (resourceNames.includes(seg)) {
27
27
  const res = resources.find(r => r.name === seg)!;
28
28
  result.push({ label: res.label, href: formatLink(currentPath) });
29
- } else if (i > 0 && resourceNames.includes(segments[i-1])) {
30
- // This is a parent ID (e.g. /teams/123/users)
31
- result.push({ label: `#${seg}`, href: formatLink(currentPath) });
32
29
  } else if (seg === 'create') {
33
30
  result.push({ label: t('common.create') ?? 'Create', href: formatLink(currentPath) });
34
31
  } else if (['edit', 'show', 'clone'].includes(seg) && segments[i+1]) {
@@ -39,6 +36,9 @@
39
36
  currentPath += `/${id}`; // advance path by id
40
37
  result.push({ label: `${actionLabel} #${id}`, href: formatLink(currentPath) });
41
38
  i++; // skip next segment since we consumed the id
39
+ } else if (i > 0 && resourceNames.includes(segments[i-1])) {
40
+ // This is a parent ID (e.g. /teams/123/users) and we are not an action
41
+ result.push({ label: `#${seg}`, href: formatLink(currentPath) });
42
42
  }
43
43
  }
44
44
 
@@ -53,6 +53,19 @@
53
53
  });
54
54
  });
55
55
 
56
+ // ─── Listen for cross-component triggers ─────────────────────
57
+ $effect(() => {
58
+ if (typeof window === 'undefined') return;
59
+ const handler = (e: CustomEvent<string>) => {
60
+ open = true;
61
+ minimized = false;
62
+ inputValue = e.detail;
63
+ sendMessage();
64
+ };
65
+ window.addEventListener('svadmin:ask-ai', handler as EventListener);
66
+ return () => window.removeEventListener('svadmin:ask-ai', handler as EventListener);
67
+ });
68
+
56
69
  // ─── Restore persisted history on mount ─────────────────────
57
70
  $effect(() => {
58
71
  if (initialized) return;
@@ -142,6 +155,12 @@
142
155
 
143
156
  messages = [...messages, userMsg];
144
157
  inputValue = '';
158
+ doSend();
159
+ }
160
+
161
+ async function doSend() {
162
+ if (isStreaming || (!provider && !agent)) return;
163
+
145
164
  scrollToBottom();
146
165
 
147
166
  // Create placeholder assistant message
@@ -200,6 +219,16 @@
200
219
  // Register in the core approval system
201
220
  const reqId = event.id;
202
221
  pendingApprovalIds = [...pendingApprovalIds, reqId];
222
+
223
+ registerApproval(reqId, (approved) => {
224
+ messages = [...messages, {
225
+ id: genId(),
226
+ role: 'user',
227
+ content: approved ? `User approved execution of tool '${event.tool}'` : `User rejected execution of tool '${event.tool}'`,
228
+ timestamp: Date.now()
229
+ }];
230
+ doSend();
231
+ });
203
232
 
204
233
  collectedActions.push(
205
234
  {
@@ -319,9 +348,18 @@
319
348
  }
320
349
  }
321
350
 
351
+ function escapeHtml(unsafe: string) {
352
+ return unsafe
353
+ .replace(/&/g, "&amp;")
354
+ .replace(/</g, "&lt;")
355
+ .replace(/>/g, "&gt;")
356
+ .replace(/"/g, "&quot;")
357
+ .replace(/'/g, "&#039;");
358
+ }
359
+
322
360
  /** Simple markdown→html: bold, italic, code blocks, inline code, line breaks */
323
361
  function renderMarkdown(text: string): string {
324
- return text
362
+ return escapeHtml(text)
325
363
  .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="chat-code-block"><code>$2</code></pre>')
326
364
  .replace(/`([^`]+)`/g, '<code class="chat-inline-code">$1</code>')
327
365
  .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
@@ -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() {
@@ -71,7 +71,7 @@
71
71
  {#if hasAI && searchValue.trim()}
72
72
  <Command.Group heading="AI">
73
73
  <Command.Item
74
- value="ask-ai-{searchValue}"
74
+ value={"ask-ai-" + searchValue}
75
75
  onSelect={askAI}
76
76
  class={itemClass}
77
77
  >
@@ -111,7 +111,7 @@
111
111
  <Command.Group heading={t('common.actions')}>
112
112
  {#each resources as r}
113
113
  <Command.Item
114
- value="create-{r.name}"
114
+ value={"create-" + r.name}
115
115
  onSelect={() => act(() => navigate(`/${r.name}/create`))}
116
116
  class={itemClass}
117
117
  >
@@ -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}
@@ -29,10 +29,9 @@
29
29
  let dragIndex = $state<number | null>(null);
30
30
  let dropIndex = $state<number | null>(null);
31
31
 
32
- // Restore saved order from localStorage (once on mount)
33
32
  let _restored = false;
34
33
  $effect(() => {
35
- if (_restored) return;
34
+ if (_restored || columns.length === 0) return;
36
35
  const storageKey = `svadmin-colorder-${resourceName}`;
37
36
  if (typeof window === 'undefined') return;
38
37
  _restored = true;