@svadmin/ui 0.36.0 → 0.37.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/dist/app.css +34 -16
- package/dist/components/AutoForm.svelte +32 -47
- package/dist/components/AutoTable.svelte +26 -31
- package/dist/components/Header.svelte +6 -6
- package/dist/components/Layout.svelte +7 -7
- package/dist/components/PageHeader.svelte +7 -9
- package/dist/components/SettingsPage.svelte +75 -41
- package/dist/components/ShowPage.svelte +9 -9
- package/dist/components/Sidebar.svelte +189 -272
- package/dist/components/SidebarItem.svelte +43 -72
- package/package.json +2 -2
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { getResources, canAccessAsync
|
|
2
|
+
import { getResources, canAccessAsync } from '@svadmin/core';
|
|
3
3
|
import type { Identity, MenuItem } from '@svadmin/core';
|
|
4
4
|
import { navigate } from '@svadmin/core/router';
|
|
5
5
|
import { getPath } from '../router-state.svelte.js';
|
|
6
6
|
import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
|
|
7
|
-
import { toggleTheme, getResolvedTheme } from '@svadmin/core';
|
|
7
|
+
import { toggleTheme, getResolvedTheme, getColorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
|
|
8
|
+
import { Button } from './ui/button/index.js';
|
|
8
9
|
import TooltipButton from './TooltipButton.svelte';
|
|
9
10
|
import * as Tooltip from './ui/tooltip/index.js';
|
|
10
|
-
import * as Popover from './ui/popover/index.js';
|
|
11
11
|
import SidebarItem from './SidebarItem.svelte';
|
|
12
12
|
|
|
13
13
|
import { ScrollArea } from './ui/scroll-area/index.js';
|
|
14
|
+
import * as Collapsible from './ui/collapsible/index.js';
|
|
14
15
|
import { Avatar } from './ui/avatar/index.js';
|
|
15
16
|
import {
|
|
16
17
|
LayoutDashboard, FileText, Users, Settings, Home,
|
|
17
18
|
ChevronLeft, ChevronRight, ChevronDown, LogOut, Sun, Moon, Palette,
|
|
18
19
|
Image as ImageIcon, Layout, Folder, Type, Video,
|
|
19
20
|
Download, ListTodo, TrendingUp, Sparkles, Images, Bot, Key, KeyRound, CreditCard, BookOpen, Wrench,
|
|
20
|
-
Repeat, ClipboardCheck, SlidersHorizontal, AlertTriangle,
|
|
21
|
-
|
|
21
|
+
Repeat, ClipboardCheck, SlidersHorizontal, AlertTriangle, Mail, Send, ShoppingBag, Briefcase, Building2,
|
|
22
|
+
Calendar, Bell, Clock, Trash2, Shield
|
|
22
23
|
} from '@lucide/svelte';
|
|
23
24
|
|
|
24
|
-
const authProvider = getAuthProvider({ optional: true });
|
|
25
|
-
|
|
26
25
|
let { collapsed, identity, title, onToggle, onLogout, menu, routeMode = 'auto' }: {
|
|
27
26
|
collapsed: boolean;
|
|
28
27
|
identity: Identity | null;
|
|
@@ -43,6 +42,7 @@
|
|
|
43
42
|
const iconMap: Record<string, typeof LayoutDashboard> = {
|
|
44
43
|
'dashboard': LayoutDashboard,
|
|
45
44
|
'posts': FileText,
|
|
45
|
+
'file': FileText,
|
|
46
46
|
'users': Users,
|
|
47
47
|
'settings': Settings,
|
|
48
48
|
'home': Home,
|
|
@@ -67,14 +67,16 @@
|
|
|
67
67
|
'clipboard-check': ClipboardCheck,
|
|
68
68
|
'sliders-horizontal': SlidersHorizontal,
|
|
69
69
|
'alert-triangle': AlertTriangle,
|
|
70
|
+
'mail': Mail,
|
|
71
|
+
'send': Send,
|
|
72
|
+
'shopping-bag': ShoppingBag,
|
|
73
|
+
'briefcase': Briefcase,
|
|
74
|
+
'building': Building2,
|
|
75
|
+
'calendar': Calendar,
|
|
70
76
|
'bell': Bell,
|
|
71
|
-
'
|
|
72
|
-
'
|
|
77
|
+
'clock': Clock,
|
|
78
|
+
'trash': Trash2,
|
|
73
79
|
'shield': Shield,
|
|
74
|
-
'barcode': Barcode,
|
|
75
|
-
'folder-tree': FolderTree,
|
|
76
|
-
'building-2': Building2,
|
|
77
|
-
'warehouse': Warehouse,
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
interface NavItem {
|
|
@@ -82,7 +84,6 @@
|
|
|
82
84
|
label: string;
|
|
83
85
|
Icon: typeof LayoutDashboard;
|
|
84
86
|
group?: string;
|
|
85
|
-
badge?: string;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
interface NavGroup {
|
|
@@ -90,23 +91,6 @@
|
|
|
90
91
|
items: NavItem[];
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
interface UserSettingsItem {
|
|
94
|
-
key: string;
|
|
95
|
-
path: string;
|
|
96
|
-
icon: typeof LayoutDashboard;
|
|
97
|
-
label: string;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const userSettingsItems: UserSettingsItem[] = [
|
|
101
|
-
{ key: 'account', path: '/settings/account', icon: User, label: 'settings.accountPreferences' },
|
|
102
|
-
{ key: 'access', path: '/settings/access', icon: Lock, label: 'settings.accessSecurity' },
|
|
103
|
-
{ key: 'developer', path: '/settings/developer', icon: Puzzle, label: 'settings.integrationApi' },
|
|
104
|
-
...(authProvider ? [
|
|
105
|
-
{ key: 'audit', path: '/settings/audit', icon: FileSearch, label: 'settings.auditLogs' },
|
|
106
|
-
] : []),
|
|
107
|
-
{ key: 'about', path: '/settings/about', icon: Info, label: 'settings.about' },
|
|
108
|
-
];
|
|
109
|
-
|
|
110
94
|
// Use effect to build nav items, taking access control into account
|
|
111
95
|
let navItems = $state.raw<NavItem[]>([]);
|
|
112
96
|
|
|
@@ -132,7 +116,6 @@
|
|
|
132
116
|
label: r.label,
|
|
133
117
|
Icon: (typeof r.icon === 'string' ? iconMap[r.icon] : r.icon) ?? iconMap[r.name] ?? Settings,
|
|
134
118
|
group: r.group,
|
|
135
|
-
badge: typeof r.meta?.badge === 'string' ? r.meta.badge : undefined,
|
|
136
119
|
});
|
|
137
120
|
}
|
|
138
121
|
}
|
|
@@ -156,32 +139,12 @@
|
|
|
156
139
|
|
|
157
140
|
// Track current hash for active state
|
|
158
141
|
const path = $derived(getPath());
|
|
159
|
-
let userMenuOpen = $state(false);
|
|
160
142
|
|
|
161
143
|
function isActive(itemPath: string): boolean {
|
|
162
144
|
if (itemPath === '/') return path === '/';
|
|
163
145
|
return path === itemPath || path.startsWith(itemPath + "/");
|
|
164
146
|
}
|
|
165
147
|
|
|
166
|
-
function aggregateBadges(items: { badge?: string }[]): string | undefined {
|
|
167
|
-
const total = items.reduce((sum, item) => {
|
|
168
|
-
const value = Number(item.badge);
|
|
169
|
-
return Number.isFinite(value) ? sum + value : sum;
|
|
170
|
-
}, 0);
|
|
171
|
-
|
|
172
|
-
return total > 0 ? String(total) : undefined;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function openUserMenuRoute(path: string) {
|
|
176
|
-
userMenuOpen = false;
|
|
177
|
-
navigate(path);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function handleUserMenuAction(action: () => void) {
|
|
181
|
-
userMenuOpen = false;
|
|
182
|
-
action();
|
|
183
|
-
}
|
|
184
|
-
|
|
185
148
|
// Group nav items by group field (null = ungrouped)
|
|
186
149
|
const navGroups = $derived.by((): NavGroup[] => {
|
|
187
150
|
const groups: NavGroup[] = [];
|
|
@@ -199,38 +162,11 @@
|
|
|
199
162
|
return groups;
|
|
200
163
|
});
|
|
201
164
|
|
|
202
|
-
|
|
203
|
-
let
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function isAutoGroupOpen(group: NavGroup): boolean {
|
|
210
|
-
if (!group.name) return true;
|
|
211
|
-
return autoGroupOpen[group.name] ?? true;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function toggleAutoGroup(group: NavGroup) {
|
|
215
|
-
if (!group.name) return;
|
|
216
|
-
const currentOpen = isAutoGroupOpen(group);
|
|
217
|
-
autoGroupTouched = { ...autoGroupTouched, [group.name]: true };
|
|
218
|
-
autoGroupOpen = { ...autoGroupOpen, [group.name]: !currentOpen };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
$effect(() => {
|
|
222
|
-
let nextOpen = autoGroupOpen;
|
|
223
|
-
let changed = false;
|
|
224
|
-
|
|
225
|
-
for (const group of navGroups) {
|
|
226
|
-
if (!group.name || autoGroupTouched[group.name] || !isGroupActive(group) || autoGroupOpen[group.name]) continue;
|
|
227
|
-
if (!changed) nextOpen = { ...autoGroupOpen };
|
|
228
|
-
nextOpen[group.name] = true;
|
|
229
|
-
changed = true;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (changed) autoGroupOpen = nextOpen;
|
|
233
|
-
});
|
|
165
|
+
// Track which groups are open
|
|
166
|
+
let openGroups = $state<Set<string>>(new Set());
|
|
167
|
+
let colorPickerOpen = $state(false);
|
|
168
|
+
let colorPickerRef = $state<HTMLDivElement | null>(null);
|
|
169
|
+
let colorPickerOpenedAt = 0;
|
|
234
170
|
|
|
235
171
|
// Density state
|
|
236
172
|
let density = $state<'compact' | 'standard'>('standard');
|
|
@@ -251,50 +187,57 @@
|
|
|
251
187
|
|
|
252
188
|
const pyClass = $derived(density === 'compact' ? 'py-1.5' : 'py-2.5');
|
|
253
189
|
const pyClassGroupItem = $derived(density === 'compact' ? 'py-1.5' : 'py-2');
|
|
190
|
+
const footerIconClass = 'h-9 w-9 rounded-lg text-sidebar-foreground/60 transition-all duration-200 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground';
|
|
254
191
|
|
|
192
|
+
// Click-outside to close color picker
|
|
193
|
+
$effect(() => {
|
|
194
|
+
if (!colorPickerOpen) return;
|
|
195
|
+
function handleMouseDown(e: MouseEvent) {
|
|
196
|
+
// Ignore if opened within last 200ms (same interaction)
|
|
197
|
+
if (Date.now() - colorPickerOpenedAt < 200) return;
|
|
198
|
+
if (colorPickerRef && !colorPickerRef.contains(e.target as Node)) {
|
|
199
|
+
colorPickerOpen = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
document.addEventListener('mousedown', handleMouseDown, true);
|
|
203
|
+
return () => {
|
|
204
|
+
document.removeEventListener('mousedown', handleMouseDown, true);
|
|
205
|
+
};
|
|
206
|
+
});
|
|
255
207
|
</script>
|
|
256
208
|
|
|
257
209
|
|
|
258
210
|
|
|
259
211
|
<aside
|
|
260
212
|
aria-label="Sidebar navigation"
|
|
261
|
-
class="fixed inset-y-0 left-0 z-30 flex flex-col
|
|
262
|
-
|
|
213
|
+
class="fixed inset-y-0 left-0 z-30 flex flex-col backdrop-blur-xl transition-all duration-300"
|
|
214
|
+
style="background-color: var(--sidebar);"
|
|
215
|
+
class:w-64={!collapsed}
|
|
263
216
|
class:w-16={collapsed}
|
|
264
|
-
data-sidebar="sidebar"
|
|
265
|
-
data-collapsed={collapsed ? "true" : "false"}
|
|
266
217
|
>
|
|
267
218
|
<!-- Logo -->
|
|
268
|
-
<div class="flex
|
|
219
|
+
<div class="flex h-16 items-center justify-between px-4">
|
|
269
220
|
{#if !collapsed}
|
|
270
|
-
<div class="flex
|
|
271
|
-
<div class="
|
|
272
|
-
|
|
273
|
-
</div>
|
|
274
|
-
<div class="min-w-0 flex-1 text-left">
|
|
275
|
-
<span class="block truncate text-[1.15rem] font-semibold leading-6 tracking-tight text-sidebar-foreground">{title}</span>
|
|
276
|
-
<span class="block truncate text-xs font-medium leading-4 text-sidebar-foreground/50">{getLocale() === 'zh-CN' ? '管理控制台' : 'Admin Console'}</span>
|
|
277
|
-
</div>
|
|
221
|
+
<div class="flex items-center gap-3">
|
|
222
|
+
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-primary to-purple-500 shrink-0" style="box-shadow: 0 4px 12px oklch(0.488 0.243 264.376 / 20%);"></div>
|
|
223
|
+
<span class="font-semibold text-lg tracking-tight text-sidebar-foreground">{title}</span>
|
|
278
224
|
</div>
|
|
279
225
|
{:else}
|
|
280
|
-
<div class="
|
|
281
|
-
{title.slice(0, 1).toUpperCase()}
|
|
282
|
-
</div>
|
|
226
|
+
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-primary to-purple-500 shrink-0 mx-auto" style="box-shadow: 0 4px 12px oklch(0.488 0.243 264.376 / 20%);"></div>
|
|
283
227
|
{/if}
|
|
284
228
|
{#if !collapsed}
|
|
285
|
-
<TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle}
|
|
229
|
+
<TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle}>
|
|
286
230
|
<ChevronLeft class="h-4 w-4" />
|
|
287
231
|
</TooltipButton>
|
|
288
232
|
{:else}
|
|
289
|
-
<TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle} class="mx-auto
|
|
233
|
+
<TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle} class="mx-auto mt-2 mb-2 block">
|
|
290
234
|
<ChevronRight class="h-4 w-4" />
|
|
291
235
|
</TooltipButton>
|
|
292
236
|
{/if}
|
|
293
237
|
</div>
|
|
294
238
|
|
|
295
|
-
<
|
|
296
|
-
<
|
|
297
|
-
<nav aria-label="Main menu" class="space-y-2 py-4 px-2">
|
|
239
|
+
<ScrollArea class="flex-1">
|
|
240
|
+
<nav aria-label="Main menu" class="py-4 px-3 space-y-1">
|
|
298
241
|
{#if menu && menu.length > 0}
|
|
299
242
|
<!-- Custom multi-level menu -->
|
|
300
243
|
{#each menu as item, _i (_i)}
|
|
@@ -304,51 +247,37 @@
|
|
|
304
247
|
<!-- Auto-generated menu from resources (fallback) -->
|
|
305
248
|
{#each navGroups as group, _i (_i)}
|
|
306
249
|
{#if group.name && !collapsed}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
aria-expanded={groupOpen}
|
|
316
|
-
onclick={() => toggleAutoGroup(group)}
|
|
250
|
+
<!-- Grouped section with Collapsible -->
|
|
251
|
+
<Collapsible.Root open={openGroups.has(group.name)} onOpenChange={(isOpen) => {
|
|
252
|
+
const next = new Set(openGroups);
|
|
253
|
+
if (isOpen) next.add(group.name as string); else next.delete(group.name as string);
|
|
254
|
+
openGroups = next;
|
|
255
|
+
}}>
|
|
256
|
+
<Collapsible.Trigger
|
|
257
|
+
class="flex w-full items-center justify-between px-3 py-1.5 text-sm font-semibold tracking-wider text-sidebar-foreground/60 hover:text-sidebar-foreground transition-colors"
|
|
317
258
|
>
|
|
318
|
-
<span
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
</span>
|
|
324
|
-
<ChevronDown class="h-3.5 w-3.5 shrink-0 transition-transform duration-200 {groupOpen ? 'rotate-180' : ''}" />
|
|
325
|
-
</button>
|
|
326
|
-
{#if groupOpen}
|
|
327
|
-
<div class="mt-0.5 divide-y divide-border/45" data-sidebar="menu-sub">
|
|
259
|
+
<span>{group.name}</span>
|
|
260
|
+
<ChevronDown class="h-3 w-3 transition-transform {openGroups.has(group.name) ? 'rotate-180' : ''}" />
|
|
261
|
+
</Collapsible.Trigger>
|
|
262
|
+
<Collapsible.Content>
|
|
263
|
+
<div class="mt-1 space-y-1">
|
|
328
264
|
{#each group.items as item, _i (_i)}
|
|
329
265
|
{@const active = isActive(item.path)}
|
|
330
266
|
<a
|
|
331
267
|
href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
|
|
332
268
|
onclick={(e) => { e.preventDefault(); navigate(item.path); }}
|
|
333
|
-
class="
|
|
269
|
+
class="flex items-center gap-3 rounded-xl px-3 {pyClassGroupItem} text-sm font-medium transition-all duration-300
|
|
334
270
|
{active
|
|
335
|
-
? 'text-
|
|
336
|
-
: 'text-sidebar-foreground/
|
|
337
|
-
data-sidebar="menu-sub-button"
|
|
338
|
-
data-active={active ? "true" : "false"}
|
|
271
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20 translate-x-1'
|
|
272
|
+
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
|
|
339
273
|
>
|
|
340
|
-
<
|
|
341
|
-
|
|
342
|
-
</span>
|
|
343
|
-
<span class="min-w-0 flex-1 truncate">{item.label}</span>
|
|
344
|
-
{#if item.badge}
|
|
345
|
-
<span class="rounded-full {active ? 'bg-primary-foreground/20 text-primary-foreground' : 'bg-primary/10 text-primary'} px-2 py-0.5 text-[10px] font-semibold">{item.badge}</span>
|
|
346
|
-
{/if}
|
|
274
|
+
<item.Icon class="h-4 w-4 flex-shrink-0" />
|
|
275
|
+
<span>{item.label}</span>
|
|
347
276
|
</a>
|
|
348
277
|
{/each}
|
|
349
278
|
</div>
|
|
350
|
-
|
|
351
|
-
</
|
|
279
|
+
</Collapsible.Content>
|
|
280
|
+
</Collapsible.Root>
|
|
352
281
|
{:else}
|
|
353
282
|
<!-- Ungrouped items (flat) -->
|
|
354
283
|
{#each group.items as item, _i (_i)}
|
|
@@ -361,12 +290,10 @@
|
|
|
361
290
|
{...props}
|
|
362
291
|
href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
|
|
363
292
|
onclick={(e) => { e.preventDefault(); navigate(item.path); }}
|
|
364
|
-
class="
|
|
293
|
+
class="flex items-center justify-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-300
|
|
365
294
|
{active
|
|
366
|
-
? 'text-
|
|
367
|
-
: 'text-sidebar-foreground/60 hover:text-sidebar-foreground'}"
|
|
368
|
-
data-sidebar="menu-button"
|
|
369
|
-
data-active={active ? "true" : "false"}
|
|
295
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20'
|
|
296
|
+
: 'text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground'}"
|
|
370
297
|
>
|
|
371
298
|
<item.Icon class="h-4 w-4 flex-shrink-0" />
|
|
372
299
|
</a>
|
|
@@ -380,19 +307,13 @@
|
|
|
380
307
|
<a
|
|
381
308
|
href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
|
|
382
309
|
onclick={(e) => { e.preventDefault(); navigate(item.path); }}
|
|
383
|
-
class="
|
|
310
|
+
class="flex items-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-300
|
|
384
311
|
{active
|
|
385
|
-
? 'text-
|
|
386
|
-
: 'text-sidebar-foreground/
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
<item.Icon class="h-4 w-4 flex-shrink-0" />
|
|
391
|
-
</span>
|
|
392
|
-
<span class="min-w-0 flex-1 truncate">{item.label}</span>
|
|
393
|
-
{#if item.badge}
|
|
394
|
-
<span class="rounded-full {active ? 'bg-primary-foreground/20 text-primary-foreground' : 'bg-primary/10 text-primary'} px-2 py-0.5 text-[10px] font-semibold">{item.badge}</span>
|
|
395
|
-
{/if}
|
|
312
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20 translate-x-1'
|
|
313
|
+
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
|
|
314
|
+
>
|
|
315
|
+
<item.Icon class="h-4 w-4 flex-shrink-0" />
|
|
316
|
+
<span>{item.label}</span>
|
|
396
317
|
</a>
|
|
397
318
|
{/if}
|
|
398
319
|
{/each}
|
|
@@ -401,133 +322,129 @@
|
|
|
401
322
|
{/if}
|
|
402
323
|
</nav>
|
|
403
324
|
</ScrollArea>
|
|
404
|
-
</div>
|
|
405
325
|
|
|
406
326
|
<!-- Footer -->
|
|
407
|
-
<div
|
|
408
|
-
<!--
|
|
409
|
-
{#if !collapsed
|
|
410
|
-
<div class="px-3
|
|
411
|
-
<
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
<p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
|
|
428
|
-
<p class="truncate text-[10px] text-sidebar-foreground/50">{((identity as Record<string, unknown>).role || (identity as Record<string, unknown>).roleName) ?? 'User'}</p>
|
|
429
|
-
</div>
|
|
430
|
-
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-sidebar-foreground/45 transition-transform {userMenuOpen ? 'rotate-180' : ''}" />
|
|
431
|
-
</button>
|
|
432
|
-
{/snippet}
|
|
433
|
-
</Popover.Trigger>
|
|
434
|
-
<Popover.Content align="start" side="right" sideOffset={10} class="w-56 p-2">
|
|
435
|
-
<div class="space-y-1">
|
|
436
|
-
<button type="button" class="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground" onclick={() => handleUserMenuAction(toggleLocale)}>
|
|
437
|
-
<Languages class="h-4 w-4 shrink-0" />
|
|
438
|
-
<span class="min-w-0 flex-1 truncate">{t('common.switchLanguage')}</span>
|
|
439
|
-
<span class="text-xs font-semibold text-muted-foreground">{localeLabel}</span>
|
|
440
|
-
</button>
|
|
441
|
-
<button type="button" class="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground" onclick={() => handleUserMenuAction(toggleTheme)}>
|
|
442
|
-
{#if getResolvedTheme() === 'dark'}
|
|
443
|
-
<Sun class="h-4 w-4 shrink-0" />
|
|
444
|
-
{:else}
|
|
445
|
-
<Moon class="h-4 w-4 shrink-0" />
|
|
446
|
-
{/if}
|
|
447
|
-
<span class="min-w-0 flex-1 truncate">{t('common.toggleTheme')}</span>
|
|
448
|
-
</button>
|
|
449
|
-
<div class="my-1 h-px bg-border/60"></div>
|
|
450
|
-
{#each userSettingsItems as item, _i (_i)}
|
|
451
|
-
{@const active = isActive(item.path)}
|
|
327
|
+
<div>
|
|
328
|
+
<!-- Color theme picker -->
|
|
329
|
+
{#if !collapsed}
|
|
330
|
+
<div class="px-3 pt-3 pb-1">
|
|
331
|
+
<div class="relative" bind:this={colorPickerRef}>
|
|
332
|
+
<Button
|
|
333
|
+
variant="ghost"
|
|
334
|
+
class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors h-auto justify-start"
|
|
335
|
+
onclick={() => { if (!colorPickerOpen) colorPickerOpenedAt = Date.now(); colorPickerOpen = !colorPickerOpen; }}
|
|
336
|
+
>
|
|
337
|
+
<Palette class="h-4 w-4" />
|
|
338
|
+
<span class="flex-1 text-left text-xs">{t('common.toggleTheme')}</span>
|
|
339
|
+
<span
|
|
340
|
+
class="h-3.5 w-3.5 rounded-full ring-1 ring-offset-1 ring-offset-sidebar"
|
|
341
|
+
style="background-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}; --tw-ring-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}"
|
|
342
|
+
></span>
|
|
343
|
+
</Button>
|
|
344
|
+
{#if colorPickerOpen}
|
|
345
|
+
<div class="absolute bottom-full left-0 mb-1 z-50 w-40 rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95">
|
|
346
|
+
{#each getColorThemes() as ct, _i (_i)}
|
|
452
347
|
<button
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
{active
|
|
456
|
-
? 'bg-muted text-foreground font-medium'
|
|
457
|
-
: 'text-muted-foreground hover:bg-muted/70 hover:text-foreground'}"
|
|
458
|
-
onclick={() => openUserMenuRoute(item.path)}
|
|
348
|
+
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
349
|
+
onclick={() => { setColorTheme(ct.id as typeof ct.id & import('@svadmin/core').ColorTheme); colorPickerOpen = false; }}
|
|
459
350
|
>
|
|
460
|
-
<
|
|
461
|
-
|
|
351
|
+
<span
|
|
352
|
+
class="h-3.5 w-3.5 rounded-full {getColorTheme() === ct.id ? 'ring-2 ring-offset-1 scale-110' : 'opacity-70'}"
|
|
353
|
+
style="background-color: {ct.color}; {getColorTheme() === ct.id ? `--tw-ring-color: ${ct.color}` : ''}"
|
|
354
|
+
></span>
|
|
355
|
+
<span class="text-xs">{ct.label}</span>
|
|
462
356
|
</button>
|
|
463
357
|
{/each}
|
|
464
|
-
<div class="my-1 h-px bg-border/60"></div>
|
|
465
|
-
<button type="button" class="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" onclick={() => handleUserMenuAction(onLogout)}>
|
|
466
|
-
<LogOut class="h-4 w-4 shrink-0" />
|
|
467
|
-
<span class="min-w-0 flex-1 truncate">{t('common.logout')}</span>
|
|
468
|
-
</button>
|
|
469
358
|
</div>
|
|
470
|
-
|
|
471
|
-
</
|
|
359
|
+
{/if}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
{/if}
|
|
363
|
+
|
|
364
|
+
<!-- User info -->
|
|
365
|
+
{#if !collapsed && identity}
|
|
366
|
+
<div class="px-3 pb-3 pt-1 transition-all duration-200">
|
|
367
|
+
<div class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-sidebar-accent/50">
|
|
368
|
+
<Avatar
|
|
369
|
+
src={(identity as Record<string, unknown>).avatar as string | undefined}
|
|
370
|
+
alt={identity.name ?? 'User'}
|
|
371
|
+
fallback={identity.name?.charAt(0).toUpperCase() ?? 'U'}
|
|
372
|
+
size="sm"
|
|
373
|
+
/>
|
|
374
|
+
<div class="flex-1 min-w-0">
|
|
375
|
+
<p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
|
|
376
|
+
<p class="truncate text-[10px] text-sidebar-foreground/50">{((identity as Record<string, unknown>).role || (identity as Record<string, unknown>).roleName) ?? 'User'}</p>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
<div class="mt-1 grid grid-cols-4 place-items-center gap-1 px-1">
|
|
380
|
+
<TooltipButton tooltip={t('common.switchLanguage')} variant="ghost" size="icon-sm" onclick={toggleLocale} class={footerIconClass}>
|
|
381
|
+
<span class="text-xs font-bold">{localeLabel}</span>
|
|
382
|
+
</TooltipButton>
|
|
383
|
+
<TooltipButton tooltip={t('common.toggleTheme')} variant="ghost" size="icon-sm" onclick={toggleTheme} class={footerIconClass}>
|
|
384
|
+
{#if getResolvedTheme() === 'dark'}
|
|
385
|
+
<Sun class="h-4 w-4" />
|
|
386
|
+
{:else}
|
|
387
|
+
<Moon class="h-4 w-4" />
|
|
388
|
+
{/if}
|
|
389
|
+
</TooltipButton>
|
|
390
|
+
<TooltipButton tooltip={t('settings.title')} variant="ghost" size="icon-sm" onclick={() => navigate('/settings')} class={footerIconClass}>
|
|
391
|
+
<Settings class="h-4 w-4" />
|
|
392
|
+
</TooltipButton>
|
|
393
|
+
<TooltipButton tooltip={t('common.logout')} variant="ghost" size="icon-sm" onclick={onLogout} class="{footerIconClass} hover:text-destructive">
|
|
394
|
+
<LogOut class="h-4 w-4" />
|
|
395
|
+
</TooltipButton>
|
|
396
|
+
</div>
|
|
472
397
|
</div>
|
|
473
398
|
{:else if collapsed}
|
|
474
|
-
<div class="flex flex-col items-center px-
|
|
475
|
-
|
|
476
|
-
<
|
|
477
|
-
<
|
|
478
|
-
|
|
479
|
-
<
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
<
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
class="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm transition-colors
|
|
510
|
-
{active
|
|
511
|
-
? 'bg-muted text-foreground font-medium'
|
|
512
|
-
: 'text-muted-foreground hover:bg-muted/70 hover:text-foreground'}"
|
|
513
|
-
onclick={() => openUserMenuRoute(item.path)}
|
|
514
|
-
>
|
|
515
|
-
<item.icon class="h-4 w-4 shrink-0" />
|
|
516
|
-
<span class="min-w-0 flex-1 truncate">{t(item.label)}</span>
|
|
517
|
-
</button>
|
|
518
|
-
{/each}
|
|
519
|
-
<div class="my-1 h-px bg-border/60"></div>
|
|
520
|
-
<button type="button" class="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" onclick={() => handleUserMenuAction(onLogout)}>
|
|
521
|
-
<LogOut class="h-4 w-4 shrink-0" />
|
|
522
|
-
<span class="min-w-0 flex-1 truncate">{t('common.logout')}</span>
|
|
523
|
-
</button>
|
|
524
|
-
</div>
|
|
525
|
-
</Popover.Content>
|
|
526
|
-
</Popover.Root>
|
|
527
|
-
{/if}
|
|
399
|
+
<div class="flex flex-col items-center gap-1 px-3 py-3 transition-all duration-200">
|
|
400
|
+
<Tooltip.Root>
|
|
401
|
+
<Tooltip.Trigger>
|
|
402
|
+
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
403
|
+
<Button {...props} variant="ghost" size="icon" onclick={toggleLocale} class={footerIconClass}>
|
|
404
|
+
<span class="text-xs font-bold">{localeLabel}</span>
|
|
405
|
+
</Button>
|
|
406
|
+
{/snippet}
|
|
407
|
+
</Tooltip.Trigger>
|
|
408
|
+
<Tooltip.Content side="right">{t('common.switchLanguage')}</Tooltip.Content>
|
|
409
|
+
</Tooltip.Root>
|
|
410
|
+
<Tooltip.Root>
|
|
411
|
+
<Tooltip.Trigger>
|
|
412
|
+
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
413
|
+
<Button {...props} variant="ghost" size="icon" onclick={toggleTheme} class={footerIconClass}>
|
|
414
|
+
{#if getResolvedTheme() === 'dark'}
|
|
415
|
+
<Sun class="h-4 w-4" />
|
|
416
|
+
{:else}
|
|
417
|
+
<Moon class="h-4 w-4" />
|
|
418
|
+
{/if}
|
|
419
|
+
</Button>
|
|
420
|
+
{/snippet}
|
|
421
|
+
</Tooltip.Trigger>
|
|
422
|
+
<Tooltip.Content side="right">{t('common.toggleTheme')}</Tooltip.Content>
|
|
423
|
+
</Tooltip.Root>
|
|
424
|
+
<Tooltip.Root>
|
|
425
|
+
<Tooltip.Trigger>
|
|
426
|
+
{#snippet child({ props }: { props: Record<string, unknown> })}
|
|
427
|
+
<Button {...props} variant="ghost" size="icon" onclick={onLogout} class="{footerIconClass} hover:text-destructive">
|
|
428
|
+
<LogOut class="h-4 w-4" />
|
|
429
|
+
</Button>
|
|
430
|
+
{/snippet}
|
|
431
|
+
</Tooltip.Trigger>
|
|
432
|
+
<Tooltip.Content side="right">{t('common.logout')}</Tooltip.Content>
|
|
433
|
+
</Tooltip.Root>
|
|
528
434
|
</div>
|
|
529
435
|
{:else}
|
|
530
|
-
<div class="
|
|
436
|
+
<div class="flex justify-center gap-2 p-3 transition-all duration-200">
|
|
437
|
+
<TooltipButton tooltip={t('common.switchLanguage')} variant="ghost" size="icon" onclick={toggleLocale} class={footerIconClass}>
|
|
438
|
+
<span class="text-xs font-bold">{localeLabel}</span>
|
|
439
|
+
</TooltipButton>
|
|
440
|
+
<TooltipButton tooltip={t('common.toggleTheme')} variant="ghost" size="icon" onclick={toggleTheme} class={footerIconClass}>
|
|
441
|
+
{#if getResolvedTheme() === 'dark'}
|
|
442
|
+
<Sun class="h-4 w-4" />
|
|
443
|
+
{:else}
|
|
444
|
+
<Moon class="h-4 w-4" />
|
|
445
|
+
{/if}
|
|
446
|
+
</TooltipButton>
|
|
447
|
+
</div>
|
|
531
448
|
{/if}
|
|
532
449
|
</div>
|
|
533
450
|
</aside>
|