@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
@@ -3,13 +3,17 @@
3
3
  import type { Identity } from '@svadmin/core';
4
4
  import { currentPath, navigate } from '@svadmin/core/router';
5
5
  import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
6
- import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
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,17 +38,43 @@
34
38
  path: string;
35
39
  label: string;
36
40
  Icon: typeof LayoutDashboard;
41
+ group?: string;
37
42
  }
38
43
 
39
- // Use $derived so navItems rebuild when locale changes
40
- const navItems: NavItem[] = $derived([
41
- { path: '/', label: t('common.home'), Icon: LayoutDashboard },
42
- ...resources.map(r => ({
43
- path: `/${r.name}`,
44
- label: r.label,
45
- Icon: iconMap[r.name] ?? Settings,
46
- })),
47
- ]);
44
+ interface NavGroup {
45
+ name: string | null; // null = ungrouped
46
+ items: NavItem[];
47
+ }
48
+
49
+ // Use effect to build nav items, taking access control into account
50
+ let navItems = $state.raw<NavItem[]>([]);
51
+
52
+ $effect(() => {
53
+ // Re-run when locale or resources change
54
+ const localeVal = getLocale();
55
+
56
+ Promise.all(resources.map(async (r) => {
57
+ try {
58
+ const { can } = await canAccessAsync(r.name, 'list');
59
+ return { r, can };
60
+ } catch {
61
+ return { r, can: false };
62
+ }
63
+ })).then(results => {
64
+ const items: NavItem[] = [{ path: '/', label: t('common.home'), Icon: LayoutDashboard }];
65
+ for (const { r, can } of results) {
66
+ if (can) {
67
+ items.push({
68
+ path: `/${r.name}`,
69
+ label: r.label,
70
+ Icon: iconMap[r.name] ?? Settings,
71
+ group: r.group,
72
+ });
73
+ }
74
+ }
75
+ navItems = items;
76
+ });
77
+ });
48
78
 
49
79
  /** Toggle between available locales */
50
80
  function toggleLocale() {
@@ -60,19 +90,39 @@
60
90
 
61
91
  // Track current hash for active state
62
92
  let path = $state(currentPath());
63
- $effect(() => {
64
- function onHash() { path = currentPath(); }
65
- window.addEventListener('hashchange', onHash);
66
- return () => window.removeEventListener('hashchange', onHash);
67
- });
93
+
94
+ function onHashChange() { path = currentPath(); }
68
95
 
69
96
  function isActive(itemPath: string): boolean {
70
97
  if (itemPath === '/') return path === '/';
71
98
  return path.startsWith(itemPath);
72
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());
73
120
  </script>
74
121
 
122
+ <svelte:window onhashchange={onHashChange} />
123
+
75
124
  <aside
125
+ aria-label="Sidebar navigation"
76
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"
77
127
  class:w-64={!collapsed}
78
128
  class:w-16={collapsed}
@@ -93,73 +143,133 @@
93
143
  </Button>
94
144
  </div>
95
145
 
96
- <!-- Nav -->
97
- <nav class="flex-1 overflow-y-auto py-4 px-2 space-y-1">
98
- {#each navItems as item}
99
- {@const active = isActive(item.path)}
100
- {#if collapsed}
101
- <Tooltip.Root>
102
- <Tooltip.Trigger>
103
- {#snippet child({ props })}
104
- <a
105
- {...props}
106
- href={`#${item.path}`}
107
- class="flex items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
108
- class:bg-sidebar-accent={active}
109
- class:text-sidebar-accent-foreground={active}
110
- class:text-sidebar-foreground={!active}
111
- class:hover:bg-sidebar-accent={!active}
112
- >
113
- <item.Icon class="h-5 w-5 flex-shrink-0" />
114
- </a>
115
- {/snippet}
116
- </Tooltip.Trigger>
117
- <Tooltip.Content side="right">
118
- {item.label}
119
- </Tooltip.Content>
120
- </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>
121
181
  {:else}
122
- <a
123
- href={`#${item.path}`}
124
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors"
125
- class:bg-sidebar-accent={active}
126
- class:text-sidebar-accent-foreground={active}
127
- class:text-sidebar-foreground={!active}
128
- class:hover:bg-sidebar-accent={!active}
129
- >
130
- <item.Icon class="h-5 w-5 flex-shrink-0" />
131
- <span>{item.label}</span>
132
- </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}
133
220
  {/if}
134
221
  {/each}
135
222
  </nav>
223
+ </ScrollArea>
136
224
 
137
225
  <!-- Footer -->
138
226
  <Separator class="bg-sidebar-border" />
139
227
  <div class="p-3 space-y-2">
140
- <!-- Color theme picker -->
228
+ <!-- Color theme picker via DropdownMenu -->
141
229
  {#if !collapsed}
142
- <div class="flex items-center justify-center gap-1.5 px-2 py-1">
143
- {#each colorThemes as ct}
144
- <button
145
- 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'}"
146
- style="background-color: {ct.color}; {getColorTheme() === ct.id ? `--tw-ring-color: ${ct.color}` : ''}"
147
- title={ct.label}
148
- onclick={() => setColorTheme(ct.id)}
149
- ></button>
150
- {/each}
151
- </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>
152
259
  {/if}
153
260
 
154
261
  {#if !collapsed && identity}
155
262
  <div class="flex items-center gap-3 rounded-lg px-2 py-2">
156
- <div class="flex h-8 w-8 items-center justify-center rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground">
157
- {identity.name?.charAt(0).toUpperCase() ?? 'U'}
158
- </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
+ />
159
269
  <div class="flex-1 min-w-0">
160
270
  <p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
161
271
  </div>
162
- <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')}>
163
273
  <span class="text-xs font-bold">{localeLabel}</span>
164
274
  </Button>
165
275
  <Button variant="ghost" size="icon-sm" onclick={toggleTheme} class="text-sidebar-foreground" title={t('common.toggleTheme')}>
@@ -174,22 +284,43 @@
174
284
  </Button>
175
285
  </div>
176
286
  {:else if collapsed}
177
- <Button variant="ghost" size="icon" onclick={toggleLocale} class="w-full text-sidebar-foreground" title="Switch language">
178
- <span class="text-xs font-bold">{localeLabel}</span>
179
- </Button>
180
- <Button variant="ghost" size="icon" onclick={toggleTheme} class="w-full text-sidebar-foreground" title={t('common.toggleTheme')}>
181
- {#if getResolvedTheme() === 'dark'}
182
- <Sun class="h-5 w-5" />
183
- {:else}
184
- <Moon class="h-5 w-5" />
185
- {/if}
186
- </Button>
187
- <Button variant="ghost" size="icon" onclick={onLogout} class="w-full text-sidebar-foreground hover:text-destructive" title={t('common.logout')}>
188
- <LogOut class="h-5 w-5" />
189
- </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>
190
321
  {:else}
191
322
  <div class="flex gap-1">
192
- <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')}>
193
324
  <span class="text-xs font-bold">{localeLabel}</span>
194
325
  </Button>
195
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>
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ import { getAdminOptions } from '@svadmin/core';
3
+
4
+ let {
5
+ collapsed = false,
6
+ icon,
7
+ text,
8
+ wrapperStyles = '',
9
+ } = $props<{
10
+ collapsed?: boolean;
11
+ icon?: string;
12
+ text?: string;
13
+ wrapperStyles?: string;
14
+ }>();
15
+
16
+ const adminOptions = getAdminOptions();
17
+ const titleText = $derived(text ?? adminOptions.title?.text ?? 'Admin');
18
+ const titleIcon = $derived(icon ?? adminOptions.title?.icon);
19
+ </script>
20
+
21
+ <div class="flex items-center gap-2 {wrapperStyles}">
22
+ {#if titleIcon}
23
+ <img src={titleIcon} alt="" class="h-6 w-6 shrink-0" />
24
+ {:else}
25
+ <div class="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
26
+ {titleText.charAt(0).toUpperCase()}
27
+ </div>
28
+ {/if}
29
+ {#if !collapsed}
30
+ <span class="text-lg font-semibold text-foreground truncate">{titleText}</span>
31
+ {/if}
32
+ </div>
@@ -1,48 +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';
2
+ import { Toaster } from 'svelte-sonner';
3
+ import { getResolvedTheme } from '@svadmin/core';
5
4
 
6
- const iconMap = {
7
- success: CheckCircle,
8
- error: AlertCircle,
9
- info: Info,
10
- warning: AlertTriangle,
11
- };
12
- const colorMap = {
13
- success: 'bg-green-50 border-green-200 text-green-800',
14
- error: 'bg-red-50 border-red-200 text-red-800',
15
- info: 'bg-blue-50 border-blue-200 text-blue-800',
16
- warning: 'bg-amber-50 border-amber-200 text-amber-800',
17
- };
18
-
19
- const toasts = $derived(getToasts());
5
+ const theme = $derived(getResolvedTheme() === 'dark' ? 'dark' : 'light');
20
6
  </script>
21
7
 
22
- {#if toasts.length > 0}
23
- <div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
24
- {#each toasts as t (t.id)}
25
- {@const Icon = iconMap[t.type]}
26
- <div
27
- class="flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg animate-slide-in {colorMap[t.type]}"
28
- role="alert"
29
- >
30
- <Icon class="h-5 w-5 flex-shrink-0 mt-0.5" />
31
- <p class="flex-1 text-sm font-medium">{t.message}</p>
32
- <Button variant="ghost" size="icon-xs" onclick={() => removeToast(t.id)} class="flex-shrink-0 -mr-2 -mt-1">
33
- <X class="h-4 w-4" />
34
- </Button>
35
- </div>
36
- {/each}
37
- </div>
38
- {/if}
39
-
40
- <style>
41
- @keyframes slide-in {
42
- from { transform: translateX(100%); opacity: 0; }
43
- to { transform: translateX(0); opacity: 1; }
44
- }
45
- .animate-slide-in {
46
- animation: slide-in 0.2s ease-out;
47
- }
48
- </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
+ />