@svadmin/ui 0.35.0 → 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.
@@ -1,27 +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
14
  import { Avatar } from './ui/avatar/index.js';
16
15
  import {
17
16
  LayoutDashboard, FileText, Users, Settings, Home,
18
- ChevronLeft, ChevronRight, LogOut, Sun, Moon, Palette,
17
+ ChevronLeft, ChevronRight, ChevronDown, LogOut, Sun, Moon, Palette,
19
18
  Image as ImageIcon, Layout, Folder, Type, Video,
20
19
  Download, ListTodo, TrendingUp, Sparkles, Images, Bot, Key, KeyRound, CreditCard, BookOpen, Wrench,
21
20
  Repeat, ClipboardCheck, SlidersHorizontal, AlertTriangle, Bell, CalendarDays, Package, Shield,
22
- Barcode, FolderTree, Building2, Warehouse
21
+ Barcode, FolderTree, Building2, Warehouse, Languages, User, Lock, Puzzle, FileSearch, Info
23
22
  } from '@lucide/svelte';
24
23
 
24
+ const authProvider = getAuthProvider({ optional: true });
25
+
25
26
  let { collapsed, identity, title, onToggle, onLogout, menu, routeMode = 'auto' }: {
26
27
  collapsed: boolean;
27
28
  identity: Identity | null;
@@ -39,8 +40,6 @@
39
40
  : routeMode
40
41
  );
41
42
 
42
- const _resources = getResources();
43
-
44
43
  const iconMap: Record<string, typeof LayoutDashboard> = {
45
44
  'dashboard': LayoutDashboard,
46
45
  'posts': FileText,
@@ -91,11 +90,28 @@
91
90
  items: NavItem[];
92
91
  }
93
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
+
94
110
  // Use effect to build nav items, taking access control into account
95
111
  let navItems = $state.raw<NavItem[]>([]);
96
112
 
97
113
  $effect(() => {
98
- const _localeVal = getLocale();
114
+ getLocale();
99
115
  const currentResources = getResources();
100
116
  let cancelled = false;
101
117
 
@@ -140,19 +156,30 @@
140
156
 
141
157
  // Track current hash for active state
142
158
  const path = $derived(getPath());
159
+ let userMenuOpen = $state(false);
143
160
 
144
161
  function isActive(itemPath: string): boolean {
145
162
  if (itemPath === '/') return path === '/';
146
163
  return path === itemPath || path.startsWith(itemPath + "/");
147
164
  }
148
165
 
149
- /** Prefetch resource data on hover for instant navigation */
150
- const prefetchedResources = new Set<string>();
151
- function _prefetchResource(resourceName: string) {
152
- if (resourceName === '/' || prefetchedResources.has(resourceName)) return;
153
- prefetchedResources.add(resourceName);
154
- // Trigger a background fetch by navigating the browser cache
155
- // 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();
156
183
  }
157
184
 
158
185
  // Group nav items by group field (null = ungrouped)
@@ -172,9 +199,38 @@
172
199
  return groups;
173
200
  });
174
201
 
175
- let colorPickerOpen = $state(false);
176
- let colorPickerRef = $state<HTMLDivElement | null>(null);
177
- 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
+ });
178
234
 
179
235
  // Density state
180
236
  let density = $state<'compact' | 'standard'>('standard');
@@ -196,21 +252,6 @@
196
252
  const pyClass = $derived(density === 'compact' ? 'py-1.5' : 'py-2.5');
197
253
  const pyClassGroupItem = $derived(density === 'compact' ? 'py-1.5' : 'py-2');
198
254
 
199
- // Click-outside to close color picker
200
- $effect(() => {
201
- if (!colorPickerOpen) return;
202
- function handleMouseDown(e: MouseEvent) {
203
- // Ignore if opened within last 200ms (same interaction)
204
- if (Date.now() - colorPickerOpenedAt < 200) return;
205
- if (colorPickerRef && !colorPickerRef.contains(e.target as Node)) {
206
- colorPickerOpen = false;
207
- }
208
- }
209
- document.addEventListener('mousedown', handleMouseDown, true);
210
- return () => {
211
- document.removeEventListener('mousedown', handleMouseDown, true);
212
- };
213
- });
214
255
  </script>
215
256
 
216
257
 
@@ -219,32 +260,33 @@
219
260
  aria-label="Sidebar navigation"
220
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"
221
262
  class:w-72={!collapsed}
222
- class:w-20={collapsed}
263
+ class:w-16={collapsed}
223
264
  data-sidebar="sidebar"
265
+ data-collapsed={collapsed ? "true" : "false"}
224
266
  >
225
267
  <!-- Logo -->
226
- <div class="flex h-16 items-center justify-between border-b border-border/60 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'}">
227
269
  {#if !collapsed}
228
- <div class="flex items-center gap-3">
229
- <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-bold text-primary-foreground shadow-sm shadow-primary/25">
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">
230
272
  {title.slice(0, 1).toUpperCase()}
231
273
  </div>
232
- <div class="min-w-0">
233
- <span class="block truncate text-[0.95rem] font-semibold tracking-tight text-sidebar-foreground">{title}</span>
234
- <span class="block truncate text-[11px] font-medium text-sidebar-foreground/50">{getLocale() === 'zh-CN' ? '管理控制台' : 'Admin Console'}</span>
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>
235
277
  </div>
236
278
  </div>
237
279
  {:else}
238
- <div class="mx-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-bold text-primary-foreground shadow-sm shadow-primary/25">
280
+ <div class="mx-auto flex h-9 w-9 shrink-0 items-center justify-center text-sm font-bold text-primary">
239
281
  {title.slice(0, 1).toUpperCase()}
240
282
  </div>
241
283
  {/if}
242
284
  {#if !collapsed}
243
- <TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle} class="rounded-xl text-sidebar-foreground/55 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground">
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">
244
286
  <ChevronLeft class="h-4 w-4" />
245
287
  </TooltipButton>
246
288
  {:else}
247
- <TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle} class="mx-auto mb-2 mt-2 block rounded-xl text-sidebar-foreground/55 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground">
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">
248
290
  <ChevronRight class="h-4 w-4" />
249
291
  </TooltipButton>
250
292
  {/if}
@@ -252,7 +294,7 @@
252
294
 
253
295
  <div class="flex min-h-0 flex-1">
254
296
  <ScrollArea class="min-w-0 flex-1">
255
- <nav aria-label="Main menu" class="space-y-5 px-3 py-5">
297
+ <nav aria-label="Main menu" class="space-y-2 py-4 px-2">
256
298
  {#if menu && menu.length > 0}
257
299
  <!-- Custom multi-level menu -->
258
300
  {#each menu as item, _i (_i)}
@@ -262,31 +304,40 @@
262
304
  <!-- Auto-generated menu from resources (fallback) -->
263
305
  {#each navGroups as group, _i (_i)}
264
306
  {#if group.name && !collapsed}
265
- <!-- Grouped section: expanded by default in desktop light sidebar mode -->
266
- <div>
267
- <div
268
- class="flex w-full items-center justify-between px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sidebar-foreground/45"
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'}"
269
314
  data-sidebar="group-label"
315
+ aria-expanded={groupOpen}
316
+ onclick={() => toggleAutoGroup(group)}
270
317
  >
271
- <span>{group.name}</span>
272
- </div>
273
- <div class="mt-1 space-y-1" data-sidebar="menu-sub">
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">
274
328
  {#each group.items as item, _i (_i)}
275
329
  {@const active = isActive(item.path)}
276
330
  <a
277
331
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
278
332
  onclick={(e) => { e.preventDefault(); navigate(item.path); }}
279
- class="group relative flex items-center gap-3 rounded-xl px-3 {pyClassGroupItem} text-sm font-medium transition-all duration-200
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
280
334
  {active
281
- ? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
282
- : 'text-sidebar-foreground/68 hover:bg-background/85 hover:text-sidebar-foreground'}"
335
+ ? 'text-primary font-semibold'
336
+ : 'text-sidebar-foreground/68 hover:bg-sidebar-accent/45 hover:text-sidebar-foreground'}"
283
337
  data-sidebar="menu-sub-button"
284
338
  data-active={active ? "true" : "false"}
285
339
  >
286
- {#if active && !collapsed}
287
- <div class="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-1 rounded-r-full bg-primary"></div>
288
- {/if}
289
- <span data-sidebar="menu-icon" class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg {active ? 'bg-primary-foreground/15' : 'bg-muted/60 text-sidebar-foreground/55 group-hover:bg-primary/10 group-hover:text-primary'}">
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'}">
290
341
  <item.Icon class="h-4 w-4 flex-shrink-0" />
291
342
  </span>
292
343
  <span class="min-w-0 flex-1 truncate">{item.label}</span>
@@ -295,7 +346,8 @@
295
346
  {/if}
296
347
  </a>
297
348
  {/each}
298
- </div>
349
+ </div>
350
+ {/if}
299
351
  </div>
300
352
  {:else}
301
353
  <!-- Ungrouped items (flat) -->
@@ -309,10 +361,10 @@
309
361
  {...props}
310
362
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
311
363
  onclick={(e) => { e.preventDefault(); navigate(item.path); }}
312
- class="flex items-center justify-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-200
364
+ class="mx-auto flex h-10 w-10 items-center justify-center text-sm font-medium transition-colors duration-200
313
365
  {active
314
- ? 'bg-primary/10 text-primary shadow-sm shadow-primary/10 ring-1 ring-primary/15'
315
- : 'text-sidebar-foreground/60 hover:bg-sidebar-accent/65 hover:text-sidebar-foreground'}"
366
+ ? 'text-primary'
367
+ : 'text-sidebar-foreground/60 hover:text-sidebar-foreground'}"
316
368
  data-sidebar="menu-button"
317
369
  data-active={active ? "true" : "false"}
318
370
  >
@@ -328,17 +380,13 @@
328
380
  <a
329
381
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
330
382
  onclick={(e) => { e.preventDefault(); navigate(item.path); }}
331
- class="group relative flex items-center gap-3 rounded-xl px-3 {pyClass} text-sm font-medium transition-all duration-200
383
+ class="group relative flex items-center gap-3 rounded-xl px-2 {pyClass} text-sm font-medium transition-all duration-200
332
384
  {active
333
- ? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
334
- : 'text-sidebar-foreground/68 hover:bg-background/85 hover:text-sidebar-foreground'}"
385
+ ? 'text-primary font-semibold'
386
+ : 'text-sidebar-foreground/68 hover:bg-sidebar-accent/45 hover:text-sidebar-foreground'}"
335
387
  data-sidebar="menu-button"
336
388
  data-active={active ? "true" : "false"}
337
- >
338
- {#if active && !collapsed}
339
- <div class="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-1 rounded-r-full bg-primary"></div>
340
- {/if}
341
- <span data-sidebar="menu-icon" class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg {active ? 'bg-primary-foreground/15' : 'bg-muted/60 text-sidebar-foreground/55 group-hover:bg-primary/10 group-hover:text-primary'}">
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'}">
342
390
  <item.Icon class="h-4 w-4 flex-shrink-0" />
343
391
  </span>
344
392
  <span class="min-w-0 flex-1 truncate">{item.label}</span>
@@ -357,136 +405,129 @@
357
405
 
358
406
  <!-- Footer -->
359
407
  <div class="border-t border-border/60 bg-sidebar/95">
360
- <!-- Color theme picker -->
361
- {#if !collapsed}
362
- <div class="px-3 pt-3 pb-1">
363
- <div class="relative" bind:this={colorPickerRef}>
364
- <Button
365
- variant="ghost"
366
- class="flex h-auto w-full items-center justify-start gap-2 rounded-xl px-2 py-1.5 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
367
- onclick={() => { if (!colorPickerOpen) colorPickerOpenedAt = Date.now(); colorPickerOpen = !colorPickerOpen; }}
368
- >
369
- <Palette class="h-4 w-4" />
370
- <span class="flex-1 text-left text-xs">{t('common.toggleTheme')}</span>
371
- <span
372
- class="h-3.5 w-3.5 rounded-full ring-1 ring-offset-1 ring-offset-sidebar"
373
- style="background-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}; --tw-ring-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}"
374
- ></span>
375
- </Button>
376
- {#if colorPickerOpen}
377
- <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">
378
- {#each getColorThemes() as ct, _i (_i)}
379
- <button
380
- 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"
381
- onclick={() => { setColorTheme(ct.id as typeof ct.id & import('@svadmin/core').ColorTheme); colorPickerOpen = false; }}
382
- >
383
- <span
384
- class="h-3.5 w-3.5 rounded-full {getColorTheme() === ct.id ? 'ring-2 ring-offset-1 scale-110' : 'opacity-70'}"
385
- style="background-color: {ct.color}; {getColorTheme() === ct.id ? `--tw-ring-color: ${ct.color}` : ''}"
386
- ></span>
387
- <span class="text-xs">{ct.label}</span>
388
- </button>
389
- {/each}
390
- </div>
391
- {/if}
392
- </div>
393
- </div>
394
- {/if}
395
-
396
408
  <!-- User info -->
397
409
  {#if !collapsed && identity}
398
- <div class="px-3 pb-3 pt-1">
399
- <div class="flex items-center gap-3 rounded-2xl border border-border/50 bg-background/65 p-2 shadow-sm shadow-slate-900/[0.02] transition-colors hover:bg-sidebar-accent/50">
400
- <Avatar
401
- src={(identity as Record<string, unknown>).avatar as string | undefined}
402
- alt={identity.name ?? 'User'}
403
- fallback={identity.name?.charAt(0).toUpperCase() ?? 'U'}
404
- size="sm"
405
- />
406
- <div class="flex-1 min-w-0">
407
- <p class="truncate text-sm font-medium text-sidebar-foreground">{identity.name}</p>
408
- <p class="truncate text-[10px] text-sidebar-foreground/50">{((identity as Record<string, unknown>).role || (identity as Record<string, unknown>).roleName) ?? 'User'}</p>
409
- </div>
410
- </div>
411
- <div class="flex items-center justify-between px-1 mt-1">
412
- <TooltipButton tooltip={t('common.switchLanguage')} variant="ghost" size="icon-sm" onclick={toggleLocale} class="text-sidebar-foreground/60 hover:text-sidebar-foreground">
413
- <span class="text-xs font-bold">{localeLabel}</span>
414
- </TooltipButton>
415
- <TooltipButton tooltip={t('common.toggleTheme')} variant="ghost" size="icon-sm" onclick={toggleTheme} class="text-sidebar-foreground/60 hover:text-sidebar-foreground">
416
- {#if getResolvedTheme() === 'dark'}
417
- <Sun class="h-4 w-4" />
418
- {:else}
419
- <Moon class="h-4 w-4" />
420
- {/if}
421
- </TooltipButton>
422
- <TooltipButton tooltip={t('settings.title')} variant="ghost" size="icon-sm" onclick={() => navigate('/settings')} class="text-sidebar-foreground/60 hover:text-sidebar-foreground">
423
- <Settings class="h-4 w-4" />
424
- </TooltipButton>
425
- <TooltipButton tooltip={t('common.logout')} variant="ghost" size="icon-sm" onclick={onLogout} class="text-sidebar-foreground/60 hover:text-destructive">
426
- <LogOut class="h-4 w-4" />
427
- </TooltipButton>
428
- </div>
429
- </div>
430
- {:else if collapsed}
431
- <div class="px-1 py-3 space-y-1">
432
- <Tooltip.Root>
433
- <Tooltip.Trigger>
410
+ <div class="px-3 py-3">
411
+ <Popover.Root bind:open={userMenuOpen}>
412
+ <Popover.Trigger>
434
413
  {#snippet child({ props }: { props: Record<string, unknown> })}
435
- <Button {...props} variant="ghost" size="icon" onclick={toggleLocale} class="w-full text-sidebar-foreground/60 hover:text-sidebar-foreground">
436
- <span class="text-xs font-bold">{localeLabel}</span>
437
- </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>
438
432
  {/snippet}
439
- </Tooltip.Trigger>
440
- <Tooltip.Content side="right">{t('common.switchLanguage')}</Tooltip.Content>
441
- </Tooltip.Root>
442
- <Tooltip.Root>
443
- <Tooltip.Trigger>
444
- {#snippet child({ props }: { props: Record<string, unknown> })}
445
- <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)}>
446
442
  {#if getResolvedTheme() === 'dark'}
447
- <Sun class="h-4 w-4" />
443
+ <Sun class="h-4 w-4 shrink-0" />
448
444
  {:else}
449
- <Moon class="h-4 w-4" />
445
+ <Moon class="h-4 w-4 shrink-0" />
450
446
  {/if}
451
- </Button>
452
- {/snippet}
453
- </Tooltip.Trigger>
454
- <Tooltip.Content side="right">{t('common.toggleTheme')}</Tooltip.Content>
455
- </Tooltip.Root>
456
- <Tooltip.Root>
457
- <Tooltip.Trigger>
458
- {#snippet child({ props }: { props: Record<string, unknown> })}
459
- <Button {...props} variant="ghost" size="icon" onclick={() => navigate('/settings')} class="w-full text-sidebar-foreground/60 hover:text-sidebar-foreground">
460
- <Settings class="h-4 w-4" />
461
- </Button>
462
- {/snippet}
463
- </Tooltip.Trigger>
464
- <Tooltip.Content side="right">{t('settings.title')}</Tooltip.Content>
465
- </Tooltip.Root>
466
- <Tooltip.Root>
467
- <Tooltip.Trigger>
468
- {#snippet child({ props }: { props: Record<string, unknown> })}
469
- <Button {...props} variant="ghost" size="icon" onclick={onLogout} class="w-full text-sidebar-foreground/60 hover:text-destructive">
470
- <LogOut class="h-4 w-4" />
471
- </Button>
472
- {/snippet}
473
- </Tooltip.Trigger>
474
- <Tooltip.Content side="right">{t('common.logout')}</Tooltip.Content>
475
- </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>
476
472
  </div>
477
- {:else}
478
- <div class="flex gap-1 p-3">
479
- <TooltipButton tooltip={t('common.switchLanguage')} variant="ghost" size="icon" onclick={toggleLocale} class="flex-1 text-sidebar-foreground/60 hover:text-sidebar-foreground">
480
- <span class="text-xs font-bold">{localeLabel}</span>
481
- </TooltipButton>
482
- <TooltipButton tooltip={t('common.toggleTheme')} variant="ghost" size="icon" onclick={toggleTheme} class="flex-1 text-sidebar-foreground/60 hover:text-sidebar-foreground">
483
- {#if getResolvedTheme() === 'dark'}
484
- <Sun class="h-4 w-4" />
485
- {:else}
486
- <Moon class="h-4 w-4" />
487
- {/if}
488
- </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}
489
528
  </div>
529
+ {:else}
530
+ <div class="h-3"></div>
490
531
  {/if}
491
532
  </div>
492
533
  </aside>