@svadmin/ui 0.31.0 → 0.31.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.
@@ -89,7 +89,7 @@
89
89
 
90
90
  // Set up initial context synchronously so children can access resources immediately during first render
91
91
  setDataProvider(dataProvider);
92
- if (authProvider) setAuthProvider(authProvider);
92
+ setAuthProvider(authProvider);
93
93
  setResources(resources);
94
94
  setRouterProvider(resolvedRouter);
95
95
  if (locale) setLocale(locale);
@@ -99,7 +99,7 @@
99
99
  // Set up context — use $effect so prop changes are tracked
100
100
  $effect.pre(() => {
101
101
  setDataProvider(dataProvider);
102
- if (authProvider) setAuthProvider(authProvider);
102
+ setAuthProvider(authProvider);
103
103
  setResources(resources);
104
104
  setRouterProvider(resolvedRouter);
105
105
  if (locale) setLocale(locale);
@@ -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)
@@ -9,7 +9,7 @@
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
14
  import { useList, useDelete, getResource } from '@svadmin/core';
15
15
  import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
@@ -265,7 +265,7 @@
265
265
  const tbl = createTable({
266
266
  get data() { return query.data?.data ?? []; },
267
267
  get columns() { return columns; },
268
- getCoreRowModel: createCoreRowModel(),
268
+ getCoreRowModel: getCoreRowModel(),
269
269
  manualPagination: true,
270
270
  manualSorting: true,
271
271
  getRowId: (row: any) => String(row[primaryKey]),
@@ -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>')
@@ -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
  >
@@ -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;
@@ -12,7 +12,7 @@
12
12
  import { getAuthProvider } from '@svadmin/core';
13
13
  import type { Identity, MenuItem } from '@svadmin/core';
14
14
  import { navigate } from '@svadmin/core/router';
15
- import { currentPath } from '@svadmin/core/router';
15
+ import { getPath } from '../router-state.svelte.js';
16
16
  import { Skeleton } from './ui/skeleton/index.js';
17
17
  import { Button } from './ui/button/index.js';
18
18
  import * as Sheet from './ui/sheet/index.js';
@@ -150,7 +150,7 @@
150
150
 
151
151
  <!-- Content area -->
152
152
  <main class="flex-1 overflow-y-auto p-6 md:p-8">
153
- {#key currentPath()}
153
+ {#key getPath()}
154
154
  <div in:fly={{ x: 20, duration: 150 }} out:fade={{ duration: 80 }}>
155
155
  {@render children()}
156
156
  </div>
@@ -158,7 +158,14 @@
158
158
  </main>
159
159
  </div>
160
160
  </div>
161
- <CommandPalette bind:open={commandOpen} />
161
+ <CommandPalette
162
+ bind:open={commandOpen}
163
+ onAskAI={(q) => {
164
+ if (typeof window !== 'undefined') {
165
+ window.dispatchEvent(new CustomEvent('svadmin:ask-ai', { detail: q }));
166
+ }
167
+ }}
168
+ />
162
169
  <KeyboardShortcuts bind:open={shortcutsOpen} />
163
170
  <ChatDialog />
164
171
  <Toast />
@@ -23,9 +23,15 @@
23
23
  type StatusKey = 'connected' | 'connecting' | 'disconnected';
24
24
  const cfg = $derived(statusConfigs[status as StatusKey]);
25
25
 
26
+ let now = $state(Date.now());
27
+ $effect(() => {
28
+ const itv = setInterval(() => { now = Date.now(); }, 1000);
29
+ return () => clearInterval(itv);
30
+ });
31
+
26
32
  const timeSinceEvent = $derived(
27
33
  lastEvent?.timestamp
28
- ? Math.floor((Date.now() - lastEvent.timestamp) / 1000)
34
+ ? Math.floor((now - lastEvent.timestamp) / 1000)
29
35
  : null
30
36
  );
31
37
  </script>
@@ -49,10 +49,20 @@
49
49
  return '';
50
50
  });
51
51
 
52
+ import { getChatProvider } from '@svadmin/core';
53
+
52
54
  const suffixText = $derived(ghostText ? ghostText.slice(value.length) : '');
53
55
 
56
+ const resolvedProvider = $derived(provider ?? (() => {
57
+ try {
58
+ return getChatProvider();
59
+ } catch {
60
+ return null;
61
+ }
62
+ })());
63
+
54
64
  async function predict() {
55
- if (!provider || !value.trim()) {
65
+ if (!resolvedProvider || !value.trim()) {
56
66
  suggestion = '';
57
67
  return;
58
68
  }
@@ -70,7 +80,7 @@ Provide ONLY the most likely completion of the exact phrase the user is typing.
70
80
  Include the user's input in your returned string.
71
81
  Do not include any other text, Markdown formatting, quotes, or explanations.`;
72
82
 
73
- const result = provider.sendMessage(
83
+ const result = resolvedProvider.sendMessage(
74
84
  [{ id: 'prompt', role: 'user', content: prompt, timestamp: Date.now() }],
75
85
  { signal: abortController.signal }
76
86
  );
@@ -67,12 +67,12 @@
67
67
  });
68
68
 
69
69
  const totalSteps = $derived(steps.length);
70
- const progressValue = $derived(((form.currentStep + 1) / totalSteps) * 100);
71
- const isLastStep = $derived(form.currentStep >= totalSteps - 1);
70
+ const progressValue = $derived(((form.steps.currentStep + 1) / totalSteps) * 100);
71
+ const isLastStep = $derived(form.steps.currentStep >= totalSteps - 1);
72
72
 
73
73
  // Fields for the current step
74
74
  const currentFields = $derived(
75
- steps[form.currentStep]?.fields
75
+ steps[form.steps.currentStep]?.fields
76
76
  .map(key => resource.fields.find(f => f.key === key))
77
77
  .filter((f): f is FieldDefinition => f != null && f.key !== primaryKey) ?? []
78
78
  );
@@ -81,15 +81,11 @@
81
81
  function validateCurrentStep(): boolean {
82
82
  let valid = true;
83
83
  form.clearErrors();
84
- for (const field of currentFields) {
85
- const value = form.values[field.key];
86
- if (field.required && (value === undefined || value === null || value === '')) {
87
- form.setFieldError(field.key, t('validation.required'));
88
- valid = false;
89
- } else if (field.validate) {
90
- const msg = field.validate(value);
91
- if (msg) {
92
- form.setFieldError(field.key, msg);
84
+ const errors = validator(form.values);
85
+ if (errors) {
86
+ for (const field of currentFields) {
87
+ if (errors[field.key]) {
88
+ form.setFieldError(field.key, errors[field.key]);
93
89
  valid = false;
94
90
  }
95
91
  }
@@ -101,7 +97,7 @@
101
97
  if (!validateCurrentStep()) return;
102
98
 
103
99
  if (!isLastStep) {
104
- form.next();
100
+ form.steps.nextStep();
105
101
  return;
106
102
  }
107
103
 
@@ -118,17 +114,17 @@
118
114
  <Button
119
115
  variant="ghost"
120
116
  type="button"
121
- class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === form.currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
122
- onclick={() => form.gotoStep(i)}
117
+ class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === form.steps.currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
118
+ onclick={() => form.steps.gotoStep(i)}
123
119
  >
124
120
  <span
125
121
  class="flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium transition-colors"
126
- class:bg-primary={i <= form.currentStep}
127
- class:text-primary-foreground={i <= form.currentStep}
128
- class:bg-muted={i > form.currentStep}
129
- class:text-muted-foreground={i > form.currentStep}
122
+ class:bg-primary={i <= form.steps.currentStep}
123
+ class:text-primary-foreground={i <= form.steps.currentStep}
124
+ class:bg-muted={i > form.steps.currentStep}
125
+ class:text-muted-foreground={i > form.steps.currentStep}
130
126
  >
131
- {#if i < form.currentStep}
127
+ {#if i < form.steps.currentStep}
132
128
  <Check class="h-3.5 w-3.5" />
133
129
  {:else}
134
130
  {i + 1}
@@ -137,7 +133,7 @@
137
133
  <span class="hidden sm:inline">{step.title}</span>
138
134
  </Button>
139
135
  {#if i < steps.length - 1}
140
- <div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < form.currentStep}></div>
136
+ <div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < form.steps.currentStep}></div>
141
137
  {/if}
142
138
  {/each}
143
139
  </div>
@@ -147,13 +143,13 @@
147
143
  <!-- Step content -->
148
144
  <Card.Root>
149
145
  <Card.CardHeader>
150
- <Card.CardTitle>{steps[form.currentStep]?.title ?? ''}</Card.CardTitle>
146
+ <Card.CardTitle>{steps[form.steps.currentStep]?.title ?? ''}</Card.CardTitle>
151
147
  <Badge variant="outline" class="w-fit">
152
- {t('common.step')} {form.currentStep + 1} / {totalSteps}
148
+ {t('common.step')} {form.steps.currentStep + 1} / {totalSteps}
153
149
  </Badge>
154
150
  </Card.CardHeader>
155
151
  <Card.CardContent>
156
- {#key form.currentStep}
152
+ {#key form.steps.currentStep}
157
153
  <form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} in:fly={{ x: 30, duration: 250 }}>
158
154
  {#each currentFields as field (field.key)}
159
155
  <FieldRenderer
@@ -170,10 +166,10 @@
170
166
  <Button
171
167
  type="button"
172
168
  variant="outline"
173
- onclick={() => form.currentStep === 0 ? navigate(`/${resourceName}`) : form.prev()}
169
+ onclick={() => form.steps.currentStep === 0 ? navigate(`/${resourceName}`) : form.steps.prevStep()}
174
170
  >
175
171
  <ArrowLeft class="h-4 w-4" data-icon="inline-start" />
176
- {form.currentStep === 0 ? t('common.cancel') : t('common.back')}
172
+ {form.steps.currentStep === 0 ? t('common.cancel') : t('common.back')}
177
173
  </Button>
178
174
 
179
175
  <Button type="submit" disabled={form.submitting}>
@@ -66,11 +66,13 @@
66
66
  style="height: {containerHeight}px;"
67
67
  onscroll={handleScroll}
68
68
  >
69
- <div style="position: relative; height: {totalHeight}px; width: 100%;">
70
- {#each visibleItems as { item, index, offsetY } (index)}
71
- <div style="position: absolute; top: {offsetY}px; left: 0; right: 0; height: {rowHeight}px;">
69
+ <table class="w-full relative" style="border-spacing: 0;">
70
+ <tbody style="display: block;">
71
+ <tr style="height: {visibleRange.start * rowHeight}px; border: none; padding: 0; display: block;"></tr>
72
+ {#each visibleItems as { item, index } (index)}
72
73
  {@render row(item, index)}
73
- </div>
74
- {/each}
75
- </div>
74
+ {/each}
75
+ <tr style="height: {Math.max(0, (items.length - visibleRange.end - 1) * rowHeight)}px; border: none; padding: 0; display: block;"></tr>
76
+ </tbody>
77
+ </table>
76
78
  </div>
@@ -43,11 +43,13 @@ function sync() {
43
43
  }
44
44
  }
45
45
  export function initRouter(provider) {
46
- if (_initialized)
47
- return;
48
- _initialized = true;
49
46
  _provider = provider;
50
47
  setActiveRouterProvider(provider);
48
+ if (_initialized) {
49
+ sync(); // Re-sync manually since provider might have switched format/URLs
50
+ return;
51
+ }
52
+ _initialized = true;
51
53
  sync();
52
54
  if (typeof window !== 'undefined') {
53
55
  window.addEventListener('hashchange', sync);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.31.0",
3
+ "version": "0.31.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.0",
28
+ "@svadmin/core": "^0.22.1",
29
29
  "@tanstack/svelte-query": "^6.0.0",
30
30
  "highlight.js": "^11.11.1",
31
31
  "isomorphic-dompurify": "^3.7.1",