@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.
- package/package.json +9 -4
- package/src/actions.ts +110 -0
- package/src/components/AdminApp.svelte +7 -4
- package/src/components/Authenticated.svelte +1 -7
- package/src/components/AutoForm.svelte +158 -69
- package/src/components/AutoSaveIndicator.svelte +19 -14
- package/src/components/AutoTable.svelte +465 -243
- package/src/components/Breadcrumbs.svelte +20 -17
- package/src/components/CanAccess.svelte +6 -4
- package/src/components/ComboboxField.svelte +122 -0
- package/src/components/CommandPalette.svelte +93 -0
- package/src/components/ConfigErrorScreen.svelte +53 -151
- package/src/components/ConfirmDialog.svelte +25 -17
- package/src/components/CreatePage.svelte +3 -2
- package/src/components/DevTools.svelte +99 -230
- package/src/components/DrawerForm.svelte +19 -10
- package/src/components/EditPage.svelte +25 -6
- package/src/components/EmptyState.svelte +5 -1
- package/src/components/ErrorBoundary.svelte +38 -40
- package/src/components/ErrorComponent.svelte +18 -17
- package/src/components/FieldRenderer.svelte +240 -75
- package/src/components/ForgotPasswordPage.svelte +20 -111
- package/src/components/Header.svelte +28 -8
- package/src/components/InferencerPanel.svelte +136 -110
- package/src/components/InfiniteList.svelte +93 -0
- package/src/components/Layout.svelte +85 -22
- package/src/components/ListPage.svelte +25 -1
- package/src/components/LiveIndicator.svelte +30 -87
- package/src/components/LoginPage.svelte +81 -155
- package/src/components/ModalForm.svelte +6 -2
- package/src/components/PageSkeleton.svelte +2 -1
- package/src/components/PasswordInput.svelte +95 -0
- package/src/components/ProfilePage.svelte +166 -0
- package/src/components/RegisterPage.svelte +35 -164
- package/src/components/ShowPage.svelte +32 -33
- package/src/components/Sidebar.svelte +185 -73
- package/src/components/StatsCard.svelte +12 -3
- package/src/components/StepsForm.svelte +139 -0
- package/src/components/Toast.svelte +15 -54
- package/src/components/TooltipButton.svelte +52 -0
- package/src/components/UndoableNotification.svelte +28 -96
- package/src/components/UpdatePasswordPage.svelte +32 -160
- package/src/components/buttons/RefreshButton.svelte +7 -2
- package/src/components/fieldComponentMap.ts +79 -0
- package/src/components/fields/BooleanField.svelte +9 -9
- package/src/components/fields/EmailField.svelte +2 -8
- package/src/components/fields/FileField.svelte +4 -9
- package/src/components/fields/ImageField.svelte +2 -7
- package/src/components/fields/JsonField.svelte +72 -0
- package/src/components/fields/MarkdownField.svelte +2 -12
- package/src/components/fields/MultiSelectField.svelte +20 -0
- package/src/components/fields/RelationField.svelte +24 -0
- package/src/components/fields/RichTextField.svelte +35 -0
- package/src/components/fields/SelectField.svelte +14 -0
- package/src/components/fields/TagField.svelte +6 -11
- package/src/components/fields/TextField.svelte +2 -7
- package/src/components/fields/UrlField.svelte +4 -10
- package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
- package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
- package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
- package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
- package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
- package/src/components/ui/alert-dialog/index.ts +20 -0
- package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
- package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
- package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
- package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
- package/src/components/ui/breadcrumb/index.ts +7 -0
- package/src/components/ui/collapsible/index.ts +7 -0
- package/src/components/ui/command/command-dialog.svelte +31 -0
- package/src/components/ui/command/index.ts +2 -0
- package/src/components/ui/context-menu/index.ts +11 -0
- package/src/components/ui/drawer/drawer-content.svelte +21 -0
- package/src/components/ui/drawer/drawer-description.svelte +11 -0
- package/src/components/ui/drawer/drawer-footer.svelte +11 -0
- package/src/components/ui/drawer/drawer-header.svelte +11 -0
- package/src/components/ui/drawer/drawer-title.svelte +11 -0
- package/src/components/ui/drawer/drawer.svelte +18 -0
- package/src/components/ui/drawer/index.ts +6 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
- package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
- package/src/components/ui/dropdown-menu/index.ts +6 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/label/label.svelte +18 -0
- package/src/components/ui/pagination/index.ts +7 -0
- package/src/components/ui/pagination/pagination-content.svelte +11 -0
- package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
- package/src/components/ui/pagination/pagination-item.svelte +11 -0
- package/src/components/ui/pagination/pagination-link.svelte +26 -0
- package/src/components/ui/pagination/pagination-next.svelte +22 -0
- package/src/components/ui/pagination/pagination-previous.svelte +22 -0
- package/src/components/ui/pagination/pagination.svelte +11 -0
- package/src/components/ui/popover/index.ts +12 -0
- package/src/components/ui/popover/popover-content.svelte +25 -0
- package/src/components/ui/progress/index.ts +1 -0
- package/src/components/ui/progress/progress.svelte +22 -0
- package/src/components/ui/scroll-area/index.ts +1 -0
- package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
- package/src/components/ui/sheet/index.ts +15 -1
- package/src/components/ui/sheet/sheet-content.svelte +28 -0
- package/src/components/ui/sheet/sheet-description.svelte +22 -0
- package/src/components/ui/sheet/sheet-footer.svelte +22 -0
- package/src/components/ui/sheet/sheet-header.svelte +22 -0
- package/src/components/ui/sheet/sheet-title.svelte +22 -0
- package/src/index.ts +31 -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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
116
|
-
<nav
|
|
117
|
-
{#each
|
|
118
|
-
{
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
<
|
|
176
|
-
{identity
|
|
177
|
-
|
|
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=
|
|
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
|
-
<
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
{
|
|
205
|
-
</
|
|
206
|
-
<
|
|
207
|
-
<
|
|
208
|
-
|
|
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=
|
|
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 {
|
|
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?:
|
|
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
|
-
<
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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>
|