@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.
@@ -1,28 +1,27 @@
1
1
  <script lang="ts">
2
- import { getResources, canAccessAsync, getAuthProvider } from '@svadmin/core';
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, Bell, CalendarDays, Package, Shield,
21
- Barcode, FolderTree, Building2, Warehouse, Languages, User, Lock, Puzzle, FileSearch, Info
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
- 'calendar': CalendarDays,
72
- 'package': Package,
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
- 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
+ // 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 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}
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 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'}">
219
+ <div class="flex h-16 items-center justify-between px-4">
269
220
  {#if !collapsed}
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>
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="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>
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} class="text-sidebar-foreground/55 hover:bg-transparent hover:text-sidebar-foreground">
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 text-sidebar-foreground/55 hover:bg-transparent hover:text-sidebar-foreground">
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
- <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">
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
- {@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)}
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 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">
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="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
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-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"}
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
- <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}
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
- {/if}
351
- </div>
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="mx-auto flex h-10 w-10 items-center justify-center text-sm font-medium transition-colors duration-200
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-primary'
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="group relative flex items-center gap-3 rounded-xl px-2 {pyClass} text-sm font-medium transition-all duration-200
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-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}
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 class="border-t border-border/60 bg-sidebar/95">
408
- <!-- User info -->
409
- {#if !collapsed && identity}
410
- <div class="px-3 py-3">
411
- <Popover.Root bind:open={userMenuOpen}>
412
- <Popover.Trigger>
413
- {#snippet child({ props }: { props: Record<string, unknown> })}
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>
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
- 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)}
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
- <item.icon class="h-4 w-4 shrink-0" />
461
- <span class="min-w-0 flex-1 truncate">{t(item.label)}</span>
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
- </Popover.Content>
471
- </Popover.Root>
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-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}
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="h-3"></div>
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>