@svadmin/ui 0.3.0 → 0.5.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 (110) hide show
  1. package/package.json +9 -4
  2. package/src/actions.ts +110 -0
  3. package/src/components/AdminApp.svelte +7 -4
  4. package/src/components/Authenticated.svelte +1 -7
  5. package/src/components/AutoForm.svelte +158 -69
  6. package/src/components/AutoSaveIndicator.svelte +19 -14
  7. package/src/components/AutoTable.svelte +465 -243
  8. package/src/components/Breadcrumbs.svelte +20 -17
  9. package/src/components/CanAccess.svelte +6 -4
  10. package/src/components/ComboboxField.svelte +122 -0
  11. package/src/components/CommandPalette.svelte +93 -0
  12. package/src/components/ConfigErrorScreen.svelte +53 -151
  13. package/src/components/ConfirmDialog.svelte +25 -17
  14. package/src/components/CreatePage.svelte +3 -2
  15. package/src/components/DevTools.svelte +99 -230
  16. package/src/components/DrawerForm.svelte +19 -10
  17. package/src/components/EditPage.svelte +25 -6
  18. package/src/components/EmptyState.svelte +5 -1
  19. package/src/components/ErrorBoundary.svelte +38 -40
  20. package/src/components/ErrorComponent.svelte +18 -17
  21. package/src/components/FieldRenderer.svelte +240 -75
  22. package/src/components/ForgotPasswordPage.svelte +20 -111
  23. package/src/components/Header.svelte +28 -8
  24. package/src/components/InferencerPanel.svelte +136 -110
  25. package/src/components/InfiniteList.svelte +93 -0
  26. package/src/components/Layout.svelte +85 -22
  27. package/src/components/ListPage.svelte +25 -1
  28. package/src/components/LiveIndicator.svelte +30 -87
  29. package/src/components/LoginPage.svelte +81 -155
  30. package/src/components/ModalForm.svelte +6 -2
  31. package/src/components/PageSkeleton.svelte +2 -1
  32. package/src/components/PasswordInput.svelte +95 -0
  33. package/src/components/ProfilePage.svelte +166 -0
  34. package/src/components/RegisterPage.svelte +35 -164
  35. package/src/components/ShowPage.svelte +32 -33
  36. package/src/components/Sidebar.svelte +185 -73
  37. package/src/components/StatsCard.svelte +12 -3
  38. package/src/components/StepsForm.svelte +139 -0
  39. package/src/components/Toast.svelte +15 -54
  40. package/src/components/TooltipButton.svelte +52 -0
  41. package/src/components/UndoableNotification.svelte +28 -96
  42. package/src/components/UpdatePasswordPage.svelte +32 -160
  43. package/src/components/buttons/RefreshButton.svelte +7 -2
  44. package/src/components/fieldComponentMap.ts +79 -0
  45. package/src/components/fields/BooleanField.svelte +9 -9
  46. package/src/components/fields/EmailField.svelte +2 -8
  47. package/src/components/fields/FileField.svelte +4 -9
  48. package/src/components/fields/ImageField.svelte +2 -7
  49. package/src/components/fields/JsonField.svelte +72 -0
  50. package/src/components/fields/MarkdownField.svelte +2 -12
  51. package/src/components/fields/MultiSelectField.svelte +20 -0
  52. package/src/components/fields/RelationField.svelte +24 -0
  53. package/src/components/fields/RichTextField.svelte +35 -0
  54. package/src/components/fields/SelectField.svelte +14 -0
  55. package/src/components/fields/TagField.svelte +6 -11
  56. package/src/components/fields/TextField.svelte +2 -7
  57. package/src/components/fields/UrlField.svelte +4 -10
  58. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  59. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  60. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  61. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  63. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  64. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  65. package/src/components/ui/alert-dialog/index.ts +20 -0
  66. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  67. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  68. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  69. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  70. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  71. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  72. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  73. package/src/components/ui/breadcrumb/index.ts +7 -0
  74. package/src/components/ui/collapsible/index.ts +7 -0
  75. package/src/components/ui/command/command-dialog.svelte +31 -0
  76. package/src/components/ui/command/index.ts +2 -0
  77. package/src/components/ui/context-menu/index.ts +11 -0
  78. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  79. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  80. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  81. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  82. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  83. package/src/components/ui/drawer/drawer.svelte +18 -0
  84. package/src/components/ui/drawer/index.ts +6 -0
  85. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  86. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  87. package/src/components/ui/dropdown-menu/index.ts +6 -0
  88. package/src/components/ui/label/index.ts +1 -0
  89. package/src/components/ui/label/label.svelte +18 -0
  90. package/src/components/ui/pagination/index.ts +7 -0
  91. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  92. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  93. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  94. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  95. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  96. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  97. package/src/components/ui/pagination/pagination.svelte +11 -0
  98. package/src/components/ui/popover/index.ts +12 -0
  99. package/src/components/ui/popover/popover-content.svelte +25 -0
  100. package/src/components/ui/progress/index.ts +1 -0
  101. package/src/components/ui/progress/progress.svelte +22 -0
  102. package/src/components/ui/scroll-area/index.ts +1 -0
  103. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  104. package/src/components/ui/sheet/index.ts +15 -1
  105. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  106. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  107. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  108. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  109. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  110. package/src/index.ts +31 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "peerDependencies": {
23
23
  "svelte": "^5.0.0",
24
- "@svadmin/core": "^0.3.0",
24
+ "@svadmin/core": "^0.4.1",
25
25
  "@tanstack/svelte-query": "^5.0.0",
26
26
  "bits-ui": "^2.0.0",
27
27
  "tailwind-variants": "^3.0.0",
@@ -30,13 +30,18 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@lucide/svelte": "^0.577.0",
33
- "lucide-svelte": "^0.577.0"
33
+ "@tanstack/svelte-table": "^9.0.0-alpha.10",
34
+ "cmdk-sv": "^0.0.19",
35
+ "lucide-svelte": "^0.577.0",
36
+ "svelte-sonner": "^1.1.0",
37
+ "vaul-svelte": "^0.3.2"
34
38
  },
35
39
  "license": "MIT",
36
40
  "author": "zuohuadong",
37
41
  "repository": {
38
42
  "type": "git",
39
- "url": "git+https://github.com/zuohuadong/svadmin.git"
43
+ "url": "https://github.com/zuohuadong/svadmin.git",
44
+ "directory": "packages/ui"
40
45
  },
41
46
  "homepage": "https://github.com/zuohuadong/svadmin#readme",
42
47
  "bugs": {
package/src/actions.ts ADDED
@@ -0,0 +1,110 @@
1
+ // Svelte Actions — reusable DOM behavior directives
2
+ // Usage: <div use:clickOutside={() => close()}>
3
+ // <div use:shortcut={{ key: 'k', ctrl: true, action: open }}>
4
+
5
+ /**
6
+ * Fires callback when clicking outside the node.
7
+ */
8
+ export function clickOutside(node: HTMLElement, callback: () => void) {
9
+ const handle = (e: MouseEvent) => {
10
+ if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
11
+ callback();
12
+ }
13
+ };
14
+
15
+ document.addEventListener('pointerdown', handle, true);
16
+
17
+ return {
18
+ update(newCallback: () => void) {
19
+ callback = newCallback;
20
+ },
21
+ destroy() {
22
+ document.removeEventListener('pointerdown', handle, true);
23
+ },
24
+ };
25
+ }
26
+
27
+ export interface ShortcutParams {
28
+ key: string;
29
+ ctrl?: boolean;
30
+ shift?: boolean;
31
+ alt?: boolean;
32
+ action: () => void;
33
+ }
34
+
35
+ /**
36
+ * Fires action on keyboard shortcut.
37
+ */
38
+ export function shortcut(_node: HTMLElement, params: ShortcutParams) {
39
+ let current = params;
40
+
41
+ const handle = (e: KeyboardEvent) => {
42
+ const match =
43
+ e.key.toLowerCase() === current.key.toLowerCase() &&
44
+ (!current.ctrl || e.ctrlKey || e.metaKey) &&
45
+ (!current.shift || e.shiftKey) &&
46
+ (!current.alt || e.altKey);
47
+
48
+ if (match) {
49
+ e.preventDefault();
50
+ current.action();
51
+ }
52
+ };
53
+
54
+ window.addEventListener('keydown', handle);
55
+
56
+ return {
57
+ update(newParams: ShortcutParams) {
58
+ current = newParams;
59
+ },
60
+ destroy() {
61
+ window.removeEventListener('keydown', handle);
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Fires callback when element enters the viewport (infinite scroll sentinel).
68
+ */
69
+ export function intersect(node: HTMLElement, callback: () => void) {
70
+ const observer = new IntersectionObserver(
71
+ ([entry]) => {
72
+ if (entry.isIntersecting) callback();
73
+ },
74
+ { rootMargin: '200px' },
75
+ );
76
+
77
+ observer.observe(node);
78
+
79
+ return {
80
+ update(newCallback: () => void) {
81
+ callback = newCallback;
82
+ },
83
+ destroy() {
84
+ observer.disconnect();
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Copies text content to clipboard on click.
91
+ */
92
+ export function copyOnClick(node: HTMLElement, text?: string) {
93
+ let value = text;
94
+
95
+ const handle = async () => {
96
+ const content = value ?? node.textContent ?? '';
97
+ await navigator.clipboard.writeText(content);
98
+ };
99
+
100
+ node.addEventListener('click', handle);
101
+
102
+ return {
103
+ update(newText: string) {
104
+ value = newText;
105
+ },
106
+ destroy() {
107
+ node.removeEventListener('click', handle);
108
+ },
109
+ };
110
+ }
@@ -89,7 +89,10 @@
89
89
  <QueryClientProvider client={queryClient}>
90
90
  {#if !authChecked}
91
91
  <div class="flex h-screen items-center justify-center">
92
- <div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"></div>
92
+ <div class="space-y-4 text-center">
93
+ <div class="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary mx-auto"></div>
94
+ <p class="text-sm text-muted-foreground">Loading...</p>
95
+ </div>
93
96
  </div>
94
97
  {:else if route === '/login' && loginPage}
95
98
  {@render loginPage()}
@@ -110,8 +113,8 @@
110
113
  {@render dashboard()}
111
114
  {:else}
112
115
  <div class="space-y-4">
113
- <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{t('common.welcome', { title })}</h1>
114
- <p class="text-gray-500">{t('common.dashboardHint')}</p>
116
+ <h1 class="text-2xl font-bold text-foreground">{t('common.welcome', { title })}</h1>
117
+ <p class="text-muted-foreground">{t('common.dashboardHint')}</p>
115
118
  </div>
116
119
  {/if}
117
120
  {:else if route === '/:resource'}
@@ -134,7 +137,7 @@
134
137
  </Layout>
135
138
  {:else}
136
139
  <div class="flex h-screen items-center justify-center">
137
- <p class="text-gray-500">{t('common.redirecting')}</p>
140
+ <p class="text-muted-foreground">{t('common.redirecting')}</p>
138
141
  </div>
139
142
  {/if}
140
143
  <Toast />
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { useIsAuthenticated } from '@svadmin/core';
3
- import { onMount, type Snippet } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
4
  import { navigate } from '@svadmin/core';
5
5
 
6
6
  let {
@@ -29,12 +29,6 @@
29
29
  navigate(url);
30
30
  }
31
31
 
32
- onMount(() => {
33
- if (!isAuth.isLoading && !isAuth.isAuthenticated) {
34
- triggerRedirect();
35
- }
36
- });
37
-
38
32
  $effect(() => {
39
33
  if (!isAuth.isLoading && !isAuth.isAuthenticated) {
40
34
  triggerRedirect();
@@ -5,16 +5,37 @@
5
5
  import { canAccess } from '@svadmin/core/permissions';
6
6
  import { t } from '@svadmin/core/i18n';
7
7
  import { Button } from './ui/button/index.js';
8
+ import TooltipButton from './TooltipButton.svelte';
8
9
  import * as Card from './ui/card/index.js';
9
10
  import { Badge } from './ui/badge/index.js';
10
- import { Save, ArrowLeft, Loader2 } from 'lucide-svelte';
11
+ import { Save, ArrowLeft, Loader2, AlertCircle } from 'lucide-svelte';
11
12
  import FieldRenderer from './FieldRenderer.svelte';
13
+ import * as Alert from './ui/alert/index.js';
14
+ import { Skeleton } from './ui/skeleton/index.js';
15
+ import ConfirmDialog from './ConfirmDialog.svelte';
12
16
 
13
- let { resourceName, id = undefined, mode = 'create' } = $props<{
17
+ import type { Snippet } from 'svelte';
18
+
19
+ interface Props {
14
20
  resourceName: string;
15
21
  id?: string | number;
16
22
  mode?: 'create' | 'edit';
17
- }>();
23
+ /** Custom field renderer — overrides default FieldRenderer */
24
+ fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
25
+ /** Custom form action buttons */
26
+ formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
27
+ /** Custom header content (after title) */
28
+ headerContent?: Snippet;
29
+ }
30
+
31
+ let {
32
+ resourceName,
33
+ id = undefined,
34
+ mode = 'create',
35
+ fieldRenderer,
36
+ formActions,
37
+ headerContent,
38
+ }: Props = $props();
18
39
 
19
40
  const resource = getResource(resourceName);
20
41
  const primaryKey = resource.primaryKey ?? 'id';
@@ -27,6 +48,19 @@
27
48
  return true;
28
49
  });
29
50
 
51
+ const hasGroups = formFields.some(f => f.group);
52
+ const groups = (() => {
53
+ if (!hasGroups) return [];
54
+ const order: string[] = [];
55
+ const map = new Map<string, typeof formFields>();
56
+ for (const f of formFields) {
57
+ const g = f.group ?? '';
58
+ if (!map.has(g)) { order.push(g); map.set(g, []); }
59
+ map.get(g)!.push(f);
60
+ }
61
+ return order.map(g => ({ name: g, fields: map.get(g)! }));
62
+ })();
63
+
30
64
  // Load existing data for edit mode
31
65
  const existingQuery = mode === 'edit' && id != null
32
66
  ? useOne({ resource: resourceName, id })
@@ -52,8 +86,8 @@
52
86
  }
53
87
  }
54
88
 
55
- // Initialize form data
56
- $effect(() => {
89
+ // Initialize form data (pre-DOM to avoid flash of empty values)
90
+ $effect.pre(() => {
57
91
  if (initialized) return;
58
92
  if (mode === 'create') {
59
93
  const defaults: Record<string, unknown> = {};
@@ -82,8 +116,8 @@
82
116
  };
83
117
  });
84
118
 
85
- const createMut = useCreate(resourceName);
86
- const updateMut = useUpdate(resourceName);
119
+ const createMut = useCreate({ resource: resourceName });
120
+ const updateMut = useUpdate({ resource: resourceName });
87
121
 
88
122
  function validateFields(): boolean {
89
123
  const errors: Record<string, string> = {};
@@ -144,9 +178,7 @@
144
178
  isDirty = true;
145
179
  // Clear field error when user starts typing
146
180
  if (fieldErrors[key]) {
147
- const next = { ...fieldErrors };
148
- delete next[key];
149
- fieldErrors = next;
181
+ delete fieldErrors[key];
150
182
  }
151
183
  }
152
184
 
@@ -157,19 +189,39 @@
157
189
  ? `${t('common.create')}${resource.label}`
158
190
  : `${t('common.edit')}${resource.label}`
159
191
  );
192
+ // Unsaved changes dialog state
193
+ let confirmOpen = $state(false);
194
+ let pendingNavigation: (() => void) | null = null;
195
+
196
+ function guardNavigate(fn: () => void) {
197
+ if (isDirty) {
198
+ pendingNavigation = fn;
199
+ confirmOpen = true;
200
+ } else {
201
+ fn();
202
+ }
203
+ }
204
+
205
+ function confirmNavigate() {
206
+ confirmOpen = false;
207
+ pendingNavigation?.();
208
+ pendingNavigation = null;
209
+ }
210
+
211
+ function cancelNavigate() {
212
+ confirmOpen = false;
213
+ pendingNavigation = null;
214
+ }
160
215
  </script>
161
216
 
162
217
  <div class="space-y-6">
163
218
  <div class="flex items-center gap-4">
164
- <Button
165
- variant="ghost" size="icon"
166
- onclick={() => {
167
- if (isDirty && !confirm(t('common.unsavedChanges'))) return;
168
- navigate(`/${resourceName}`);
169
- }}
219
+ <TooltipButton
220
+ tooltip={t('common.back')}
221
+ onclick={() => guardNavigate(() => navigate(`/${resourceName}`))}
170
222
  >
171
223
  <ArrowLeft class="h-5 w-5" />
172
- </Button>
224
+ </TooltipButton>
173
225
  <h1 class="text-2xl font-bold text-foreground">{pageTitle}</h1>
174
226
  {#if isDirty}
175
227
  <Badge variant="outline" class="border-amber-200 bg-amber-50 text-amber-700">{t('common.unsaved')}</Badge>
@@ -177,68 +229,105 @@
177
229
  </div>
178
230
 
179
231
  {#if isLoading}
180
- <div class="flex h-64 items-center justify-center">
181
- <Loader2 class="h-6 w-6 animate-spin text-primary" />
232
+ <div class="max-w-3xl space-y-6">
233
+ <div class="rounded-xl border p-6 space-y-5">
234
+ {#each Array(4) as _}
235
+ <div class="space-y-2">
236
+ <Skeleton class="h-4 w-24" />
237
+ <Skeleton class="h-10 w-full" />
238
+ </div>
239
+ {/each}
240
+ </div>
182
241
  </div>
183
242
  {:else}
184
243
  <form onsubmit={(e: Event) => { e.preventDefault(); handleSubmit(); }} class="max-w-3xl space-y-6">
185
244
  {#if error}
186
- <div class="rounded-lg bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
187
- {error}
188
- </div>
245
+ <Alert.Root variant="destructive">
246
+ <AlertCircle class="h-4 w-4" />
247
+ <Alert.Description>{error}</Alert.Description>
248
+ </Alert.Root>
189
249
  {/if}
190
250
 
191
- <Card.Root>
192
- <Card.Content class="space-y-5">
193
- {#each formFields as field (field.key)}
194
- <div class="field-wrapper" class:has-error={fieldErrors[field.key]}>
195
- <FieldRenderer
196
- {field}
197
- value={formData[field.key]}
198
- onchange={(val: unknown) => handleFieldChange(field.key, val)}
199
- />
200
- {#if fieldErrors[field.key]}
201
- <p class="field-error">{fieldErrors[field.key]}</p>
202
- {/if}
203
- </div>
204
- {/each}
205
- </Card.Content>
206
- </Card.Root>
251
+ {#if hasGroups}
252
+ {#each groups as group}
253
+ <Card.Root>
254
+ {#if group.name}
255
+ <Card.Header>
256
+ <Card.Title class="text-lg">{group.name}</Card.Title>
257
+ </Card.Header>
258
+ {/if}
259
+ <Card.Content class="space-y-5">
260
+ {#each group.fields as field (field.key)}
261
+ <div class:border-destructive={!!fieldErrors[field.key]}>
262
+ {#if fieldRenderer}
263
+ {@render fieldRenderer({ field, value: formData[field.key], onchange: (val: unknown) => handleFieldChange(field.key, val) })}
264
+ {:else}
265
+ <FieldRenderer
266
+ {field}
267
+ value={formData[field.key]}
268
+ onchange={(val: unknown) => handleFieldChange(field.key, val)}
269
+ />
270
+ {/if}
271
+ {#if fieldErrors[field.key]}
272
+ <p class="text-destructive text-[0.8125rem] mt-1">{fieldErrors[field.key]}</p>
273
+ {/if}
274
+ </div>
275
+ {/each}
276
+ </Card.Content>
277
+ </Card.Root>
278
+ {/each}
279
+ {:else}
280
+ <Card.Root>
281
+ <Card.Content class="space-y-5">
282
+ {#each formFields as field (field.key)}
283
+ <div class:border-destructive={!!fieldErrors[field.key]}>
284
+ {#if fieldRenderer}
285
+ {@render fieldRenderer({ field, value: formData[field.key], onchange: (val: unknown) => handleFieldChange(field.key, val) })}
286
+ {:else}
287
+ <FieldRenderer
288
+ {field}
289
+ value={formData[field.key]}
290
+ onchange={(val: unknown) => handleFieldChange(field.key, val)}
291
+ />
292
+ {/if}
293
+ {#if fieldErrors[field.key]}
294
+ <p class="text-destructive text-[0.8125rem] mt-1">{fieldErrors[field.key]}</p>
295
+ {/if}
296
+ </div>
297
+ {/each}
298
+ </Card.Content>
299
+ </Card.Root>
300
+ {/if}
207
301
 
208
302
  <div class="flex items-center gap-3">
209
- <Button type="submit" disabled={submitting}>
210
- {#if submitting}
211
- <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
212
- {:else}
213
- <Save class="h-4 w-4" data-icon="inline-start" />
214
- {/if}
215
- {t('common.save')}
216
- </Button>
217
- <Button
218
- type="button"
219
- variant="outline"
220
- onclick={() => {
221
- if (isDirty && !confirm(t('common.unsavedChanges'))) return;
222
- navigate(`/${resourceName}`);
223
- }}
224
- >
225
- {t('common.cancel')}
226
- </Button>
303
+ {#if formActions}
304
+ {@render formActions({ isLoading: submitting, onSubmit: handleSubmit })}
305
+ {:else}
306
+ <Button type="submit" disabled={submitting}>
307
+ {#if submitting}
308
+ <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
309
+ {:else}
310
+ <Save class="h-4 w-4" data-icon="inline-start" />
311
+ {/if}
312
+ {t('common.save')}
313
+ </Button>
314
+ <Button
315
+ type="button"
316
+ variant="outline"
317
+ onclick={() => guardNavigate(() => navigate(`/${resourceName}`))}
318
+ >
319
+ {t('common.cancel')}
320
+ </Button>
321
+ {/if}
227
322
  </div>
228
323
  </form>
229
324
  {/if}
230
325
  </div>
231
326
 
232
- <style>
233
- .field-error {
234
- color: hsl(var(--destructive));
235
- font-size: 0.8125rem;
236
- margin-top: 0.25rem;
237
- }
238
-
239
- .field-wrapper.has-error :global(input),
240
- .field-wrapper.has-error :global(textarea),
241
- .field-wrapper.has-error :global(select) {
242
- border-color: hsl(var(--destructive));
243
- }
244
- </style>
327
+ <ConfirmDialog
328
+ open={confirmOpen}
329
+ message={t('common.unsavedChanges')}
330
+ confirmText={t('common.confirm')}
331
+ onconfirm={confirmNavigate}
332
+ oncancel={cancelNavigate}
333
+ />
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { fade, fly } from 'svelte/transition';
2
3
  import { Loader2, Check, X, Cloud } from 'lucide-svelte';
3
4
  import { t } from '@svadmin/core/i18n';
4
5
 
@@ -13,18 +14,22 @@
13
14
  }>();
14
15
  </script>
15
16
 
16
- <div class="flex items-center gap-2 text-sm text-muted-foreground transition-all duration-300">
17
- {#if status === 'idle'}
18
- <Cloud class="h-4 w-4" />
19
- <span>{t('autoSave.idle')}</span>
20
- {:else if status === 'loading'}
21
- <Loader2 class="h-4 w-4 animate-spin text-primary" />
22
- <span>{t('autoSave.saving')}</span>
23
- {:else if status === 'success'}
24
- <Check class="h-4 w-4 text-green-500" />
25
- <span class="text-green-500">{t('autoSave.saved')}</span>
26
- {:else if status === 'error'}
27
- <X class="h-4 w-4 text-destructive" />
28
- <span class="text-destructive" title={error}>{t('autoSave.error')}</span>
29
- {/if}
17
+ <div class="flex items-center gap-2 text-sm text-muted-foreground">
18
+ {#key status}
19
+ <div class="flex items-center gap-2" in:fly={{ y: -8, duration: 200 }} out:fade={{ duration: 150 }}>
20
+ {#if status === 'idle'}
21
+ <Cloud class="h-4 w-4" />
22
+ <span>{t('autoSave.idle')}</span>
23
+ {:else if status === 'loading'}
24
+ <Loader2 class="h-4 w-4 animate-spin text-primary" />
25
+ <span>{t('autoSave.saving')}</span>
26
+ {:else if status === 'success'}
27
+ <Check class="h-4 w-4 text-green-500" />
28
+ <span class="text-green-500">{t('autoSave.saved')}</span>
29
+ {:else if status === 'error'}
30
+ <X class="h-4 w-4 text-destructive" />
31
+ <span class="text-destructive" title={error}>{t('autoSave.error')}</span>
32
+ {/if}
33
+ </div>
34
+ {/key}
30
35
  </div>