@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.
- package/package.json +8 -3
- package/src/actions.ts +110 -0
- package/src/components/AdminApp.svelte +8 -4
- package/src/components/Authenticated.svelte +38 -13
- package/src/components/AutoForm.svelte +163 -74
- package/src/components/AutoSaveIndicator.svelte +35 -0
- package/src/components/AutoTable.svelte +476 -183
- package/src/components/Breadcrumbs.svelte +22 -17
- package/src/components/CatchAllNavigate.svelte +21 -0
- 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 +43 -0
- package/src/components/DevTools.svelte +99 -230
- package/src/components/DrawerForm.svelte +19 -10
- package/src/components/EditPage.svelte +71 -0
- package/src/components/EmptyState.svelte +21 -31
- package/src/components/ErrorBoundary.svelte +38 -40
- package/src/components/ErrorComponent.svelte +36 -0
- package/src/components/FieldRenderer.svelte +240 -75
- package/src/components/ForgotPasswordPage.svelte +20 -111
- package/src/components/Header.svelte +46 -57
- 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 +71 -0
- package/src/components/LiveIndicator.svelte +58 -0
- package/src/components/LoginPage.svelte +33 -154
- package/src/components/ModalForm.svelte +6 -2
- package/src/components/NavigateToResource.svelte +20 -0
- package/src/components/PageSkeleton.svelte +71 -0
- package/src/components/PasswordInput.svelte +95 -0
- package/src/components/RegisterPage.svelte +35 -164
- package/src/components/ShowPage.svelte +35 -36
- package/src/components/Sidebar.svelte +212 -81
- package/src/components/StatsCard.svelte +12 -3
- package/src/components/StepsForm.svelte +139 -0
- package/src/components/ThemedTitle.svelte +32 -0
- package/src/components/Toast.svelte +15 -44
- 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/CloneButton.svelte +1 -1
- package/src/components/buttons/CreateButton.svelte +1 -1
- package/src/components/buttons/DeleteButton.svelte +3 -4
- package/src/components/buttons/EditButton.svelte +1 -1
- package/src/components/buttons/ImportButton.svelte +29 -7
- package/src/components/buttons/RefreshButton.svelte +11 -5
- package/src/components/fieldComponentMap.ts +79 -0
- package/src/components/fields/BooleanField.svelte +13 -0
- package/src/components/fields/DateField.svelte +6 -0
- package/src/components/fields/EmailField.svelte +9 -0
- package/src/components/fields/FileField.svelte +14 -0
- package/src/components/fields/ImageField.svelte +9 -0
- package/src/components/fields/JsonField.svelte +72 -0
- package/src/components/fields/MarkdownField.svelte +9 -0
- package/src/components/fields/MultiSelectField.svelte +20 -0
- package/src/components/fields/NumberField.svelte +6 -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 +12 -0
- package/src/components/fields/TextField.svelte +11 -0
- package/src/components/fields/UrlField.svelte +14 -0
- 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/avatar/avatar.svelte +2 -1
- 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 +52 -1
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
97
|
-
<nav
|
|
98
|
-
{#each
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
<
|
|
157
|
-
{identity
|
|
158
|
-
|
|
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=
|
|
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
|
-
<
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
{
|
|
186
|
-
</
|
|
187
|
-
<
|
|
188
|
-
<
|
|
189
|
-
|
|
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=
|
|
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 {
|
|
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>
|
|
@@ -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 {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
/>
|