@svadmin/ui 0.3.0 → 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 (108) hide show
  1. package/package.json +7 -3
  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/ComboboxField.svelte +122 -0
  10. package/src/components/CommandPalette.svelte +93 -0
  11. package/src/components/ConfigErrorScreen.svelte +53 -151
  12. package/src/components/ConfirmDialog.svelte +25 -17
  13. package/src/components/CreatePage.svelte +3 -2
  14. package/src/components/DevTools.svelte +99 -230
  15. package/src/components/DrawerForm.svelte +19 -10
  16. package/src/components/EditPage.svelte +25 -6
  17. package/src/components/EmptyState.svelte +5 -1
  18. package/src/components/ErrorBoundary.svelte +38 -40
  19. package/src/components/ErrorComponent.svelte +18 -17
  20. package/src/components/FieldRenderer.svelte +240 -75
  21. package/src/components/ForgotPasswordPage.svelte +20 -111
  22. package/src/components/Header.svelte +28 -8
  23. package/src/components/InferencerPanel.svelte +136 -110
  24. package/src/components/InfiniteList.svelte +93 -0
  25. package/src/components/Layout.svelte +85 -22
  26. package/src/components/ListPage.svelte +25 -1
  27. package/src/components/LiveIndicator.svelte +30 -87
  28. package/src/components/LoginPage.svelte +33 -154
  29. package/src/components/ModalForm.svelte +6 -2
  30. package/src/components/PageSkeleton.svelte +2 -1
  31. package/src/components/PasswordInput.svelte +95 -0
  32. package/src/components/RegisterPage.svelte +35 -164
  33. package/src/components/ShowPage.svelte +32 -33
  34. package/src/components/Sidebar.svelte +185 -73
  35. package/src/components/StatsCard.svelte +12 -3
  36. package/src/components/StepsForm.svelte +139 -0
  37. package/src/components/Toast.svelte +15 -54
  38. package/src/components/TooltipButton.svelte +52 -0
  39. package/src/components/UndoableNotification.svelte +28 -96
  40. package/src/components/UpdatePasswordPage.svelte +32 -160
  41. package/src/components/buttons/RefreshButton.svelte +7 -2
  42. package/src/components/fieldComponentMap.ts +79 -0
  43. package/src/components/fields/BooleanField.svelte +9 -9
  44. package/src/components/fields/EmailField.svelte +2 -8
  45. package/src/components/fields/FileField.svelte +4 -9
  46. package/src/components/fields/ImageField.svelte +2 -7
  47. package/src/components/fields/JsonField.svelte +72 -0
  48. package/src/components/fields/MarkdownField.svelte +2 -12
  49. package/src/components/fields/MultiSelectField.svelte +20 -0
  50. package/src/components/fields/RelationField.svelte +24 -0
  51. package/src/components/fields/RichTextField.svelte +35 -0
  52. package/src/components/fields/SelectField.svelte +14 -0
  53. package/src/components/fields/TagField.svelte +6 -11
  54. package/src/components/fields/TextField.svelte +2 -7
  55. package/src/components/fields/UrlField.svelte +4 -10
  56. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  57. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  58. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  59. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  60. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  61. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  63. package/src/components/ui/alert-dialog/index.ts +20 -0
  64. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  65. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  66. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  67. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  68. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  69. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  70. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  71. package/src/components/ui/breadcrumb/index.ts +7 -0
  72. package/src/components/ui/collapsible/index.ts +7 -0
  73. package/src/components/ui/command/command-dialog.svelte +31 -0
  74. package/src/components/ui/command/index.ts +2 -0
  75. package/src/components/ui/context-menu/index.ts +11 -0
  76. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  77. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  78. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  79. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  80. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  81. package/src/components/ui/drawer/drawer.svelte +18 -0
  82. package/src/components/ui/drawer/index.ts +6 -0
  83. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  84. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  85. package/src/components/ui/dropdown-menu/index.ts +6 -0
  86. package/src/components/ui/label/index.ts +1 -0
  87. package/src/components/ui/label/label.svelte +18 -0
  88. package/src/components/ui/pagination/index.ts +7 -0
  89. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  90. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  91. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  92. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  93. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  94. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  95. package/src/components/ui/pagination/pagination.svelte +11 -0
  96. package/src/components/ui/popover/index.ts +12 -0
  97. package/src/components/ui/popover/popover-content.svelte +25 -0
  98. package/src/components/ui/progress/index.ts +1 -0
  99. package/src/components/ui/progress/progress.svelte +22 -0
  100. package/src/components/ui/scroll-area/index.ts +1 -0
  101. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  102. package/src/components/ui/sheet/index.ts +15 -1
  103. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  104. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  105. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  106. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  107. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  108. package/src/index.ts +30 -1
@@ -6,10 +6,14 @@
6
6
  import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme, canAccessAsync } from '@svadmin/core';
7
7
  import { Button } from './ui/button/index.js';
8
8
  import * as Tooltip from './ui/tooltip/index.js';
9
+ import * as DropdownMenu from './ui/dropdown-menu/index.js';
9
10
  import { Separator } from './ui/separator/index.js';
11
+ import { ScrollArea } from './ui/scroll-area/index.js';
12
+ import * as Collapsible from './ui/collapsible/index.js';
13
+ import { Avatar } from './ui/avatar/index.js';
10
14
  import {
11
15
  LayoutDashboard, FileText, Users, Settings, Home,
12
- ChevronLeft, ChevronRight, LogOut, Sun, Moon, Languages
16
+ ChevronLeft, ChevronRight, ChevronDown, LogOut, Sun, Moon, Languages, Palette
13
17
  } from 'lucide-svelte';
14
18
 
15
19
  let { collapsed, identity, title, onToggle, onLogout } = $props<{
@@ -34,15 +38,21 @@
34
38
  path: string;
35
39
  label: string;
36
40
  Icon: typeof LayoutDashboard;
41
+ group?: string;
42
+ }
43
+
44
+ interface NavGroup {
45
+ name: string | null; // null = ungrouped
46
+ items: NavItem[];
37
47
  }
38
48
 
39
49
  // Use effect to build nav items, taking access control into account
40
- let navItems = $state<NavItem[]>([]);
50
+ let navItems = $state.raw<NavItem[]>([]);
41
51
 
42
52
  $effect(() => {
43
53
  // Re-run when locale or resources change
44
54
  const localeVal = getLocale();
45
-
55
+
46
56
  Promise.all(resources.map(async (r) => {
47
57
  try {
48
58
  const { can } = await canAccessAsync(r.name, 'list');
@@ -58,6 +68,7 @@
58
68
  path: `/${r.name}`,
59
69
  label: r.label,
60
70
  Icon: iconMap[r.name] ?? Settings,
71
+ group: r.group,
61
72
  });
62
73
  }
63
74
  }
@@ -79,19 +90,39 @@
79
90
 
80
91
  // Track current hash for active state
81
92
  let path = $state(currentPath());
82
- $effect(() => {
83
- function onHash() { path = currentPath(); }
84
- window.addEventListener('hashchange', onHash);
85
- return () => window.removeEventListener('hashchange', onHash);
86
- });
93
+
94
+ function onHashChange() { path = currentPath(); }
87
95
 
88
96
  function isActive(itemPath: string): boolean {
89
97
  if (itemPath === '/') return path === '/';
90
98
  return path.startsWith(itemPath);
91
99
  }
100
+
101
+ // Group nav items by group field (null = ungrouped)
102
+ const navGroups = $derived.by((): NavGroup[] => {
103
+ const groups: NavGroup[] = [];
104
+ const groupMap = new Map<string | null, NavItem[]>();
105
+
106
+ for (const item of navItems) {
107
+ const key = item.group ?? null;
108
+ if (!groupMap.has(key)) {
109
+ groupMap.set(key, []);
110
+ groups.push({ name: key, items: groupMap.get(key)! });
111
+ }
112
+ groupMap.get(key)!.push(item);
113
+ }
114
+
115
+ return groups;
116
+ });
117
+
118
+ // Track which groups are open
119
+ let openGroups = $state<Set<string>>(new Set());
92
120
  </script>
93
121
 
122
+ <svelte:window onhashchange={onHashChange} />
123
+
94
124
  <aside
125
+ aria-label="Sidebar navigation"
95
126
  class="fixed inset-y-0 left-0 z-30 flex flex-col bg-sidebar/80 backdrop-blur-xl border-r border-sidebar-border/50 shadow-xl transition-all duration-300"
96
127
  class:w-64={!collapsed}
97
128
  class:w-16={collapsed}
@@ -112,73 +143,133 @@
112
143
  </Button>
113
144
  </div>
114
145
 
115
- <!-- Nav -->
116
- <nav class="flex-1 overflow-y-auto py-4 px-2 space-y-1">
117
- {#each navItems as item}
118
- {@const active = isActive(item.path)}
119
- {#if collapsed}
120
- <Tooltip.Root>
121
- <Tooltip.Trigger>
122
- {#snippet child({ props })}
123
- <a
124
- {...props}
125
- href={`#${item.path}`}
126
- class="flex items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
127
- class:bg-sidebar-accent={active}
128
- class:text-sidebar-accent-foreground={active}
129
- class:text-sidebar-foreground={!active}
130
- class:hover:bg-sidebar-accent={!active}
131
- >
132
- <item.Icon class="h-5 w-5 flex-shrink-0" />
133
- </a>
134
- {/snippet}
135
- </Tooltip.Trigger>
136
- <Tooltip.Content side="right">
137
- {item.label}
138
- </Tooltip.Content>
139
- </Tooltip.Root>
146
+ <ScrollArea class="flex-1">
147
+ <nav aria-label="Main menu" class="py-4 px-2 space-y-1">
148
+ {#each navGroups as group}
149
+ {#if group.name && !collapsed}
150
+ <!-- Grouped section with Collapsible -->
151
+ <Collapsible.Root open={openGroups.has(group.name)} onOpenChange={(isOpen) => {
152
+ const next = new Set(openGroups);
153
+ if (isOpen) next.add(group.name!); else next.delete(group.name!);
154
+ openGroups = next;
155
+ }}>
156
+ <Collapsible.Trigger
157
+ class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/60 hover:bg-sidebar-accent/50 transition-colors"
158
+ >
159
+ <span>{group.name}</span>
160
+ <ChevronDown class="h-3 w-3 transition-transform {openGroups.has(group.name) ? 'rotate-180' : ''}" />
161
+ </Collapsible.Trigger>
162
+ <Collapsible.Content>
163
+ <div class="mt-1 space-y-0.5 pl-1">
164
+ {#each group.items as item}
165
+ {@const active = isActive(item.path)}
166
+ <a
167
+ href={`#${item.path}`}
168
+ class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
169
+ class:bg-sidebar-accent={active}
170
+ class:text-sidebar-accent-foreground={active}
171
+ class:text-sidebar-foreground={!active}
172
+ class:hover:bg-sidebar-accent={!active}
173
+ >
174
+ <item.Icon class="h-5 w-5 flex-shrink-0" />
175
+ <span>{item.label}</span>
176
+ </a>
177
+ {/each}
178
+ </div>
179
+ </Collapsible.Content>
180
+ </Collapsible.Root>
140
181
  {:else}
141
- <a
142
- href={`#${item.path}`}
143
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
144
- class:bg-sidebar-accent={active}
145
- class:text-sidebar-accent-foreground={active}
146
- class:text-sidebar-foreground={!active}
147
- class:hover:bg-sidebar-accent={!active}
148
- >
149
- <item.Icon class="h-5 w-5 flex-shrink-0" />
150
- <span>{item.label}</span>
151
- </a>
182
+ <!-- Ungrouped items (flat) -->
183
+ {#each group.items as item}
184
+ {@const active = isActive(item.path)}
185
+ {#if collapsed}
186
+ <Tooltip.Root>
187
+ <Tooltip.Trigger>
188
+ {#snippet child({ props })}
189
+ <a
190
+ {...props}
191
+ href={`#${item.path}`}
192
+ class="flex items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
193
+ class:bg-sidebar-accent={active}
194
+ class:text-sidebar-accent-foreground={active}
195
+ class:text-sidebar-foreground={!active}
196
+ class:hover:bg-sidebar-accent={!active}
197
+ >
198
+ <item.Icon class="h-5 w-5 flex-shrink-0" />
199
+ </a>
200
+ {/snippet}
201
+ </Tooltip.Trigger>
202
+ <Tooltip.Content side="right">
203
+ {item.label}
204
+ </Tooltip.Content>
205
+ </Tooltip.Root>
206
+ {:else}
207
+ <a
208
+ href={`#${item.path}`}
209
+ class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
210
+ class:bg-sidebar-accent={active}
211
+ class:text-sidebar-accent-foreground={active}
212
+ class:text-sidebar-foreground={!active}
213
+ class:hover:bg-sidebar-accent={!active}
214
+ >
215
+ <item.Icon class="h-5 w-5 flex-shrink-0" />
216
+ <span>{item.label}</span>
217
+ </a>
218
+ {/if}
219
+ {/each}
152
220
  {/if}
153
221
  {/each}
154
222
  </nav>
223
+ </ScrollArea>
155
224
 
156
225
  <!-- Footer -->
157
226
  <Separator class="bg-sidebar-border" />
158
227
  <div class="p-3 space-y-2">
159
- <!-- Color theme picker -->
228
+ <!-- Color theme picker via DropdownMenu -->
160
229
  {#if !collapsed}
161
- <div class="flex items-center justify-center gap-1.5 px-2 py-1">
162
- {#each colorThemes as ct}
163
- <button
164
- class="h-5 w-5 rounded-full transition-all duration-200 hover:scale-110 {getColorTheme() === ct.id ? 'ring-2 ring-offset-2 ring-offset-sidebar scale-110' : 'opacity-70 hover:opacity-100'}"
165
- style="background-color: {ct.color}; {getColorTheme() === ct.id ? `--tw-ring-color: ${ct.color}` : ''}"
166
- title={ct.label}
167
- onclick={() => setColorTheme(ct.id)}
168
- ></button>
169
- {/each}
170
- </div>
230
+ <DropdownMenu.Root>
231
+ <DropdownMenu.Trigger>
232
+ {#snippet child({ props })}
233
+ <Button
234
+ {...props}
235
+ variant="ghost"
236
+ class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors h-auto justify-start"
237
+ >
238
+ <Palette class="h-4 w-4" />
239
+ <span class="flex-1 text-left text-xs">{t('common.toggleTheme')}</span>
240
+ <span
241
+ class="h-4 w-4 rounded-full ring-1 ring-offset-1 ring-offset-sidebar"
242
+ style="background-color: {colorThemes.find(c => c.id === getColorTheme())?.color ?? '#3b82f6'}; --tw-ring-color: {colorThemes.find(c => c.id === getColorTheme())?.color ?? '#3b82f6'}"
243
+ ></span>
244
+ </Button>
245
+ {/snippet}
246
+ </DropdownMenu.Trigger>
247
+ <DropdownMenu.Content side="right" align="end" class="w-40">
248
+ {#each colorThemes as ct}
249
+ <DropdownMenu.Item onclick={() => setColorTheme(ct.id)} class="gap-2">
250
+ <span
251
+ class="h-4 w-4 rounded-full {getColorTheme() === ct.id ? 'ring-2 ring-offset-2 scale-110' : 'opacity-70'}"
252
+ style="background-color: {ct.color}; {getColorTheme() === ct.id ? `--tw-ring-color: ${ct.color}` : ''}"
253
+ ></span>
254
+ {ct.label}
255
+ </DropdownMenu.Item>
256
+ {/each}
257
+ </DropdownMenu.Content>
258
+ </DropdownMenu.Root>
171
259
  {/if}
172
260
 
173
261
  {#if !collapsed && identity}
174
262
  <div class="flex items-center gap-3 rounded-lg px-2 py-2">
175
- <div class="flex h-8 w-8 items-center justify-center rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground">
176
- {identity.name?.charAt(0).toUpperCase() ?? 'U'}
177
- </div>
263
+ <Avatar
264
+ src={(identity as Record<string, unknown>).avatar as string | undefined}
265
+ alt={identity.name ?? 'User'}
266
+ fallback={identity.name?.charAt(0).toUpperCase() ?? 'U'}
267
+ size="sm"
268
+ />
178
269
  <div class="flex-1 min-w-0">
179
270
  <p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
180
271
  </div>
181
- <Button variant="ghost" size="icon-sm" onclick={toggleLocale} class="text-sidebar-foreground" title="Switch language">
272
+ <Button variant="ghost" size="icon-sm" onclick={toggleLocale} class="text-sidebar-foreground" title={t('common.switchLanguage')}>
182
273
  <span class="text-xs font-bold">{localeLabel}</span>
183
274
  </Button>
184
275
  <Button variant="ghost" size="icon-sm" onclick={toggleTheme} class="text-sidebar-foreground" title={t('common.toggleTheme')}>
@@ -193,22 +284,43 @@
193
284
  </Button>
194
285
  </div>
195
286
  {:else if collapsed}
196
- <Button variant="ghost" size="icon" onclick={toggleLocale} class="w-full text-sidebar-foreground" title="Switch language">
197
- <span class="text-xs font-bold">{localeLabel}</span>
198
- </Button>
199
- <Button variant="ghost" size="icon" onclick={toggleTheme} class="w-full text-sidebar-foreground" title={t('common.toggleTheme')}>
200
- {#if getResolvedTheme() === 'dark'}
201
- <Sun class="h-5 w-5" />
202
- {:else}
203
- <Moon class="h-5 w-5" />
204
- {/if}
205
- </Button>
206
- <Button variant="ghost" size="icon" onclick={onLogout} class="w-full text-sidebar-foreground hover:text-destructive" title={t('common.logout')}>
207
- <LogOut class="h-5 w-5" />
208
- </Button>
287
+ <Tooltip.Root>
288
+ <Tooltip.Trigger>
289
+ {#snippet child({ props })}
290
+ <Button {...props} variant="ghost" size="icon" onclick={toggleLocale} class="w-full text-sidebar-foreground" title={t('common.switchLanguage')}>
291
+ <span class="text-xs font-bold">{localeLabel}</span>
292
+ </Button>
293
+ {/snippet}
294
+ </Tooltip.Trigger>
295
+ <Tooltip.Content side="right">{t('common.switchLanguage')}</Tooltip.Content>
296
+ </Tooltip.Root>
297
+ <Tooltip.Root>
298
+ <Tooltip.Trigger>
299
+ {#snippet child({ props })}
300
+ <Button {...props} variant="ghost" size="icon" onclick={toggleTheme} class="w-full text-sidebar-foreground" title={t('common.toggleTheme')}>
301
+ {#if getResolvedTheme() === 'dark'}
302
+ <Sun class="h-5 w-5" />
303
+ {:else}
304
+ <Moon class="h-5 w-5" />
305
+ {/if}
306
+ </Button>
307
+ {/snippet}
308
+ </Tooltip.Trigger>
309
+ <Tooltip.Content side="right">{t('common.toggleTheme')}</Tooltip.Content>
310
+ </Tooltip.Root>
311
+ <Tooltip.Root>
312
+ <Tooltip.Trigger>
313
+ {#snippet child({ props })}
314
+ <Button {...props} variant="ghost" size="icon" onclick={onLogout} class="w-full text-sidebar-foreground hover:text-destructive" title={t('common.logout')}>
315
+ <LogOut class="h-5 w-5" />
316
+ </Button>
317
+ {/snippet}
318
+ </Tooltip.Trigger>
319
+ <Tooltip.Content side="right">{t('common.logout')}</Tooltip.Content>
320
+ </Tooltip.Root>
209
321
  {:else}
210
322
  <div class="flex gap-1">
211
- <Button variant="ghost" size="icon" onclick={toggleLocale} class="flex-1 text-sidebar-foreground" title="Switch language">
323
+ <Button variant="ghost" size="icon" onclick={toggleLocale} class="flex-1 text-sidebar-foreground" title={t('common.switchLanguage')}>
212
324
  <span class="text-xs font-bold">{localeLabel}</span>
213
325
  </Button>
214
326
  <Button variant="ghost" size="icon" onclick={toggleTheme} class="flex-1 text-sidebar-foreground" title={t('common.toggleTheme')}>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- import { Loader2 } from 'lucide-svelte';
2
+ import { Skeleton } from './ui/skeleton/index.js';
3
+ import type { Snippet } from 'svelte';
3
4
 
4
5
  type ColorVariant = 'primary' | 'success' | 'warning' | 'danger' | 'info';
5
6
  type StyleVariant = 'default' | 'outline' | 'filled';
@@ -21,11 +22,13 @@
21
22
  interface Props {
22
23
  label: string;
23
24
  value: string | number;
24
- icon?: typeof Loader2;
25
+ icon?: import('svelte').Component<{ class?: string }>;
25
26
  trend?: { value: number; label?: string };
26
27
  loading?: boolean;
27
28
  color?: ColorVariant;
28
29
  variant?: StyleVariant;
30
+ /** Optional footer snippet for additional content below the value */
31
+ footer?: Snippet;
29
32
  class?: string;
30
33
  }
31
34
 
@@ -37,6 +40,7 @@
37
40
  loading = false,
38
41
  color = 'primary',
39
42
  variant = 'default',
43
+ footer,
40
44
  class: className = '',
41
45
  }: Props = $props();
42
46
  </script>
@@ -50,7 +54,7 @@
50
54
  <div class="flex-1 min-w-0">
51
55
  <p class="text-sm text-muted-foreground">{label}</p>
52
56
  {#if loading}
53
- <Loader2 class="mt-1 h-5 w-5 animate-spin text-muted-foreground" />
57
+ <Skeleton class="mt-1 h-7 w-20" />
54
58
  {:else}
55
59
  <div class="flex items-baseline gap-2">
56
60
  <p class="text-2xl font-bold text-foreground">{value}</p>
@@ -66,5 +70,10 @@
66
70
  {/if}
67
71
  </div>
68
72
  {/if}
73
+ {#if footer}
74
+ <div class="mt-2">
75
+ {@render footer()}
76
+ </div>
77
+ {/if}
69
78
  </div>
70
79
  </div>
@@ -0,0 +1,139 @@
1
+ <script lang="ts">
2
+ import { fly } from 'svelte/transition';
3
+ import { useStepsForm } from '@svadmin/core';
4
+ import type { FieldDefinition } from '@svadmin/core';
5
+ import { getResource } from '@svadmin/core';
6
+ import { navigate } from '@svadmin/core/router';
7
+ import { t } from '@svadmin/core/i18n';
8
+ import { Button } from './ui/button/index.js';
9
+ import * as Card from './ui/card/index.js';
10
+ import { Badge } from './ui/badge/index.js';
11
+ import { Progress } from './ui/progress/index.js';
12
+ import { Skeleton } from './ui/skeleton/index.js';
13
+ import { Save, ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-svelte';
14
+ import FieldRenderer from './FieldRenderer.svelte';
15
+
16
+ interface StepDef {
17
+ title: string;
18
+ fields: string[];
19
+ }
20
+
21
+ interface Props {
22
+ resourceName: string;
23
+ id?: string | number;
24
+ mode?: 'create' | 'edit';
25
+ steps: StepDef[];
26
+ }
27
+
28
+ let { resourceName, id, mode = 'create', steps }: Props = $props();
29
+
30
+ const resource = getResource(resourceName);
31
+ const primaryKey = resource.primaryKey ?? 'id';
32
+
33
+ const stepsForm = useStepsForm({
34
+ resource: resourceName,
35
+ action: mode,
36
+ id,
37
+ });
38
+
39
+ const { currentStep, next, prev, gotoStep } = stepsForm;
40
+
41
+ const totalSteps = steps.length;
42
+ const progressValue = $derived(((currentStep + 1) / totalSteps) * 100);
43
+ const isLastStep = $derived(currentStep >= totalSteps - 1);
44
+
45
+ // Get fields for current step
46
+ const currentFields = $derived(
47
+ steps[currentStep]?.fields
48
+ .map(key => resource.fields.find(f => f.key === key))
49
+ .filter((f): f is FieldDefinition => f != null && f.key !== primaryKey) ?? []
50
+ );
51
+
52
+ function handleSubmit() {
53
+ if (!isLastStep) {
54
+ next();
55
+ return;
56
+ }
57
+ stepsForm.onFinish(stepsForm.formValues);
58
+ }
59
+ </script>
60
+
61
+ <div class="space-y-6">
62
+ <!-- Step indicators -->
63
+ <div class="space-y-3">
64
+ <div class="flex items-center justify-between text-sm">
65
+ {#each steps as step, i}
66
+ <Button
67
+ variant="ghost"
68
+ type="button"
69
+ class="flex items-center gap-2 transition-colors h-auto py-1 px-2 {i === currentStep ? 'text-primary font-semibold' : 'text-muted-foreground'}"
70
+ onclick={() => gotoStep(i)}
71
+ >
72
+ <span
73
+ class="flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium transition-colors"
74
+ class:bg-primary={i <= currentStep}
75
+ class:text-primary-foreground={i <= currentStep}
76
+ class:bg-muted={i > currentStep}
77
+ class:text-muted-foreground={i > currentStep}
78
+ >
79
+ {#if i < currentStep}
80
+ <Check class="h-3.5 w-3.5" />
81
+ {:else}
82
+ {i + 1}
83
+ {/if}
84
+ </span>
85
+ <span class="hidden sm:inline">{step.title}</span>
86
+ </Button>
87
+ {#if i < steps.length - 1}
88
+ <div class="flex-1 mx-2 h-px bg-border" class:bg-primary={i < currentStep}></div>
89
+ {/if}
90
+ {/each}
91
+ </div>
92
+ <Progress value={progressValue} class="h-1.5" />
93
+ </div>
94
+
95
+ <!-- Step content -->
96
+ <Card.Root>
97
+ <Card.CardHeader>
98
+ <Card.CardTitle>{steps[currentStep]?.title ?? ''}</Card.CardTitle>
99
+ <Badge variant="outline" class="w-fit">
100
+ {t('common.step')} {currentStep + 1} / {totalSteps}
101
+ </Badge>
102
+ </Card.CardHeader>
103
+ <Card.CardContent>
104
+ {#key currentStep}
105
+ <form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} in:fly={{ x: 30, duration: 250 }}>
106
+ {#each currentFields as field}
107
+ <FieldRenderer
108
+ {field}
109
+ value={stepsForm.formValues[field.key]}
110
+ onchange={(v) => stepsForm.setFieldValue(field.key, v)}
111
+ />
112
+ {/each}
113
+
114
+ <div class="flex items-center justify-between pt-4">
115
+ <Button
116
+ type="button"
117
+ variant="outline"
118
+ onclick={() => currentStep === 0 ? navigate(`/${resourceName}`) : prev()}
119
+ >
120
+ <ArrowLeft class="h-4 w-4" data-icon="inline-start" />
121
+ {currentStep === 0 ? t('common.cancel') : t('common.back')}
122
+ </Button>
123
+
124
+ <Button type="submit" disabled={stepsForm.formLoading}>
125
+ {#if stepsForm.formLoading}
126
+ <Loader2 class="h-4 w-4 animate-spin" data-icon="inline-start" />
127
+ {:else if isLastStep}
128
+ <Save class="h-4 w-4" data-icon="inline-start" />
129
+ {:else}
130
+ <ArrowRight class="h-4 w-4" data-icon="inline-start" />
131
+ {/if}
132
+ {isLastStep ? t('common.save') : t('common.next')}
133
+ </Button>
134
+ </div>
135
+ </form>
136
+ {/key}
137
+ </Card.CardContent>
138
+ </Card.Root>
139
+ </div>
@@ -1,58 +1,19 @@
1
1
  <script lang="ts">
2
- import { getToasts, removeToast } from '@svadmin/core/toast';
3
- import { Button } from './ui/button/index.js';
4
- import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-svelte';
5
- import UndoableNotification from './UndoableNotification.svelte';
2
+ import { Toaster } from 'svelte-sonner';
3
+ import { getResolvedTheme } from '@svadmin/core';
6
4
 
7
- const iconMap = {
8
- success: CheckCircle,
9
- error: AlertCircle,
10
- info: Info,
11
- warning: AlertTriangle,
12
- };
13
- const colorMap = {
14
- success: 'bg-green-50 border-green-200 text-green-800',
15
- error: 'bg-red-50 border-red-200 text-red-800',
16
- info: 'bg-blue-50 border-blue-200 text-blue-800',
17
- warning: 'bg-amber-50 border-amber-200 text-amber-800',
18
- };
19
-
20
- const toasts = $derived(getToasts());
5
+ const theme = $derived(getResolvedTheme() === 'dark' ? 'dark' : 'light');
21
6
  </script>
22
7
 
23
- {#if toasts.length > 0}
24
- <div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
25
- {#each toasts as t (t.id)}
26
- {#if t.type === 'undoable'}
27
- <UndoableNotification
28
- message={t.message}
29
- duration={t.duration}
30
- onUndo={() => { t.onUndo?.(); removeToast(t.id); }}
31
- onTimeout={() => { t.onTimeout?.(); removeToast(t.id); }}
32
- />
33
- {:else}
34
- {@const Icon = iconMap[t.type]}
35
- <div
36
- class="flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg animate-slide-in {colorMap[t.type]}"
37
- role="alert"
38
- >
39
- <Icon class="h-5 w-5 flex-shrink-0 mt-0.5" />
40
- <p class="flex-1 text-sm font-medium">{t.message}</p>
41
- <Button variant="ghost" size="icon-xs" onclick={() => removeToast(t.id)} class="flex-shrink-0 -mr-2 -mt-1">
42
- <X class="h-4 w-4" />
43
- </Button>
44
- </div>
45
- {/if}
46
- {/each}
47
- </div>
48
- {/if}
49
-
50
- <style>
51
- @keyframes slide-in {
52
- from { transform: translateX(100%); opacity: 0; }
53
- to { transform: translateX(0); opacity: 1; }
54
- }
55
- .animate-slide-in {
56
- animation: slide-in 0.2s ease-out;
57
- }
58
- </style>
8
+ <Toaster
9
+ position="top-right"
10
+ richColors
11
+ closeButton
12
+ expand
13
+ {theme}
14
+ toastOptions={{
15
+ classes: {
16
+ toast: 'font-sans',
17
+ },
18
+ }}
19
+ />
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import * as Tooltip from './ui/tooltip/index.js';
3
+ import { Button } from './ui/button/index.js';
4
+ import type { Snippet } from 'svelte';
5
+ import type { HTMLButtonAttributes } from 'svelte/elements';
6
+
7
+ interface Props {
8
+ tooltip: string;
9
+ variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
10
+ size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
11
+ side?: 'top' | 'right' | 'bottom' | 'left';
12
+ class?: string;
13
+ onclick?: (e: MouseEvent) => void;
14
+ disabled?: boolean;
15
+ type?: 'button' | 'submit' | 'reset';
16
+ tabindex?: number;
17
+ children: Snippet;
18
+ }
19
+
20
+ let {
21
+ tooltip,
22
+ variant = 'ghost',
23
+ size = 'icon',
24
+ side = 'top',
25
+ class: className = '',
26
+ onclick,
27
+ disabled = false,
28
+ type = 'button',
29
+ tabindex,
30
+ children,
31
+ }: Props = $props();
32
+ </script>
33
+
34
+ <Tooltip.Root>
35
+ <Tooltip.Trigger>
36
+ {#snippet child({ props })}
37
+ <Button
38
+ {...props}
39
+ {variant}
40
+ {size}
41
+ {type}
42
+ class={className}
43
+ {onclick}
44
+ {disabled}
45
+ {tabindex}
46
+ >
47
+ {@render children()}
48
+ </Button>
49
+ {/snippet}
50
+ </Tooltip.Trigger>
51
+ <Tooltip.Content {side}>{tooltip}</Tooltip.Content>
52
+ </Tooltip.Root>