@svadmin/ui 0.32.1 → 0.32.3

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.
@@ -91,8 +91,8 @@
91
91
 
92
92
  setComponentRegistry({ ...defaultComponents, ...userComponents });
93
93
 
94
- // Resolve router provider (default to hash)
95
- const resolvedRouter = $derived(routerProvider ?? createHashRouterProvider());
94
+ const defaultRouter = createHashRouterProvider();
95
+ const resolvedRouter = $derived(routerProvider ?? defaultRouter);
96
96
  const resolvedRouteMode = $derived(routeMode ?? (routerProvider ? 'auto' : 'hash'));
97
97
 
98
98
  // Set up context — use $effect.pre so prop changes are tracked and children can access during first render
@@ -23,7 +23,7 @@
23
23
  let drawerOpen = $state(false);
24
24
  let drawerLog = $state<AuditLog | null>(null);
25
25
 
26
- let cancelled = $state(false);
26
+ let requestId = 0;
27
27
 
28
28
  async function loadLogs() {
29
29
  if (!authProvider?.getAuditLogs) {
@@ -31,27 +31,25 @@
31
31
  loading = false;
32
32
  return;
33
33
  }
34
+ const currentId = ++requestId;
34
35
  loading = true;
35
36
  error = null;
36
37
  try {
37
38
  const result = await authProvider.getAuditLogs({ page, pageSize });
38
- if (cancelled) return;
39
+ if (currentId !== requestId) return;
39
40
  logs = result.data;
40
41
  total = result.total;
41
42
  } catch (e) {
42
- if (cancelled) return;
43
+ if (currentId !== requestId) return;
43
44
  toast.error((e as Error).message);
44
45
  } finally {
45
- if (!cancelled) loading = false;
46
+ if (currentId === requestId) loading = false;
46
47
  }
47
48
  }
48
49
 
49
50
  $effect(() => {
50
51
  void page;
51
- cancelled = true;
52
- cancelled = false;
53
52
  loadLogs();
54
- return () => { cancelled = true; };
55
53
  });
56
54
 
57
55
  let totalPages = $derived(Math.ceil(total / pageSize) || 1);
@@ -22,9 +22,10 @@
22
22
  fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
23
23
  formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
24
24
  headerContent?: Snippet;
25
+ onSuccess?: () => void;
25
26
  }
26
27
 
27
- let { resourceName, id, mode = 'create', fieldRenderer, formActions, headerContent }: Props = $props();
28
+ let { resourceName, id, mode = 'create', fieldRenderer, formActions, headerContent, onSuccess }: Props = $props();
28
29
  const isReadonly = $derived(mode === 'show');
29
30
 
30
31
  // ─── Resource metadata ────────────────────────────────────────────
@@ -104,6 +105,7 @@
104
105
  submitError = null;
105
106
  try {
106
107
  await form.submit();
108
+ onSuccess?.();
107
109
  } catch (e) {
108
110
  submitError = e instanceof Error ? e.message : t('common.operationFailed');
109
111
  }
@@ -14,6 +14,7 @@ interface Props {
14
14
  onSubmit: () => void;
15
15
  }]>;
16
16
  headerContent?: Snippet;
17
+ onSuccess?: () => void;
17
18
  }
18
19
  declare const AutoForm: import("svelte").Component<Props, {}, "">;
19
20
  type AutoForm = ReturnType<typeof AutoForm>;
@@ -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, notify } from '@svadmin/core';
14
+ import { useList, useDelete, useDeleteMany, 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';
@@ -170,6 +170,8 @@
170
170
  const query = listResult;
171
171
  const deleteResult = useDelete({ get resource() { return resourceName; } });
172
172
  const deleteMutation = deleteResult.mutation;
173
+ const deleteManyResult = useDeleteMany({ get resource() { return resourceName; } });
174
+ const deleteManyMutation = deleteManyResult.mutation;
173
175
 
174
176
  // ─── Permissions ──────────────────────────────────────────────
175
177
  const acEnabled = $derived(!!getAccessControlProvider());
@@ -347,13 +349,11 @@
347
349
  const ids = Object.keys(rowSelection);
348
350
  confirmMessage = t('common.batchDeleteConfirm', { count: ids.length });
349
351
  confirmAction = async () => {
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 }) });
352
+ try {
353
+ await deleteManyMutation.mutateAsync({ ids, resource: resourceName, successNotification: false });
354
+ notify({ type: 'success', message: t('common.batchDeleteSuccess', { count: ids.length }) });
355
+ } catch {
356
+ notify({ type: 'error', message: t('common.batchDeletePartialFail', { failed: 1, total: ids.length }) });
357
357
  }
358
358
  rowSelection = {};
359
359
  confirmOpen = false;
@@ -374,13 +374,13 @@
374
374
  return String(val);
375
375
  })
376
376
  );
377
- const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${v.replace(new RegExp('"', 'g'), '""')}"`).join(','))].join('\n');
377
+ const csv = [headers.map(h => `"${h.replace(/"/g, '""')}"`).join(','), ...rows.map(r => r.map(v => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
378
378
  const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
379
379
  const link = document.createElement('a');
380
380
  link.href = URL.createObjectURL(blob);
381
381
  link.download = `${resourceName}_${new Date().toISOString().slice(0, 10)}.csv`;
382
382
  link.click();
383
- URL.revokeObjectURL(link.href);
383
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
384
384
  }
385
385
 
386
386
  function goToPage(page: number) {
@@ -5,7 +5,7 @@
5
5
  import { t } from '@svadmin/core/i18n';
6
6
  import * as Breadcrumb from './ui/breadcrumb/index.js';
7
7
 
8
- const resources = getResources();
8
+ const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
9
9
 
10
10
  interface Crumb { label: string; href: string; }
11
11
 
@@ -89,26 +89,45 @@
89
89
 
90
90
  // ─── Persist on change (debounced) ──────────────────────────
91
91
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
92
+ let lastPersistSnapshot: ChatMessage[] | null = null;
93
+
94
+ function flushPersist(snapshot: ChatMessage[]) {
95
+ if (onPersist) {
96
+ onPersist(snapshot);
97
+ } else if (persistKey) {
98
+ try {
99
+ localStorage.setItem(persistKey, JSON.stringify(snapshot));
100
+ } catch {
101
+ // storage full or unavailable
102
+ }
103
+ }
104
+ }
92
105
 
93
106
  $effect(() => {
94
- // Access `messages` to subscribe to changes
95
107
  const snapshot = messages;
96
108
  if (!initialized) return;
97
109
 
98
110
  if (persistTimer) clearTimeout(persistTimer);
111
+ lastPersistSnapshot = snapshot;
99
112
  persistTimer = setTimeout(() => {
100
- if (onPersist) {
101
- onPersist(snapshot);
102
- } else if (persistKey) {
103
- try {
104
- localStorage.setItem(persistKey, JSON.stringify(snapshot));
105
- } catch {
106
- // storage full or unavailable
107
- }
108
- }
113
+ persistTimer = null;
114
+ flushPersist(snapshot);
109
115
  }, 300);
110
116
 
111
- return () => { if (persistTimer) clearTimeout(persistTimer); };
117
+ return () => {
118
+ if (persistTimer) {
119
+ clearTimeout(persistTimer);
120
+ persistTimer = null;
121
+ }
122
+ };
123
+ });
124
+
125
+ $effect(() => {
126
+ return () => {
127
+ if (lastPersistSnapshot != null) {
128
+ flushPersist(lastPersistSnapshot);
129
+ }
130
+ };
112
131
  });
113
132
 
114
133
  function genId(): string {
@@ -192,6 +211,7 @@
192
211
  const collectedActions: ChatAction[] = [];
193
212
 
194
213
  for await (const event of stream) {
214
+ if (!isStreaming) break;
195
215
  switch (event.type) {
196
216
  case 'text':
197
217
  assistantMsg.content += event.content;
@@ -216,7 +236,6 @@
216
236
  break;
217
237
 
218
238
  case 'approval_request': {
219
- // Register in the core approval system
220
239
  const reqId = event.id;
221
240
  pendingApprovalIds = [...pendingApprovalIds, reqId];
222
241
 
@@ -227,7 +246,9 @@
227
246
  content: approved ? `User approved execution of tool '${event.tool}'` : `User rejected execution of tool '${event.tool}'`,
228
247
  timestamp: Date.now()
229
248
  }];
230
- doSend();
249
+ if (agent?.approveToolCall) {
250
+ agent.approveToolCall(reqId, approved);
251
+ }
231
252
  });
232
253
 
233
254
  collectedActions.push(
@@ -269,6 +290,7 @@
269
290
 
270
291
  if (result && typeof result === 'object' && Symbol.asyncIterator in result) {
271
292
  for await (const chunk of result as AsyncGenerator<string>) {
293
+ if (!isStreaming) break;
272
294
  assistantMsg.content += chunk;
273
295
  messages = [...messages.slice(0, -1), { ...assistantMsg }];
274
296
  scrollToBottom();
@@ -359,12 +381,22 @@
359
381
 
360
382
  /** Simple markdown→html: bold, italic, code blocks, inline code, line breaks */
361
383
  function renderMarkdown(text: string): string {
362
- return escapeHtml(text)
363
- .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="chat-code-block"><code>$2</code></pre>')
384
+ const escaped = escapeHtml(text);
385
+ const codeBlocks: string[] = [];
386
+ let processed = escaped.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
387
+ const idx = codeBlocks.length;
388
+ codeBlocks.push(`<pre class="chat-code-block"><code>${code}</code></pre>`);
389
+ return `\x00CODEBLOCK${idx}\x00`;
390
+ });
391
+ processed = processed
364
392
  .replace(/`([^`]+)`/g, '<code class="chat-inline-code">$1</code>')
365
393
  .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
366
394
  .replace(/\*(.+?)\*/g, '<em>$1</em>')
367
395
  .replace(/\n/g, '<br>');
396
+ for (let i = 0; i < codeBlocks.length; i++) {
397
+ processed = processed.replace(`\x00CODEBLOCK${i}\x00`, codeBlocks[i]);
398
+ }
399
+ return processed;
368
400
  }
369
401
 
370
402
  const suggestions = $derived([
@@ -9,6 +9,14 @@
9
9
  import { Skeleton } from './ui/skeleton/index.js';
10
10
  import { ChevronsUpDown, Check, X, Search } from '@lucide/svelte';
11
11
 
12
+ function clickOutside(node: HTMLElement) {
13
+ const handler = (e: MouseEvent) => {
14
+ if (!node.contains(e.target as Node)) open = false;
15
+ };
16
+ document.addEventListener('click', handler, true);
17
+ return { destroy() { document.removeEventListener('click', handler, true); } };
18
+ }
19
+
12
20
  interface Props {
13
21
  resource: string;
14
22
  value?: string | number | null;
@@ -60,6 +68,8 @@
60
68
  function handleClear(e: Event) {
61
69
  e.stopPropagation();
62
70
  onchange?.(null);
71
+ open = false;
72
+ searchInputValue = '';
63
73
  }
64
74
 
65
75
  function handleSearchInput(e: Event) {
@@ -69,7 +79,7 @@
69
79
  }
70
80
  </script>
71
81
 
72
- <div class="relative">
82
+ <div class="relative" use:clickOutside>
73
83
  <Button
74
84
  variant="outline"
75
85
  type="button"
@@ -50,7 +50,7 @@
50
50
  <AlertDialog.Footer>
51
51
  <AlertDialog.Cancel>
52
52
  {#snippet child({ props })}
53
- <Button variant="outline" {...props} onclick={oncancel}>
53
+ <Button variant="outline" {...props}>
54
54
  {resolvedCancelText}
55
55
  </Button>
56
56
  {/snippet}
@@ -15,7 +15,7 @@
15
15
 
16
16
  const resource = $derived(getResource(resourceName));
17
17
 
18
- function handleClose() {
18
+ function handleSuccess() {
19
19
  open = false;
20
20
  onSuccess?.();
21
21
  }
@@ -29,7 +29,7 @@
29
29
  </Sheet.Title>
30
30
  </Sheet.Header>
31
31
  <div class="flex-1 overflow-y-auto">
32
- <AutoForm {resourceName} {mode} {id} />
32
+ <AutoForm {resourceName} {mode} {id} onSuccess={handleSuccess} />
33
33
  </div>
34
34
  </Sheet.Content>
35
35
  </Sheet.Root>
@@ -30,11 +30,18 @@
30
30
 
31
31
  // Typed accessors
32
32
  const strVal = $derived((value as string) ?? '');
33
- const numVal = $derived((value as number) ?? 0);
33
+ const numVal = $derived(value as number | null | undefined);
34
34
  const boolVal = $derived((value as boolean) ?? false);
35
35
  const tagsVal = $derived((value as string[]) ?? []);
36
36
  const multiVal = $derived((value as (string | number)[]) ?? []);
37
37
  const imagesVal = $derived((value as string[]) ?? []);
38
+ let jsonEditText = $state('');
39
+ let jsonEditing = false;
40
+
41
+ $effect(() => {
42
+ if (jsonEditing) return;
43
+ jsonEditText = typeof value === 'string' ? String(value) : JSON.stringify(value, null, 2);
44
+ });
38
45
 
39
46
  function handleTagKeydown(e: KeyboardEvent) {
40
47
  if (e.key === 'Enter') {
@@ -165,7 +172,7 @@
165
172
  <Input
166
173
  id={field.key}
167
174
  type="number"
168
- value={String(numVal)}
175
+ value={numVal == null ? '' : String(numVal)}
169
176
  oninput={(e) => {
170
177
  const v = (e.target as HTMLInputElement).value;
171
178
  onchange(v === '' ? null : Number(v));
@@ -359,10 +366,17 @@
359
366
  {:else if field.type === 'json'}
360
367
  <Textarea
361
368
  id={field.key}
362
- value={typeof value === 'string' ? strVal : JSON.stringify(value, null, 2)}
369
+ value={jsonEditText}
370
+ onfocus={() => { jsonEditing = true; }}
371
+ onblur={() => {
372
+ jsonEditing = false;
373
+ jsonEditText = typeof value === 'string' ? String(value) : JSON.stringify(value, null, 2);
374
+ }}
363
375
  oninput={(e) => {
376
+ const raw = (e.target as HTMLTextAreaElement).value;
377
+ jsonEditText = raw;
364
378
  try {
365
- onchange(JSON.parse((e.target as HTMLTextAreaElement).value));
379
+ onchange(JSON.parse(raw));
366
380
  } catch {
367
381
  // keep raw text until valid JSON
368
382
  }
@@ -17,6 +17,7 @@
17
17
  let editValue = $state('');
18
18
  let inputRef = $state<HTMLInputElement | null>(null);
19
19
  let saving = $state(false);
20
+ let savePending = false;
20
21
  const displayValue = $derived(String(value ?? ''));
21
22
 
22
23
  const { mutation } = useUpdate({ get resource() { return resourceName; } });
@@ -31,12 +32,13 @@
31
32
  }
32
33
 
33
34
  async function save() {
34
- if (!editing || saving) return;
35
- const newValue = field.type === 'number' ? Number(editValue) : editValue;
35
+ if (!editing || saving || savePending) return;
36
+ const newValue = field.type === 'number' ? (editValue.trim() === '' ? null : (() => { const n = Number(editValue); return isNaN(n) ? value : n; })()) : editValue;
36
37
  if (newValue === value) {
37
38
  editing = false;
38
39
  return;
39
40
  }
41
+ savePending = true;
40
42
  saving = true;
41
43
  try {
42
44
  await mutation.mutateAsync({
@@ -50,6 +52,7 @@
50
52
  // Keep editing on error
51
53
  } finally {
52
54
  saving = false;
55
+ savePending = false;
53
56
  }
54
57
  }
55
58
 
@@ -59,9 +62,16 @@
59
62
  }
60
63
 
61
64
  function handleKeydown(e: KeyboardEvent) {
62
- if (e.key === 'Enter') save();
65
+ if (e.key === 'Enter') {
66
+ e.preventDefault();
67
+ save();
68
+ }
63
69
  if (e.key === 'Escape') cancel();
64
70
  }
71
+
72
+ function handleBlur() {
73
+ if (!savePending) save();
74
+ }
65
75
  </script>
66
76
 
67
77
  {#if editing}
@@ -70,7 +80,7 @@
70
80
  type={field.type === 'number' ? 'number' : 'text'}
71
81
  bind:value={editValue}
72
82
  onkeydown={handleKeydown}
73
- onblur={save}
83
+ onblur={handleBlur}
74
84
  disabled={saving}
75
85
  class="h-7 w-full rounded border bg-background px-2 text-sm outline-none ring-1 ring-primary/50 focus:ring-2 focus:ring-primary transition-all"
76
86
  />
@@ -52,9 +52,13 @@
52
52
 
53
53
  async function handleLogout() {
54
54
  if (!auth) return;
55
- const result = await auth.logout();
56
- if (result.success) {
57
- navigate(result.redirectTo ?? '/login');
55
+ try {
56
+ const result = await auth.logout();
57
+ if (result.success) {
58
+ navigate(result.redirectTo ?? '/login');
59
+ }
60
+ } catch {
61
+ navigate('/login');
58
62
  }
59
63
  }
60
64
 
@@ -18,7 +18,7 @@
18
18
  const fieldCount = $derived(resource.fields.filter(f => f.showInForm !== false).length);
19
19
  const dialogWidth = $derived(fieldCount > 8 ? 'sm:max-w-3xl' : fieldCount > 5 ? 'sm:max-w-2xl' : 'sm:max-w-lg');
20
20
 
21
- function handleClose() {
21
+ function handleSuccess() {
22
22
  open = false;
23
23
  onSuccess?.();
24
24
  }
@@ -32,7 +32,7 @@
32
32
  {mode === 'create' ? `${t('common.create')}${resource.label}` : `${t('common.edit')}${resource.label}`}
33
33
  </Dialog.DialogTitle>
34
34
  </Dialog.DialogHeader>
35
- <AutoForm {resourceName} {mode} {id} />
35
+ <AutoForm {resourceName} {mode} {id} onSuccess={handleSuccess} />
36
36
  </Dialog.DialogContent>
37
37
  </Dialog.Dialog>
38
38
  {/if}
@@ -39,8 +39,7 @@
39
39
  function isActive(itemHref: string | undefined): boolean {
40
40
  if (!itemHref) return false;
41
41
  if (itemHref === '/') return currentPath === '/';
42
- // Remove hash/leading slash for comparison if necessary, but currentPath usually starts with /
43
- return currentPath.startsWith(itemHref);
42
+ return currentPath.startsWith(itemHref) && (currentPath.length === itemHref.length || currentPath[itemHref.length] === '/');
44
43
  }
45
44
 
46
45
  /** Check if any child (recursively) is active — used to auto-open parent groups */
@@ -61,6 +61,7 @@
61
61
  get action() { return mode; },
62
62
  get id() { return id; },
63
63
  get defaultValues() { return defaults; },
64
+ stepsCount: steps.length,
64
65
  redirect: 'list',
65
66
  warnWhenUnsavedChanges: true,
66
67
  get validate() { return validator; },
@@ -12,18 +12,16 @@
12
12
  onTimeout: () => void;
13
13
  }>();
14
14
 
15
- let remaining = $state(0);
15
+ let remaining = $state(duration);
16
16
  let dismissed = $state(false);
17
17
 
18
- // Initialize remaining from prop
19
- $effect.pre(() => {
20
- remaining = duration;
21
- });
22
-
23
18
  $effect(() => {
19
+ const startTime = Date.now();
20
+ const totalMs = duration;
24
21
  const interval = setInterval(() => {
25
- remaining -= 100;
22
+ remaining = totalMs - (Date.now() - startTime);
26
23
  if (remaining <= 0) {
24
+ remaining = 0;
27
25
  clearInterval(interval);
28
26
  if (!dismissed) {
29
27
  dismissed = true;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { ExternalLink } from '@lucide/svelte';
3
+ import { getRouterProvider } from '@svadmin/core';
3
4
 
4
5
  let { value, resourceName, displayValue } = $props<{
5
6
  value: string | number | null | undefined;
@@ -8,7 +9,12 @@
8
9
  }>();
9
10
 
10
11
  const display = $derived(displayValue ?? (value != null ? `#${value}` : '—'));
11
- const href = $derived(resourceName && value != null ? `#/${resourceName}/show/${value}` : undefined);
12
+ const href = $derived.by(() => {
13
+ if (!resourceName || value == null) return undefined;
14
+ const router = getRouterProvider();
15
+ const path = `/${resourceName}/show/${value}`;
16
+ return router?.formatLink?.(path) ?? path;
17
+ });
12
18
  </script>
13
19
 
14
20
  {#if href}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.32.1",
3
+ "version": "0.32.3",
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.23.1",
28
+ "@svadmin/core": "^0.23.3",
29
29
  "@tanstack/svelte-query": "^6.0.0",
30
30
  "highlight.js": "^11.11.1",
31
31
  "isomorphic-dompurify": "^3.7.1",
@@ -48,6 +48,15 @@
48
48
  },
49
49
  "@svadmin/editor": {
50
50
  "optional": true
51
+ },
52
+ "@svadmin/core": {
53
+ "optional": true
54
+ },
55
+ "@tanstack/svelte-query": {
56
+ "optional": true
57
+ },
58
+ "svelte": {
59
+ "optional": true
51
60
  }
52
61
  },
53
62
  "dependencies": {