@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.
- package/dist/app.css +7 -6
- package/dist/components/AdminApp.svelte +19 -24
- package/dist/components/AdminApp.svelte.d.ts +3 -1
- package/dist/components/AnimatedCounter.svelte +8 -2
- package/dist/components/AnomalyBadge.svelte +5 -1
- package/dist/components/AuditLogViewer.svelte +9 -2
- package/dist/components/AutoForm.svelte +29 -8
- package/dist/components/AutoForm.svelte.d.ts +1 -1
- package/dist/components/AutoTable.svelte +35 -15
- package/dist/components/Breadcrumbs.svelte +3 -3
- package/dist/components/ChatDialog.svelte +39 -1
- package/dist/components/CommandPalette.svelte +3 -3
- package/dist/components/ConfirmDialog.svelte +16 -2
- package/dist/components/DraggableHeader.svelte +1 -2
- package/dist/components/FieldRenderer.svelte +37 -10
- package/dist/components/FieldRenderer.svelte.d.ts +1 -1
- package/dist/components/Header.svelte +5 -6
- package/dist/components/Layout.svelte +10 -5
- package/dist/components/LiveIndicator.svelte +7 -1
- package/dist/components/LoginPage.svelte +1 -1
- package/dist/components/NavigateToResource.svelte +4 -2
- package/dist/components/PermissionMatrix.svelte +6 -1
- package/dist/components/ShowPage.svelte +9 -0
- package/dist/components/Sidebar.svelte +16 -9
- package/dist/components/SidebarItem.svelte +2 -2
- package/dist/components/SmartSuggest.svelte +12 -2
- package/dist/components/StepsForm.svelte +22 -26
- package/dist/components/TenantSwitcher.svelte +2 -2
- package/dist/components/VirtualTable.svelte +8 -6
- package/dist/components/VoiceInput.svelte +12 -0
- package/dist/components/ui/dialog/dialog-content.svelte +1 -1
- package/dist/components/ui/dialog/dialog-overlay.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-content.svelte +2 -1
- package/dist/router-state.svelte.js +5 -3
- package/package.json +2 -2
package/dist/app.css
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
:root {
|
|
20
|
-
--radius: 0.
|
|
21
|
-
--background: oklch(0.
|
|
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
|
|
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.
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
<
|
|
157
|
-
<
|
|
158
|
-
|
|
159
|
-
|
|
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" />
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
type ColumnVisibilityState,
|
|
10
10
|
type ExpandedState,
|
|
11
11
|
} from '@tanstack/svelte-table';
|
|
12
|
-
import {
|
|
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:
|
|
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=""
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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/
|
|
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-
|
|
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, "&")
|
|
354
|
+
.replace(/</g, "<")
|
|
355
|
+
.replace(/>/g, ">")
|
|
356
|
+
.replace(/"/g, """)
|
|
357
|
+
.replace(/'/g, "'");
|
|
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-
|
|
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-
|
|
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={
|
|
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={
|
|
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;
|