@svadmin/ui 0.0.6 → 0.4.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.
Files changed (120) hide show
  1. package/package.json +8 -3
  2. package/src/actions.ts +110 -0
  3. package/src/components/AdminApp.svelte +8 -4
  4. package/src/components/Authenticated.svelte +38 -13
  5. package/src/components/AutoForm.svelte +163 -74
  6. package/src/components/AutoSaveIndicator.svelte +35 -0
  7. package/src/components/AutoTable.svelte +476 -183
  8. package/src/components/Breadcrumbs.svelte +22 -17
  9. package/src/components/CatchAllNavigate.svelte +21 -0
  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 +43 -0
  15. package/src/components/DevTools.svelte +99 -230
  16. package/src/components/DrawerForm.svelte +19 -10
  17. package/src/components/EditPage.svelte +71 -0
  18. package/src/components/EmptyState.svelte +21 -31
  19. package/src/components/ErrorBoundary.svelte +38 -40
  20. package/src/components/ErrorComponent.svelte +36 -0
  21. package/src/components/FieldRenderer.svelte +240 -75
  22. package/src/components/ForgotPasswordPage.svelte +20 -111
  23. package/src/components/Header.svelte +46 -57
  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 +71 -0
  28. package/src/components/LiveIndicator.svelte +58 -0
  29. package/src/components/LoginPage.svelte +33 -154
  30. package/src/components/ModalForm.svelte +6 -2
  31. package/src/components/NavigateToResource.svelte +20 -0
  32. package/src/components/PageSkeleton.svelte +71 -0
  33. package/src/components/PasswordInput.svelte +95 -0
  34. package/src/components/RegisterPage.svelte +35 -164
  35. package/src/components/ShowPage.svelte +35 -36
  36. package/src/components/Sidebar.svelte +212 -81
  37. package/src/components/StatsCard.svelte +12 -3
  38. package/src/components/StepsForm.svelte +139 -0
  39. package/src/components/ThemedTitle.svelte +32 -0
  40. package/src/components/Toast.svelte +15 -44
  41. package/src/components/TooltipButton.svelte +52 -0
  42. package/src/components/UndoableNotification.svelte +28 -96
  43. package/src/components/UpdatePasswordPage.svelte +32 -160
  44. package/src/components/buttons/CloneButton.svelte +1 -1
  45. package/src/components/buttons/CreateButton.svelte +1 -1
  46. package/src/components/buttons/DeleteButton.svelte +3 -4
  47. package/src/components/buttons/EditButton.svelte +1 -1
  48. package/src/components/buttons/ImportButton.svelte +29 -7
  49. package/src/components/buttons/RefreshButton.svelte +11 -5
  50. package/src/components/fieldComponentMap.ts +79 -0
  51. package/src/components/fields/BooleanField.svelte +13 -0
  52. package/src/components/fields/DateField.svelte +6 -0
  53. package/src/components/fields/EmailField.svelte +9 -0
  54. package/src/components/fields/FileField.svelte +14 -0
  55. package/src/components/fields/ImageField.svelte +9 -0
  56. package/src/components/fields/JsonField.svelte +72 -0
  57. package/src/components/fields/MarkdownField.svelte +9 -0
  58. package/src/components/fields/MultiSelectField.svelte +20 -0
  59. package/src/components/fields/NumberField.svelte +6 -0
  60. package/src/components/fields/RelationField.svelte +24 -0
  61. package/src/components/fields/RichTextField.svelte +35 -0
  62. package/src/components/fields/SelectField.svelte +14 -0
  63. package/src/components/fields/TagField.svelte +12 -0
  64. package/src/components/fields/TextField.svelte +11 -0
  65. package/src/components/fields/UrlField.svelte +14 -0
  66. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  67. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  68. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  69. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  70. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  71. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  72. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  73. package/src/components/ui/alert-dialog/index.ts +20 -0
  74. package/src/components/ui/avatar/avatar.svelte +2 -1
  75. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  76. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  77. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  78. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  79. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  80. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  81. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  82. package/src/components/ui/breadcrumb/index.ts +7 -0
  83. package/src/components/ui/collapsible/index.ts +7 -0
  84. package/src/components/ui/command/command-dialog.svelte +31 -0
  85. package/src/components/ui/command/index.ts +2 -0
  86. package/src/components/ui/context-menu/index.ts +11 -0
  87. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  88. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  89. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  90. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  91. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  92. package/src/components/ui/drawer/drawer.svelte +18 -0
  93. package/src/components/ui/drawer/index.ts +6 -0
  94. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  95. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  96. package/src/components/ui/dropdown-menu/index.ts +6 -0
  97. package/src/components/ui/label/index.ts +1 -0
  98. package/src/components/ui/label/label.svelte +18 -0
  99. package/src/components/ui/pagination/index.ts +7 -0
  100. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  101. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  102. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  103. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  104. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  105. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  106. package/src/components/ui/pagination/pagination.svelte +11 -0
  107. package/src/components/ui/popover/index.ts +12 -0
  108. package/src/components/ui/popover/popover-content.svelte +25 -0
  109. package/src/components/ui/progress/index.ts +1 -0
  110. package/src/components/ui/progress/progress.svelte +22 -0
  111. package/src/components/ui/scroll-area/index.ts +1 -0
  112. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  113. package/src/components/ui/sheet/index.ts +15 -1
  114. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  115. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  116. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  117. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  118. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  119. package/src/index.ts +52 -1
  120. package/src/router-state.svelte.ts +16 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.0.6",
3
+ "version": "0.4.1",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -21,7 +21,8 @@
21
21
  },
22
22
  "peerDependencies": {
23
23
  "svelte": "^5.0.0",
24
- "@svadmin/core": "0.0.5",
24
+ "@svadmin/core": "^0.4.1",
25
+ "@tanstack/svelte-query": "^5.0.0",
25
26
  "bits-ui": "^2.0.0",
26
27
  "tailwind-variants": "^3.0.0",
27
28
  "clsx": "^2.0.0",
@@ -29,7 +30,11 @@
29
30
  },
30
31
  "dependencies": {
31
32
  "@lucide/svelte": "^0.577.0",
32
- "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"
33
38
  },
34
39
  "license": "MIT",
35
40
  "author": "zuohuadong",
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
+ }
@@ -2,6 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { DataProvider, AuthProvider, ResourceDefinition, ThemeMode, RouterProvider } from '@svadmin/core';
4
4
  import { setDataProvider, setAuthProvider, setResources, setLocale, setTheme, setRouterProvider, getAuthProvider, createHashRouterProvider } from '@svadmin/core';
5
+ import { t } from '@svadmin/core/i18n';
5
6
  import { navigate } from '@svadmin/core/router';
6
7
  import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
7
8
  import Layout from './Layout.svelte';
@@ -88,7 +89,10 @@
88
89
  <QueryClientProvider client={queryClient}>
89
90
  {#if !authChecked}
90
91
  <div class="flex h-screen items-center justify-center">
91
- <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>
92
96
  </div>
93
97
  {:else if route === '/login' && loginPage}
94
98
  {@render loginPage()}
@@ -109,8 +113,8 @@
109
113
  {@render dashboard()}
110
114
  {:else}
111
115
  <div class="space-y-4">
112
- <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Welcome to {title}</h1>
113
- <p class="text-gray-500">Select a resource from the sidebar to get started.</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>
114
118
  </div>
115
119
  {/if}
116
120
  {:else if route === '/:resource'}
@@ -133,7 +137,7 @@
133
137
  </Layout>
134
138
  {:else}
135
139
  <div class="flex h-screen items-center justify-center">
136
- <p class="text-gray-500">Redirecting to login...</p>
140
+ <p class="text-muted-foreground">{t('common.redirecting')}</p>
137
141
  </div>
138
142
  {/if}
139
143
  <Toast />
@@ -1,26 +1,51 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
2
  import { useIsAuthenticated } from '@svadmin/core';
3
+ import type { Snippet } from 'svelte';
4
+ import { navigate } from '@svadmin/core';
4
5
 
5
- let { children, fallback, loading } = $props<{
6
- children: Snippet;
6
+ let {
7
+ children,
8
+ fallback,
9
+ loading,
10
+ redirectTo = '/login',
11
+ appendCurrentPathToQuery = true,
12
+ } = $props<{
13
+ children?: Snippet;
7
14
  fallback?: Snippet;
8
15
  loading?: Snippet;
16
+ redirectTo?: string;
17
+ appendCurrentPathToQuery?: boolean;
9
18
  }>();
10
19
 
11
- const auth = useIsAuthenticated();
20
+ const isAuth = useIsAuthenticated();
21
+
22
+ function triggerRedirect() {
23
+ if (fallback) return;
24
+ let url = redirectTo;
25
+ if (appendCurrentPathToQuery && typeof window !== 'undefined') {
26
+ const current = encodeURIComponent(window.location.pathname + window.location.search);
27
+ url += `?to=${current}`;
28
+ }
29
+ navigate(url);
30
+ }
31
+
32
+ $effect(() => {
33
+ if (!isAuth.isLoading && !isAuth.isAuthenticated) {
34
+ triggerRedirect();
35
+ }
36
+ });
12
37
  </script>
13
38
 
14
- {#if auth.isLoading}
39
+ {#if isAuth.isLoading}
15
40
  {#if loading}
16
41
  {@render loading()}
17
- {:else}
18
- <div class="flex items-center justify-center min-h-[200px]">
19
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
20
- </div>
21
42
  {/if}
22
- {:else if auth.isAuthenticated}
23
- {@render children()}
24
- {:else if fallback}
25
- {@render fallback()}
43
+ {:else if isAuth.isAuthenticated}
44
+ {#if children}
45
+ {@render children()}
46
+ {/if}
47
+ {:else}
48
+ {#if fallback}
49
+ {@render fallback()}
50
+ {/if}
26
51
  {/if}
@@ -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> = {};
@@ -62,8 +96,8 @@
62
96
  }
63
97
  formData = defaults;
64
98
  initialized = true;
65
- } else if (existingQuery && existingQuery?.data) {
66
- formData = { ...existingQuery.data as Record<string, unknown> };
99
+ } else if (existingQuery && existingQuery.query.data?.data) {
100
+ formData = { ...existingQuery.query.data.data as Record<string, unknown> };
67
101
  initialized = true;
68
102
  }
69
103
  });
@@ -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> = {};
@@ -125,9 +159,9 @@
125
159
  }
126
160
 
127
161
  if (mode === 'create') {
128
- await createMut.mutateAsync(cleanData);
162
+ await createMut.mutation.mutateAsync({ variables: cleanData });
129
163
  } else if (id != null) {
130
- await updateMut.mutateAsync({ id, variables: cleanData });
164
+ await updateMut.mutation.mutateAsync({ id, variables: cleanData });
131
165
  }
132
166
 
133
167
  isDirty = false;
@@ -144,32 +178,50 @@
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
 
153
- const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery?.isLoading : false);
185
+ const isLoading = $derived(mode === 'edit' && existingQuery ? existingQuery.query.isLoading : false);
154
186
 
155
187
  const pageTitle = $derived(
156
188
  mode === 'create'
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
+ />
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ import { fade, fly } from 'svelte/transition';
3
+ import { Loader2, Check, X, Cloud } from 'lucide-svelte';
4
+ import { t } from '@svadmin/core/i18n';
5
+
6
+ let {
7
+ status = 'idle',
8
+ error,
9
+ } = $props<{
10
+ /** The current auto-save status */
11
+ status?: 'idle' | 'loading' | 'success' | 'error';
12
+ /** Optional error message */
13
+ error?: string;
14
+ }>();
15
+ </script>
16
+
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}
35
+ </div>