@svadmin/ui 0.34.10 → 0.36.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.
Files changed (71) hide show
  1. package/dist/app.css +746 -22
  2. package/dist/component-registry.svelte.d.ts +1 -0
  3. package/dist/components/AdminApp.svelte +94 -15
  4. package/dist/components/AdminApp.svelte.d.ts +32 -2
  5. package/dist/components/ApiSettings.svelte +191 -0
  6. package/dist/components/ApiSettings.svelte.d.ts +3 -0
  7. package/dist/components/AutoForm.svelte +47 -42
  8. package/dist/components/AutoTable.svelte +29 -24
  9. package/dist/components/ErrorPage.svelte +58 -0
  10. package/dist/components/ErrorPage.svelte.d.ts +8 -0
  11. package/dist/components/FieldRenderer.svelte +2 -2
  12. package/dist/components/ForgotPasswordPage.svelte +111 -68
  13. package/dist/components/Header.svelte +6 -7
  14. package/dist/components/IntegrationsSettings.svelte +70 -0
  15. package/dist/components/IntegrationsSettings.svelte.d.ts +4 -0
  16. package/dist/components/Layout.svelte +7 -7
  17. package/dist/components/LoginPage.svelte +207 -105
  18. package/dist/components/LoginPage.svelte.d.ts +3 -15
  19. package/dist/components/NotificationsSettings.svelte +115 -0
  20. package/dist/components/NotificationsSettings.svelte.d.ts +3 -0
  21. package/dist/components/PageHeader.svelte +9 -7
  22. package/dist/components/RegisterPage.svelte +103 -65
  23. package/dist/components/ResourceOperationsPage.svelte +151 -0
  24. package/dist/components/ResourceOperationsPage.svelte.d.ts +37 -0
  25. package/dist/components/SecuritySettings.svelte +179 -0
  26. package/dist/components/SecuritySettings.svelte.d.ts +4 -0
  27. package/dist/components/SettingsPage.svelte +120 -87
  28. package/dist/components/SettingsPage.svelte.d.ts +25 -1
  29. package/dist/components/ShowPage.svelte +9 -9
  30. package/dist/components/Sidebar.svelte +278 -189
  31. package/dist/components/SidebarItem.svelte +77 -18
  32. package/dist/components/TwoFactorAuthPage.svelte +247 -0
  33. package/dist/components/TwoFactorAuthPage.svelte.d.ts +3 -0
  34. package/dist/components/UpdatePasswordPage.svelte +86 -48
  35. package/dist/components/account/CompanyProfilePage.svelte +99 -0
  36. package/dist/components/account/CompanyProfilePage.svelte.d.ts +3 -0
  37. package/dist/components/account/GetStartedPage.svelte +123 -0
  38. package/dist/components/account/GetStartedPage.svelte.d.ts +4 -0
  39. package/dist/components/account/ImportMembersPage.svelte +143 -0
  40. package/dist/components/account/ImportMembersPage.svelte.d.ts +3 -0
  41. package/dist/components/account/MembersStarterPage.svelte +104 -0
  42. package/dist/components/account/MembersStarterPage.svelte.d.ts +3 -0
  43. package/dist/components/account/SecurityLogPage.svelte +135 -0
  44. package/dist/components/account/SecurityLogPage.svelte.d.ts +3 -0
  45. package/dist/components/account/SettingsEnterprisePage.svelte +131 -0
  46. package/dist/components/account/SettingsEnterprisePage.svelte.d.ts +3 -0
  47. package/dist/components/account/SettingsPlainPage.svelte +81 -0
  48. package/dist/components/account/SettingsPlainPage.svelte.d.ts +3 -0
  49. package/dist/components/account/SettingsSidebarPage.svelte +98 -0
  50. package/dist/components/account/SettingsSidebarPage.svelte.d.ts +3 -0
  51. package/dist/components/account/TeamMembersPage.svelte +98 -0
  52. package/dist/components/account/TeamMembersPage.svelte.d.ts +3 -0
  53. package/dist/components/network/TeamCrewTablePage.svelte +155 -0
  54. package/dist/components/network/TeamCrewTablePage.svelte.d.ts +3 -0
  55. package/dist/components/network/UserCardsNFTPage.svelte +134 -0
  56. package/dist/components/network/UserCardsNFTPage.svelte.d.ts +3 -0
  57. package/dist/components/profile/ActivityTimeline.svelte +77 -0
  58. package/dist/components/profile/ActivityTimeline.svelte.d.ts +16 -0
  59. package/dist/components/profile/ProfileCard.svelte +216 -0
  60. package/dist/components/profile/ProfileCard.svelte.d.ts +27 -0
  61. package/dist/components/profile/ProjectsGrid.svelte +91 -0
  62. package/dist/components/profile/ProjectsGrid.svelte.d.ts +17 -0
  63. package/dist/components/profile/PublicProfilePage.svelte +179 -0
  64. package/dist/components/profile/PublicProfilePage.svelte.d.ts +10 -0
  65. package/dist/components/profile/TeamsShowcase.svelte +98 -0
  66. package/dist/components/profile/TeamsShowcase.svelte.d.ts +19 -0
  67. package/dist/components/ui/pagination/pagination-ellipsis.svelte +1 -1
  68. package/dist/index.d.ts +23 -0
  69. package/dist/index.js +27 -0
  70. package/dist/router-state.svelte.js +17 -0
  71. package/package.json +2 -2
@@ -1,26 +1,28 @@
1
1
  <script lang="ts">
2
- /* eslint-disable svelte/prefer-svelte-reactivity */
3
- import { getResources, canAccessAsync } from '@svadmin/core';
2
+ import { getResources, canAccessAsync, getAuthProvider } from '@svadmin/core';
4
3
  import type { Identity, MenuItem } from '@svadmin/core';
5
4
  import { navigate } from '@svadmin/core/router';
6
5
  import { getPath } from '../router-state.svelte.js';
7
6
  import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
8
- import { toggleTheme, getResolvedTheme, getColorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
9
- import { Button } from './ui/button/index.js';
7
+ import { toggleTheme, getResolvedTheme } from '@svadmin/core';
10
8
  import TooltipButton from './TooltipButton.svelte';
11
9
  import * as Tooltip from './ui/tooltip/index.js';
10
+ import * as Popover from './ui/popover/index.js';
12
11
  import SidebarItem from './SidebarItem.svelte';
13
12
 
14
13
  import { ScrollArea } from './ui/scroll-area/index.js';
15
- import * as Collapsible from './ui/collapsible/index.js';
16
14
  import { Avatar } from './ui/avatar/index.js';
17
15
  import {
18
16
  LayoutDashboard, FileText, Users, Settings, Home,
19
17
  ChevronLeft, ChevronRight, ChevronDown, LogOut, Sun, Moon, Palette,
20
18
  Image as ImageIcon, Layout, Folder, Type, Video,
21
- Download, ListTodo, TrendingUp, Sparkles, Images, Bot, Key, KeyRound, CreditCard, BookOpen, Wrench
19
+ Download, ListTodo, TrendingUp, Sparkles, Images, Bot, Key, KeyRound, CreditCard, BookOpen, Wrench,
20
+ Repeat, ClipboardCheck, SlidersHorizontal, AlertTriangle, Bell, CalendarDays, Package, Shield,
21
+ Barcode, FolderTree, Building2, Warehouse, Languages, User, Lock, Puzzle, FileSearch, Info
22
22
  } from '@lucide/svelte';
23
23
 
24
+ const authProvider = getAuthProvider({ optional: true });
25
+
24
26
  let { collapsed, identity, title, onToggle, onLogout, menu, routeMode = 'auto' }: {
25
27
  collapsed: boolean;
26
28
  identity: Identity | null;
@@ -38,8 +40,6 @@
38
40
  : routeMode
39
41
  );
40
42
 
41
- const _resources = getResources();
42
-
43
43
  const iconMap: Record<string, typeof LayoutDashboard> = {
44
44
  'dashboard': LayoutDashboard,
45
45
  'posts': FileText,
@@ -63,6 +63,18 @@
63
63
  'credit-card': CreditCard,
64
64
  'book-open': BookOpen,
65
65
  'wrench': Wrench,
66
+ 'repeat': Repeat,
67
+ 'clipboard-check': ClipboardCheck,
68
+ 'sliders-horizontal': SlidersHorizontal,
69
+ 'alert-triangle': AlertTriangle,
70
+ 'bell': Bell,
71
+ 'calendar': CalendarDays,
72
+ 'package': Package,
73
+ 'shield': Shield,
74
+ 'barcode': Barcode,
75
+ 'folder-tree': FolderTree,
76
+ 'building-2': Building2,
77
+ 'warehouse': Warehouse,
66
78
  };
67
79
 
68
80
  interface NavItem {
@@ -70,6 +82,7 @@
70
82
  label: string;
71
83
  Icon: typeof LayoutDashboard;
72
84
  group?: string;
85
+ badge?: string;
73
86
  }
74
87
 
75
88
  interface NavGroup {
@@ -77,11 +90,28 @@
77
90
  items: NavItem[];
78
91
  }
79
92
 
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
+
80
110
  // Use effect to build nav items, taking access control into account
81
111
  let navItems = $state.raw<NavItem[]>([]);
82
112
 
83
113
  $effect(() => {
84
- const _localeVal = getLocale();
114
+ getLocale();
85
115
  const currentResources = getResources();
86
116
  let cancelled = false;
87
117
 
@@ -102,6 +132,7 @@
102
132
  label: r.label,
103
133
  Icon: (typeof r.icon === 'string' ? iconMap[r.icon] : r.icon) ?? iconMap[r.name] ?? Settings,
104
134
  group: r.group,
135
+ badge: typeof r.meta?.badge === 'string' ? r.meta.badge : undefined,
105
136
  });
106
137
  }
107
138
  }
@@ -125,19 +156,30 @@
125
156
 
126
157
  // Track current hash for active state
127
158
  const path = $derived(getPath());
159
+ let userMenuOpen = $state(false);
128
160
 
129
161
  function isActive(itemPath: string): boolean {
130
162
  if (itemPath === '/') return path === '/';
131
163
  return path === itemPath || path.startsWith(itemPath + "/");
132
164
  }
133
165
 
134
- /** Prefetch resource data on hover for instant navigation */
135
- const prefetchedResources = new Set<string>();
136
- function _prefetchResource(resourceName: string) {
137
- if (resourceName === '/' || prefetchedResources.has(resourceName)) return;
138
- prefetchedResources.add(resourceName);
139
- // Trigger a background fetch by navigating the browser cache
140
- // The actual prefetch happens via the #key block re-creating useList in AdminApp
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();
141
183
  }
142
184
 
143
185
  // Group nav items by group field (null = ungrouped)
@@ -157,11 +199,38 @@
157
199
  return groups;
158
200
  });
159
201
 
160
- // Track which groups are open
161
- let openGroups = $state<Set<string>>(new Set());
162
- let colorPickerOpen = $state(false);
163
- let colorPickerRef = $state<HTMLDivElement | null>(null);
164
- let colorPickerOpenedAt = 0;
202
+ let autoGroupOpen = $state<Record<string, boolean>>({});
203
+ let autoGroupTouched = $state<Record<string, boolean>>({});
204
+
205
+ function isGroupActive(group: NavGroup): boolean {
206
+ return group.items.some((item) => isActive(item.path));
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
234
 
166
235
  // Density state
167
236
  let density = $state<'compact' | 'standard'>('standard');
@@ -183,55 +252,49 @@
183
252
  const pyClass = $derived(density === 'compact' ? 'py-1.5' : 'py-2.5');
184
253
  const pyClassGroupItem = $derived(density === 'compact' ? 'py-1.5' : 'py-2');
185
254
 
186
- // Click-outside to close color picker
187
- $effect(() => {
188
- if (!colorPickerOpen) return;
189
- function handleMouseDown(e: MouseEvent) {
190
- // Ignore if opened within last 200ms (same interaction)
191
- if (Date.now() - colorPickerOpenedAt < 200) return;
192
- if (colorPickerRef && !colorPickerRef.contains(e.target as Node)) {
193
- colorPickerOpen = false;
194
- }
195
- }
196
- document.addEventListener('mousedown', handleMouseDown, true);
197
- return () => {
198
- document.removeEventListener('mousedown', handleMouseDown, true);
199
- };
200
- });
201
255
  </script>
202
256
 
203
257
 
204
258
 
205
259
  <aside
206
260
  aria-label="Sidebar navigation"
207
- class="fixed inset-y-0 left-0 z-30 flex flex-col backdrop-blur-xl transition-all duration-300"
208
- style="background-color: var(--sidebar);"
209
- class:w-64={!collapsed}
261
+ class="fixed inset-y-0 left-0 z-30 flex flex-col border-r border-border/70 bg-sidebar/95 shadow-sm shadow-slate-900/[0.03] backdrop-blur-xl transition-all duration-300"
262
+ class:w-72={!collapsed}
210
263
  class:w-16={collapsed}
264
+ data-sidebar="sidebar"
265
+ data-collapsed={collapsed ? "true" : "false"}
211
266
  >
212
267
  <!-- Logo -->
213
- <div class="flex h-16 items-center justify-between px-4">
268
+ <div class="flex border-b border-border/60 {collapsed ? 'h-24 flex-col items-center justify-center gap-1 px-2' : 'h-16 items-center justify-between px-4'}">
214
269
  {#if !collapsed}
215
- <div class="flex items-center gap-3">
216
- <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>
217
- <span class="font-semibold text-lg tracking-tight text-sidebar-foreground">{title}</span>
270
+ <div class="flex min-w-0 flex-1 items-center gap-3">
271
+ <div class="flex h-9 w-6 shrink-0 items-center justify-center text-sm font-bold text-primary">
272
+ {title.slice(0, 1).toUpperCase()}
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>
218
278
  </div>
219
279
  {:else}
220
- <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>
280
+ <div class="mx-auto flex h-9 w-9 shrink-0 items-center justify-center text-sm font-bold text-primary">
281
+ {title.slice(0, 1).toUpperCase()}
282
+ </div>
221
283
  {/if}
222
284
  {#if !collapsed}
223
- <TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle}>
285
+ <TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle} class="text-sidebar-foreground/55 hover:bg-transparent hover:text-sidebar-foreground">
224
286
  <ChevronLeft class="h-4 w-4" />
225
287
  </TooltipButton>
226
288
  {:else}
227
- <TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle} class="mx-auto mt-2 mb-2 block">
289
+ <TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle} class="mx-auto text-sidebar-foreground/55 hover:bg-transparent hover:text-sidebar-foreground">
228
290
  <ChevronRight class="h-4 w-4" />
229
291
  </TooltipButton>
230
292
  {/if}
231
293
  </div>
232
294
 
233
- <ScrollArea class="flex-1">
234
- <nav aria-label="Main menu" class="py-4 px-3 space-y-1">
295
+ <div class="flex min-h-0 flex-1">
296
+ <ScrollArea class="min-w-0 flex-1">
297
+ <nav aria-label="Main menu" class="space-y-2 py-4 px-2">
235
298
  {#if menu && menu.length > 0}
236
299
  <!-- Custom multi-level menu -->
237
300
  {#each menu as item, _i (_i)}
@@ -241,37 +304,51 @@
241
304
  <!-- Auto-generated menu from resources (fallback) -->
242
305
  {#each navGroups as group, _i (_i)}
243
306
  {#if group.name && !collapsed}
244
- <!-- Grouped section with Collapsible -->
245
- <Collapsible.Root open={openGroups.has(group.name)} onOpenChange={(isOpen) => {
246
- const next = new Set(openGroups);
247
- if (isOpen) next.add(group.name as string); else next.delete(group.name as string);
248
- openGroups = next;
249
- }}>
250
- <Collapsible.Trigger
251
- 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"
307
+ {@const groupOpen = isAutoGroupOpen(group)}
308
+ {@const groupActive = isGroupActive(group)}
309
+ {@const groupBadge = aggregateBadges(group.items)}
310
+ <div class="border-b border-border/60 pb-2 last:border-b-0">
311
+ <button
312
+ type="button"
313
+ class="flex w-full items-center justify-between px-0.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] transition-colors {groupActive ? 'text-primary' : 'text-sidebar-foreground/45 hover:text-sidebar-foreground/75'}"
314
+ data-sidebar="group-label"
315
+ aria-expanded={groupOpen}
316
+ onclick={() => toggleAutoGroup(group)}
252
317
  >
253
- <span>{group.name}</span>
254
- <ChevronDown class="h-3 w-3 transition-transform {openGroups.has(group.name) ? 'rotate-180' : ''}" />
255
- </Collapsible.Trigger>
256
- <Collapsible.Content>
257
- <div class="mt-1 space-y-1">
318
+ <span class="flex min-w-0 flex-1 items-center">
319
+ <span class="truncate">{group.name}</span>
320
+ {#if groupBadge}
321
+ <span class="ml-2 rounded-full {groupActive ? 'bg-primary/12 text-primary' : 'bg-sidebar-accent text-sidebar-foreground/60'} px-2 py-0.5 text-[10px] font-semibold tracking-normal">{groupBadge}</span>
322
+ {/if}
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">
258
328
  {#each group.items as item, _i (_i)}
259
329
  {@const active = isActive(item.path)}
260
330
  <a
261
331
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
262
332
  onclick={(e) => { e.preventDefault(); navigate(item.path); }}
263
- class="flex items-center gap-3 rounded-xl px-3 {pyClassGroupItem} text-sm font-medium transition-all duration-300
333
+ class="group relative flex items-center gap-3 px-2 {pyClassGroupItem} text-sm font-medium transition-all duration-200 first:rounded-t-xl last:rounded-b-xl
264
334
  {active
265
- ? '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'
266
- : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
335
+ ? 'text-primary font-semibold'
336
+ : 'text-sidebar-foreground/68 hover:bg-sidebar-accent/45 hover:text-sidebar-foreground'}"
337
+ data-sidebar="menu-sub-button"
338
+ data-active={active ? "true" : "false"}
267
339
  >
268
- <item.Icon class="h-4 w-4 flex-shrink-0" />
269
- <span>{item.label}</span>
340
+ <span data-sidebar="menu-icon" class="flex h-5 w-5 shrink-0 items-center justify-center {active ? 'text-primary' : 'text-sidebar-foreground/55 group-hover:text-primary'}">
341
+ <item.Icon class="h-4 w-4 flex-shrink-0" />
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}
270
347
  </a>
271
348
  {/each}
272
349
  </div>
273
- </Collapsible.Content>
274
- </Collapsible.Root>
350
+ {/if}
351
+ </div>
275
352
  {:else}
276
353
  <!-- Ungrouped items (flat) -->
277
354
  {#each group.items as item, _i (_i)}
@@ -284,10 +361,12 @@
284
361
  {...props}
285
362
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
286
363
  onclick={(e) => { e.preventDefault(); navigate(item.path); }}
287
- class="flex items-center justify-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-300
364
+ class="mx-auto flex h-10 w-10 items-center justify-center text-sm font-medium transition-colors duration-200
288
365
  {active
289
- ? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[0_2px_12px_rgb(0,0,0,0.08)] ring-1 ring-border/20'
290
- : 'text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground'}"
366
+ ? 'text-primary'
367
+ : 'text-sidebar-foreground/60 hover:text-sidebar-foreground'}"
368
+ data-sidebar="menu-button"
369
+ data-active={active ? "true" : "false"}
291
370
  >
292
371
  <item.Icon class="h-4 w-4 flex-shrink-0" />
293
372
  </a>
@@ -301,13 +380,19 @@
301
380
  <a
302
381
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
303
382
  onclick={(e) => { e.preventDefault(); navigate(item.path); }}
304
- class="flex items-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-300
383
+ class="group relative flex items-center gap-3 rounded-xl px-2 {pyClass} text-sm font-medium transition-all duration-200
305
384
  {active
306
- ? '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'
307
- : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
308
- >
309
- <item.Icon class="h-4 w-4 flex-shrink-0" />
310
- <span>{item.label}</span>
385
+ ? 'text-primary font-semibold'
386
+ : 'text-sidebar-foreground/68 hover:bg-sidebar-accent/45 hover:text-sidebar-foreground'}"
387
+ data-sidebar="menu-button"
388
+ data-active={active ? "true" : "false"}
389
+ > <span data-sidebar="menu-icon" class="flex h-5 w-5 shrink-0 items-center justify-center {active ? 'text-primary' : 'text-sidebar-foreground/55 group-hover:text-primary'}">
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}
311
396
  </a>
312
397
  {/if}
313
398
  {/each}
@@ -316,129 +401,133 @@
316
401
  {/if}
317
402
  </nav>
318
403
  </ScrollArea>
404
+ </div>
319
405
 
320
406
  <!-- Footer -->
321
- <div>
322
- <!-- Color theme picker -->
323
- {#if !collapsed}
324
- <div class="px-3 pt-3 pb-1">
325
- <div class="relative" bind:this={colorPickerRef}>
326
- <Button
327
- variant="ghost"
328
- 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"
329
- onclick={() => { if (!colorPickerOpen) colorPickerOpenedAt = Date.now(); colorPickerOpen = !colorPickerOpen; }}
330
- >
331
- <Palette class="h-4 w-4" />
332
- <span class="flex-1 text-left text-xs">{t('common.toggleTheme')}</span>
333
- <span
334
- class="h-3.5 w-3.5 rounded-full ring-1 ring-offset-1 ring-offset-sidebar"
335
- style="background-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}; --tw-ring-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}"
336
- ></span>
337
- </Button>
338
- {#if colorPickerOpen}
339
- <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">
340
- {#each getColorThemes() as ct, _i (_i)}
341
- <button
342
- 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"
343
- onclick={() => { setColorTheme(ct.id as typeof ct.id & import('@svadmin/core').ColorTheme); colorPickerOpen = false; }}
344
- >
345
- <span
346
- class="h-3.5 w-3.5 rounded-full {getColorTheme() === ct.id ? 'ring-2 ring-offset-1 scale-110' : 'opacity-70'}"
347
- style="background-color: {ct.color}; {getColorTheme() === ct.id ? `--tw-ring-color: ${ct.color}` : ''}"
348
- ></span>
349
- <span class="text-xs">{ct.label}</span>
350
- </button>
351
- {/each}
352
- </div>
353
- {/if}
354
- </div>
355
- </div>
356
- {/if}
357
-
407
+ <div class="border-t border-border/60 bg-sidebar/95">
358
408
  <!-- User info -->
359
409
  {#if !collapsed && identity}
360
- <div class="px-3 pb-3 pt-1">
361
- <div class="flex items-center gap-3 rounded-lg p-2 hover:bg-sidebar-accent/50 transition-colors">
362
- <Avatar
363
- src={(identity as Record<string, unknown>).avatar as string | undefined}
364
- alt={identity.name ?? 'User'}
365
- fallback={identity.name?.charAt(0).toUpperCase() ?? 'U'}
366
- size="sm"
367
- />
368
- <div class="flex-1 min-w-0">
369
- <p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
370
- <p class="truncate text-[10px] text-sidebar-foreground/50">{((identity as Record<string, unknown>).role || (identity as Record<string, unknown>).roleName) ?? 'User'}</p>
371
- </div>
372
- </div>
373
- <div class="flex items-center justify-between px-1 mt-1">
374
- <TooltipButton tooltip={t('common.switchLanguage')} variant="ghost" size="icon-sm" onclick={toggleLocale} class="text-sidebar-foreground/60 hover:text-sidebar-foreground">
375
- <span class="text-xs font-bold">{localeLabel}</span>
376
- </TooltipButton>
377
- <TooltipButton tooltip={t('common.toggleTheme')} variant="ghost" size="icon-sm" onclick={toggleTheme} class="text-sidebar-foreground/60 hover:text-sidebar-foreground">
378
- {#if getResolvedTheme() === 'dark'}
379
- <Sun class="h-4 w-4" />
380
- {:else}
381
- <Moon class="h-4 w-4" />
382
- {/if}
383
- </TooltipButton>
384
- <TooltipButton tooltip={t('settings.title')} variant="ghost" size="icon-sm" onclick={() => navigate('/settings')} class="text-sidebar-foreground/60 hover:text-sidebar-foreground">
385
- <Settings class="h-4 w-4" />
386
- </TooltipButton>
387
- <TooltipButton tooltip={t('common.logout')} variant="ghost" size="icon-sm" onclick={onLogout} class="text-sidebar-foreground/60 hover:text-destructive">
388
- <LogOut class="h-4 w-4" />
389
- </TooltipButton>
390
- </div>
391
- </div>
392
- {:else if collapsed}
393
- <div class="px-1 py-3 space-y-1">
394
- <Tooltip.Root>
395
- <Tooltip.Trigger>
410
+ <div class="px-3 py-3">
411
+ <Popover.Root bind:open={userMenuOpen}>
412
+ <Popover.Trigger>
396
413
  {#snippet child({ props }: { props: Record<string, unknown> })}
397
- <Button {...props} variant="ghost" size="icon" onclick={toggleLocale} class="w-full text-sidebar-foreground/60 hover:text-sidebar-foreground">
398
- <span class="text-xs font-bold">{localeLabel}</span>
399
- </Button>
414
+ <button
415
+ {...props}
416
+ type="button"
417
+ class="flex w-full items-center gap-3 rounded-2xl border border-border/50 bg-background/65 p-2 text-left shadow-sm shadow-slate-900/[0.02] transition-colors hover:bg-sidebar-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
418
+ aria-label={identity.name ?? 'User'}
419
+ >
420
+ <Avatar
421
+ src={(identity as Record<string, unknown>).avatar as string | undefined}
422
+ alt={identity.name ?? 'User'}
423
+ fallback={identity.name?.charAt(0).toUpperCase() ?? 'U'}
424
+ size="sm"
425
+ />
426
+ <div class="min-w-0 flex-1">
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>
400
432
  {/snippet}
401
- </Tooltip.Trigger>
402
- <Tooltip.Content side="right">{t('common.switchLanguage')}</Tooltip.Content>
403
- </Tooltip.Root>
404
- <Tooltip.Root>
405
- <Tooltip.Trigger>
406
- {#snippet child({ props }: { props: Record<string, unknown> })}
407
- <Button {...props} variant="ghost" size="icon" onclick={toggleTheme} class="w-full text-sidebar-foreground/60 hover:text-sidebar-foreground">
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)}>
408
442
  {#if getResolvedTheme() === 'dark'}
409
- <Sun class="h-4 w-4" />
443
+ <Sun class="h-4 w-4 shrink-0" />
410
444
  {:else}
411
- <Moon class="h-4 w-4" />
445
+ <Moon class="h-4 w-4 shrink-0" />
412
446
  {/if}
413
- </Button>
414
- {/snippet}
415
- </Tooltip.Trigger>
416
- <Tooltip.Content side="right">{t('common.toggleTheme')}</Tooltip.Content>
417
- </Tooltip.Root>
418
- <Tooltip.Root>
419
- <Tooltip.Trigger>
420
- {#snippet child({ props }: { props: Record<string, unknown> })}
421
- <Button {...props} variant="ghost" size="icon" onclick={onLogout} class="w-full text-sidebar-foreground/60 hover:text-destructive">
422
- <LogOut class="h-4 w-4" />
423
- </Button>
424
- {/snippet}
425
- </Tooltip.Trigger>
426
- <Tooltip.Content side="right">{t('common.logout')}</Tooltip.Content>
427
- </Tooltip.Root>
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)}
452
+ <button
453
+ type="button"
454
+ class="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm transition-colors
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)}
459
+ >
460
+ <item.icon class="h-4 w-4 shrink-0" />
461
+ <span class="min-w-0 flex-1 truncate">{t(item.label)}</span>
462
+ </button>
463
+ {/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
+ </div>
470
+ </Popover.Content>
471
+ </Popover.Root>
428
472
  </div>
429
- {:else}
430
- <div class="flex gap-1 p-3">
431
- <TooltipButton tooltip={t('common.switchLanguage')} variant="ghost" size="icon" onclick={toggleLocale} class="flex-1 text-sidebar-foreground/60 hover:text-sidebar-foreground">
432
- <span class="text-xs font-bold">{localeLabel}</span>
433
- </TooltipButton>
434
- <TooltipButton tooltip={t('common.toggleTheme')} variant="ghost" size="icon" onclick={toggleTheme} class="flex-1 text-sidebar-foreground/60 hover:text-sidebar-foreground">
435
- {#if getResolvedTheme() === 'dark'}
436
- <Sun class="h-4 w-4" />
437
- {:else}
438
- <Moon class="h-4 w-4" />
439
- {/if}
440
- </TooltipButton>
473
+ {:else if collapsed}
474
+ <div class="flex flex-col items-center px-2 py-3">
475
+ {#if identity}
476
+ <Popover.Root bind:open={userMenuOpen}>
477
+ <Popover.Trigger>
478
+ {#snippet child({ props }: { props: Record<string, unknown> })}
479
+ <button {...props} type="button" class="mx-auto flex h-10 w-10 items-center justify-center rounded-full text-sidebar-foreground transition-colors hover:bg-sidebar-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" aria-label={identity.name ?? 'User'}>
480
+ <Avatar
481
+ src={(identity as Record<string, unknown>).avatar as string | undefined}
482
+ alt={identity.name ?? 'User'}
483
+ fallback={identity.name?.charAt(0).toUpperCase() ?? 'U'}
484
+ size="sm"
485
+ />
486
+ </button>
487
+ {/snippet}
488
+ </Popover.Trigger>
489
+ <Popover.Content align="start" side="right" sideOffset={10} class="w-56 p-2">
490
+ <div class="space-y-1">
491
+ <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)}>
492
+ <Languages class="h-4 w-4 shrink-0" />
493
+ <span class="min-w-0 flex-1 truncate">{t('common.switchLanguage')}</span>
494
+ <span class="text-xs font-semibold text-muted-foreground">{localeLabel}</span>
495
+ </button>
496
+ <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)}>
497
+ {#if getResolvedTheme() === 'dark'}
498
+ <Sun class="h-4 w-4 shrink-0" />
499
+ {:else}
500
+ <Moon class="h-4 w-4 shrink-0" />
501
+ {/if}
502
+ <span class="min-w-0 flex-1 truncate">{t('common.toggleTheme')}</span>
503
+ </button>
504
+ <div class="my-1 h-px bg-border/60"></div>
505
+ {#each userSettingsItems as item, _i (_i)}
506
+ {@const active = isActive(item.path)}
507
+ <button
508
+ type="button"
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}
441
528
  </div>
529
+ {:else}
530
+ <div class="h-3"></div>
442
531
  {/if}
443
532
  </div>
444
533
  </aside>