@svadmin/ui 0.36.0 → 0.38.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 (49) hide show
  1. package/dist/app.css +76 -104
  2. package/dist/components/AdminApp.svelte +18 -14
  3. package/dist/components/AutoForm.svelte +32 -47
  4. package/dist/components/AutoTable.svelte +26 -31
  5. package/dist/components/CanAccess.svelte +3 -2
  6. package/dist/components/CanAccess.svelte.d.ts +1 -0
  7. package/dist/components/CreateInferencer.svelte +13 -0
  8. package/dist/components/CreateInferencer.svelte.d.ts +7 -0
  9. package/dist/components/EditInferencer.svelte +21 -0
  10. package/dist/components/EditInferencer.svelte.d.ts +9 -0
  11. package/dist/components/Header.svelte +6 -6
  12. package/dist/components/Layout.svelte +21 -11
  13. package/dist/components/ListInferencer.svelte +14 -0
  14. package/dist/components/ListInferencer.svelte.d.ts +7 -0
  15. package/dist/components/PageHeader.svelte +7 -9
  16. package/dist/components/ResourceInferencer.svelte +28 -0
  17. package/dist/components/ResourceInferencer.svelte.d.ts +7 -0
  18. package/dist/components/ResourceOperationsPage.svelte +168 -21
  19. package/dist/components/ResourceOperationsPage.svelte.d.ts +2 -0
  20. package/dist/components/SettingsPage.svelte +76 -41
  21. package/dist/components/ShowInferencer.svelte +21 -0
  22. package/dist/components/ShowInferencer.svelte.d.ts +9 -0
  23. package/dist/components/ShowPage.svelte +11 -11
  24. package/dist/components/ShowPage.svelte.d.ts +1 -0
  25. package/dist/components/Sidebar.svelte +214 -300
  26. package/dist/components/Sidebar.svelte.d.ts +0 -1
  27. package/dist/components/SidebarItem.svelte +141 -108
  28. package/dist/components/account/TeamMembersPage.svelte +9 -3
  29. package/dist/components/buttons/CloneButton.svelte +10 -2
  30. package/dist/components/buttons/CloneButton.svelte.d.ts +2 -4
  31. package/dist/components/buttons/CreateButton.svelte +9 -2
  32. package/dist/components/buttons/CreateButton.svelte.d.ts +2 -4
  33. package/dist/components/buttons/DeleteButton.svelte +10 -2
  34. package/dist/components/buttons/DeleteButton.svelte.d.ts +2 -4
  35. package/dist/components/buttons/EditButton.svelte +10 -2
  36. package/dist/components/buttons/EditButton.svelte.d.ts +2 -4
  37. package/dist/components/buttons/ExportButton.svelte +9 -2
  38. package/dist/components/buttons/ExportButton.svelte.d.ts +2 -4
  39. package/dist/components/buttons/ImportButton.svelte +9 -2
  40. package/dist/components/buttons/ImportButton.svelte.d.ts +2 -4
  41. package/dist/components/buttons/ShowButton.svelte +10 -2
  42. package/dist/components/buttons/ShowButton.svelte.d.ts +2 -4
  43. package/dist/components/buttons/access-control.d.ts +7 -0
  44. package/dist/components/buttons/access-control.js +5 -0
  45. package/dist/components/ui/sheet/sheet.svelte +3 -3
  46. package/dist/index.d.ts +5 -0
  47. package/dist/index.js +5 -0
  48. package/dist/router-state.svelte.js +15 -0
  49. package/package.json +2 -2
@@ -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, Map as MapIcon, MessageSquare, Send, ShoppingBag, Star, Briefcase, Building2,
22
+ Calendar, Bell, Clock, Tag, 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;
@@ -30,7 +29,6 @@
30
29
  onToggle: () => void;
31
30
  onLogout: () => void;
32
31
  menu?: MenuItem[];
33
- /** 'hash' for SPA hash routing, 'path' for SvelteKit/filesystem routing, 'auto' to detect */
34
32
  routeMode?: 'hash' | 'path' | 'auto';
35
33
  } = $props();
36
34
 
@@ -43,6 +41,7 @@
43
41
  const iconMap: Record<string, typeof LayoutDashboard> = {
44
42
  'dashboard': LayoutDashboard,
45
43
  'posts': FileText,
44
+ 'file': FileText,
46
45
  'users': Users,
47
46
  'settings': Settings,
48
47
  'home': Home,
@@ -67,14 +66,20 @@
67
66
  'clipboard-check': ClipboardCheck,
68
67
  'sliders-horizontal': SlidersHorizontal,
69
68
  'alert-triangle': AlertTriangle,
69
+ 'mail': Mail,
70
+ 'map': MapIcon,
71
+ 'message-square': MessageSquare,
72
+ 'send': Send,
73
+ 'shopping-bag': ShoppingBag,
74
+ 'star': Star,
75
+ 'briefcase': Briefcase,
76
+ 'building': Building2,
77
+ 'calendar': Calendar,
70
78
  'bell': Bell,
71
- 'calendar': CalendarDays,
72
- 'package': Package,
79
+ 'clock': Clock,
80
+ 'tag': Tag,
81
+ 'trash': Trash2,
73
82
  'shield': Shield,
74
- 'barcode': Barcode,
75
- 'folder-tree': FolderTree,
76
- 'building-2': Building2,
77
- 'warehouse': Warehouse,
78
83
  };
79
84
 
80
85
  interface NavItem {
@@ -82,32 +87,13 @@
82
87
  label: string;
83
88
  Icon: typeof LayoutDashboard;
84
89
  group?: string;
85
- badge?: string;
86
90
  }
87
91
 
88
92
  interface NavGroup {
89
- name: string | null; // null = ungrouped
93
+ name: string | null;
90
94
  items: NavItem[];
91
95
  }
92
96
 
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
- // Use effect to build nav items, taking access control into account
111
97
  let navItems = $state.raw<NavItem[]>([]);
112
98
 
113
99
  $effect(() => {
@@ -132,7 +118,6 @@
132
118
  label: r.label,
133
119
  Icon: (typeof r.icon === 'string' ? iconMap[r.icon] : r.icon) ?? iconMap[r.name] ?? Settings,
134
120
  group: r.group,
135
- badge: typeof r.meta?.badge === 'string' ? r.meta.badge : undefined,
136
121
  });
137
122
  }
138
123
  }
@@ -142,7 +127,6 @@
142
127
  return () => { cancelled = true; };
143
128
  });
144
129
 
145
- /** Toggle between available locales */
146
130
  function toggleLocale() {
147
131
  const locales = getAvailableLocales();
148
132
  const current = getLocale();
@@ -151,38 +135,15 @@
151
135
  setLocale(next);
152
136
  }
153
137
 
154
- /** Short display label for current locale */
155
138
  const localeLabel = $derived(getLocale() === 'zh-CN' ? '中' : 'EN');
156
139
 
157
- // Track current hash for active state
158
140
  const path = $derived(getPath());
159
- let userMenuOpen = $state(false);
160
141
 
161
142
  function isActive(itemPath: string): boolean {
162
143
  if (itemPath === '/') return path === '/';
163
144
  return path === itemPath || path.startsWith(itemPath + "/");
164
145
  }
165
146
 
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
- // Group nav items by group field (null = ungrouped)
186
147
  const navGroups = $derived.by((): NavGroup[] => {
187
148
  const groups: NavGroup[] = [];
188
149
  const groupMap = new Map<string | null, NavItem[]>();
@@ -199,40 +160,11 @@
199
160
  return groups;
200
161
  });
201
162
 
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
- });
163
+ let openGroups = $state<Set<string>>(new Set());
164
+ let colorPickerOpen = $state(false);
165
+ let colorPickerRef = $state<HTMLDivElement | null>(null);
166
+ let colorPickerOpenedAt = 0;
234
167
 
235
- // Density state
236
168
  let density = $state<'compact' | 'standard'>('standard');
237
169
 
238
170
  $effect(() => {
@@ -249,108 +181,88 @@
249
181
  }
250
182
  });
251
183
 
252
- const pyClass = $derived(density === 'compact' ? 'py-1.5' : 'py-2.5');
253
- const pyClassGroupItem = $derived(density === 'compact' ? 'py-1.5' : 'py-2');
184
+ const pyClass = $derived(density === 'compact' ? 'py-1' : 'py-[7px]');
185
+ const pyClassGroupItem = $derived(density === 'compact' ? 'py-1' : 'py-[6px]');
254
186
 
187
+ $effect(() => {
188
+ if (!colorPickerOpen) return;
189
+ function handleMouseDown(e: MouseEvent) {
190
+ if (Date.now() - colorPickerOpenedAt < 200) return;
191
+ if (colorPickerRef && !colorPickerRef.contains(e.target as Node)) {
192
+ colorPickerOpen = false;
193
+ }
194
+ }
195
+ document.addEventListener('mousedown', handleMouseDown, true);
196
+ return () => {
197
+ document.removeEventListener('mousedown', handleMouseDown, true);
198
+ };
199
+ });
255
200
  </script>
256
201
 
257
202
 
258
203
 
259
204
  <aside
260
205
  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}
263
- class:w-16={collapsed}
264
- data-sidebar="sidebar"
265
- data-collapsed={collapsed ? "true" : "false"}
206
+ class="fixed inset-y-0 left-0 z-30 flex flex-col border-r border-border/40 transition-all duration-300"
207
+ style="background-color: var(--sidebar);"
208
+ class:w-[252px]={!collapsed}
209
+ class:w-[70px]={collapsed}
266
210
  >
267
- <!-- 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'}">
269
- {#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>
278
- </div>
279
- {: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>
283
- {/if}
211
+ <div class="flex h-[70px] items-center shrink-0" class:px-5={!collapsed} class:justify-center={collapsed}>
284
212
  {#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">
286
- <ChevronLeft class="h-4 w-4" />
287
- </TooltipButton>
213
+ <a href="#/" class="flex items-center gap-2.5 group" onclick={(e) => { e.preventDefault(); navigate('/'); }}>
214
+ <div class="w-[34px] h-[34px] rounded-lg bg-gradient-to-tr from-primary to-purple-500 shrink-0 transition-transform group-hover:scale-105" style="box-shadow: 0 4px 12px oklch(0.488 0.243 264.376 / 20%);"></div>
215
+ <span class="font-semibold text-[15px] tracking-[-0.01em] text-sidebar-foreground">{title}</span>
216
+ </a>
288
217
  {: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">
290
- <ChevronRight class="h-4 w-4" />
291
- </TooltipButton>
218
+ <a href="#/" class="group" aria-label={title} onclick={(e) => { e.preventDefault(); navigate('/'); }}>
219
+ <div class="w-[34px] h-[34px] rounded-lg bg-gradient-to-tr from-primary to-purple-500 shrink-0 transition-transform group-hover:scale-105" style="box-shadow: 0 4px 12px oklch(0.488 0.243 264.376 / 20%);"></div>
220
+ </a>
292
221
  {/if}
293
222
  </div>
294
223
 
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">
224
+ <ScrollArea class="flex-1 sidebar-scroll">
225
+ <nav aria-label="Main menu" class="pb-4" class:px-[10px]={!collapsed} class:px-2={collapsed}>
298
226
  {#if menu && menu.length > 0}
299
- <!-- Custom multi-level menu -->
300
- {#each menu as item, _i (_i)}
301
- <SidebarItem {item} currentPath={path} {collapsed} depth={0} />
302
- {/each}
227
+ <div class="space-y-[2px]">
228
+ {#each menu as item, _i (_i)}
229
+ <SidebarItem {item} currentPath={path} {collapsed} depth={0} />
230
+ {/each}
231
+ </div>
303
232
  {:else}
304
- <!-- Auto-generated menu from resources (fallback) -->
305
233
  {#each navGroups as group, _i (_i)}
306
234
  {#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)}
235
+ <Collapsible.Root open={openGroups.has(group.name)} onOpenChange={(isOpen) => {
236
+ const next = new Set(openGroups);
237
+ if (isOpen) next.add(group.name as string); else next.delete(group.name as string);
238
+ openGroups = next;
239
+ }}>
240
+ <Collapsible.Trigger
241
+ class="flex w-full items-center justify-between px-[10px] py-[6px] mt-4 mb-[2px] text-[10.5px] font-semibold tracking-[0.06em] uppercase text-sidebar-foreground/45 hover:text-sidebar-foreground/70 transition-colors"
317
242
  >
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">
243
+ <span>{group.name}</span>
244
+ <ChevronDown class="h-3 w-3 transition-transform duration-200 {openGroups.has(group.name) ? 'rotate-180' : ''}" />
245
+ </Collapsible.Trigger>
246
+ <Collapsible.Content>
247
+ <div class="space-y-[2px]">
328
248
  {#each group.items as item, _i (_i)}
329
249
  {@const active = isActive(item.path)}
330
250
  <a
331
251
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
332
252
  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
253
+ class="sidebar-menu-item flex items-center gap-2.5 rounded-md px-[10px] {pyClassGroupItem} text-[13px] font-medium transition-colors duration-150
334
254
  {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"}
255
+ ? 'sidebar-menu-item-active bg-sidebar-accent text-primary'
256
+ : 'text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent/50'}"
339
257
  >
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}
258
+ <item.Icon class="h-[18px] w-[18px] flex-shrink-0 {active ? 'text-primary' : 'text-sidebar-foreground/50'}" />
259
+ <span>{item.label}</span>
347
260
  </a>
348
261
  {/each}
349
262
  </div>
350
- {/if}
351
- </div>
263
+ </Collapsible.Content>
264
+ </Collapsible.Root>
352
265
  {:else}
353
- <!-- Ungrouped items (flat) -->
354
266
  {#each group.items as item, _i (_i)}
355
267
  {@const active = isActive(item.path)}
356
268
  {#if collapsed}
@@ -361,14 +273,12 @@
361
273
  {...props}
362
274
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
363
275
  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
276
+ class="sidebar-menu-item flex items-center justify-center rounded-md px-2 {pyClass} transition-colors duration-150
365
277
  {active
366
- ? 'text-primary'
367
- : 'text-sidebar-foreground/60 hover:text-sidebar-foreground'}"
368
- data-sidebar="menu-button"
369
- data-active={active ? "true" : "false"}
278
+ ? 'sidebar-menu-item-active bg-sidebar-accent text-primary'
279
+ : 'text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
370
280
  >
371
- <item.Icon class="h-4 w-4 flex-shrink-0" />
281
+ <item.Icon class="h-[18px] w-[18px] flex-shrink-0" />
372
282
  </a>
373
283
  {/snippet}
374
284
  </Tooltip.Trigger>
@@ -380,19 +290,13 @@
380
290
  <a
381
291
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
382
292
  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
293
+ class="sidebar-menu-item flex items-center gap-2.5 rounded-md px-[10px] {pyClass} text-[13px] font-medium transition-colors duration-150
384
294
  {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}
295
+ ? 'sidebar-menu-item-active bg-sidebar-accent text-primary'
296
+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
297
+ >
298
+ <item.Icon class="h-[18px] w-[18px] flex-shrink-0 {active ? 'text-primary' : 'text-sidebar-foreground/50'}" />
299
+ <span>{item.label}</span>
396
300
  </a>
397
301
  {/if}
398
302
  {/each}
@@ -401,133 +305,143 @@
401
305
  {/if}
402
306
  </nav>
403
307
  </ScrollArea>
404
- </div>
405
308
 
406
- <!-- 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)}
309
+ <div class="shrink-0 border-t border-border/40">
310
+ {#if !collapsed}
311
+ <div class="px-3 pt-3 pb-1">
312
+ <div class="relative" bind:this={colorPickerRef}>
313
+ <button
314
+ class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[12px] text-sidebar-foreground/60 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground transition-colors"
315
+ onclick={() => { if (!colorPickerOpen) colorPickerOpenedAt = Date.now(); colorPickerOpen = !colorPickerOpen; }}
316
+ >
317
+ <Palette class="h-3.5 w-3.5" />
318
+ <span class="flex-1 text-left">{t('common.toggleTheme')}</span>
319
+ <span
320
+ class="h-3 w-3 rounded-full ring-1 ring-offset-1 ring-offset-sidebar"
321
+ style="background-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}; --tw-ring-color: {getColorThemes().find(c => c.id === getColorTheme())?.color ?? '#6366f1'}"
322
+ ></span>
323
+ </button>
324
+ {#if colorPickerOpen}
325
+ <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">
326
+ {#each getColorThemes() as ct, _i (_i)}
452
327
  <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)}
328
+ 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"
329
+ onclick={() => { setColorTheme(ct.id as typeof ct.id & import('@svadmin/core').ColorTheme); colorPickerOpen = false; }}
459
330
  >
460
- <item.icon class="h-4 w-4 shrink-0" />
461
- <span class="min-w-0 flex-1 truncate">{t(item.label)}</span>
331
+ <span
332
+ class="h-3 w-3 rounded-full {getColorTheme() === ct.id ? 'ring-2 ring-offset-1 scale-110' : 'opacity-70'}"
333
+ style="background-color: {ct.color}; {getColorTheme() === ct.id ? `--tw-ring-color: ${ct.color}` : ''}"
334
+ ></span>
335
+ <span class="text-xs">{ct.label}</span>
462
336
  </button>
463
337
  {/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
338
  </div>
470
- </Popover.Content>
471
- </Popover.Root>
339
+ {/if}
340
+ </div>
341
+ </div>
342
+ {/if}
343
+
344
+ {#if !collapsed && identity}
345
+ <div class="px-3 pb-3 pt-1">
346
+ <div class="flex items-center gap-2.5 rounded-lg p-2 transition-colors hover:bg-sidebar-accent/40 cursor-pointer">
347
+ <Avatar
348
+ src={(identity as Record<string, unknown>).avatar as string | undefined}
349
+ alt={identity.name ?? 'User'}
350
+ fallback={identity.name?.charAt(0).toUpperCase() ?? 'U'}
351
+ size="sm"
352
+ />
353
+ <div class="flex-1 min-w-0">
354
+ <p class="truncate text-[13px] font-medium text-sidebar-foreground">{identity.name}</p>
355
+ <p class="truncate text-[11px] text-sidebar-foreground/45">{((identity as Record<string, unknown>).role || (identity as Record<string, unknown>).roleName) ?? 'User'}</p>
356
+ </div>
357
+ <button
358
+ class="h-7 w-7 flex items-center justify-center rounded-md text-sidebar-foreground/40 hover:text-sidebar-foreground hover:bg-sidebar-accent/60 transition-colors"
359
+ onclick={onLogout}
360
+ >
361
+ <LogOut class="h-3.5 w-3.5" />
362
+ </button>
363
+ </div>
364
+ <div class="mt-1 flex items-center justify-between px-1">
365
+ <div class="flex items-center gap-0.5">
366
+ <TooltipButton tooltip={t('common.switchLanguage')} variant="ghost" size="icon-sm" onclick={toggleLocale} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
367
+ <span class="text-[11px] font-semibold">{localeLabel}</span>
368
+ </TooltipButton>
369
+ <TooltipButton tooltip={t('common.toggleTheme')} variant="ghost" size="icon-sm" onclick={toggleTheme} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
370
+ {#if getResolvedTheme() === 'dark'}
371
+ <Sun class="h-3.5 w-3.5" />
372
+ {:else}
373
+ <Moon class="h-3.5 w-3.5" />
374
+ {/if}
375
+ </TooltipButton>
376
+ <TooltipButton tooltip={t('settings.title')} variant="ghost" size="icon-sm" onclick={() => navigate('/settings')} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
377
+ <Settings class="h-3.5 w-3.5" />
378
+ </TooltipButton>
379
+ </div>
380
+ <TooltipButton tooltip={t('common.toggleSidebar')} variant="ghost" size="icon-sm" onclick={onToggle} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
381
+ <ChevronLeft class="h-3.5 w-3.5" />
382
+ </TooltipButton>
383
+ </div>
472
384
  </div>
473
385
  {: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}
386
+ <div class="flex flex-col items-center gap-0.5 px-2 py-3">
387
+ <Tooltip.Root>
388
+ <Tooltip.Trigger>
389
+ {#snippet child({ props }: { props: Record<string, unknown> })}
390
+ <Button {...props} variant="ghost" size="icon" onclick={toggleLocale} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
391
+ <span class="text-[11px] font-semibold">{localeLabel}</span>
392
+ </Button>
393
+ {/snippet}
394
+ </Tooltip.Trigger>
395
+ <Tooltip.Content side="right">{t('common.switchLanguage')}</Tooltip.Content>
396
+ </Tooltip.Root>
397
+ <Tooltip.Root>
398
+ <Tooltip.Trigger>
399
+ {#snippet child({ props }: { props: Record<string, unknown> })}
400
+ <Button {...props} variant="ghost" size="icon" onclick={toggleTheme} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
401
+ {#if getResolvedTheme() === 'dark'}
402
+ <Sun class="h-3.5 w-3.5" />
403
+ {:else}
404
+ <Moon class="h-3.5 w-3.5" />
405
+ {/if}
406
+ </Button>
407
+ {/snippet}
408
+ </Tooltip.Trigger>
409
+ <Tooltip.Content side="right">{t('common.toggleTheme')}</Tooltip.Content>
410
+ </Tooltip.Root>
411
+ <Tooltip.Root>
412
+ <Tooltip.Trigger>
413
+ {#snippet child({ props }: { props: Record<string, unknown> })}
414
+ <Button {...props} variant="ghost" size="icon" onclick={onToggle} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
415
+ <ChevronRight class="h-3.5 w-3.5" />
416
+ </Button>
417
+ {/snippet}
418
+ </Tooltip.Trigger>
419
+ <Tooltip.Content side="right">{t('common.toggleSidebar')}</Tooltip.Content>
420
+ </Tooltip.Root>
421
+ <Tooltip.Root>
422
+ <Tooltip.Trigger>
423
+ {#snippet child({ props }: { props: Record<string, unknown> })}
424
+ <Button {...props} variant="ghost" size="icon" onclick={onLogout} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-destructive">
425
+ <LogOut class="h-3.5 w-3.5" />
426
+ </Button>
427
+ {/snippet}
428
+ </Tooltip.Trigger>
429
+ <Tooltip.Content side="right">{t('common.logout')}</Tooltip.Content>
430
+ </Tooltip.Root>
528
431
  </div>
529
432
  {:else}
530
- <div class="h-3"></div>
433
+ <div class="flex justify-center gap-1 p-3">
434
+ <TooltipButton tooltip={t('common.switchLanguage')} variant="ghost" size="icon" onclick={toggleLocale} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
435
+ <span class="text-[11px] font-semibold">{localeLabel}</span>
436
+ </TooltipButton>
437
+ <TooltipButton tooltip={t('common.toggleTheme')} variant="ghost" size="icon" onclick={toggleTheme} class="h-8 w-8 rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground">
438
+ {#if getResolvedTheme() === 'dark'}
439
+ <Sun class="h-3.5 w-3.5" />
440
+ {:else}
441
+ <Moon class="h-3.5 w-3.5" />
442
+ {/if}
443
+ </TooltipButton>
444
+ </div>
531
445
  {/if}
532
446
  </div>
533
447
  </aside>