@@ -23,6 +23,8 @@ import {
2323 LifeBuoy ,
2424 Mail ,
2525 Palette ,
26+ PanelLeftClose ,
27+ PanelLeftOpen ,
2628 Plug ,
2729 Radar ,
2830 ScrollText ,
@@ -41,6 +43,7 @@ import {
4143} from 'lucide-react'
4244import { useTranslation } from 'react-i18next'
4345import { cn } from '@/shared/lib/utils'
46+ import { Tooltip , TooltipContent , TooltipTrigger } from '@/shared/components/ui/tooltip'
4447import { useAuth } from '@/features/auth'
4548import { useBilling } from '@/features/billing'
4649import { useSupportTenant } from '@/shared/lib/current-tenant'
@@ -177,6 +180,7 @@ const settingsItems: {
177180
178181const SECTIONS_KEY = 'utmstack-sidebar-sections-closed'
179182const GROUPS_KEY = 'utmstack-sidebar-groups-closed'
183+ const COLLAPSED_KEY = 'utmstack-sidebar-collapsed'
180184
181185function loadSet ( key : string ) : Set < string > {
182186 if ( typeof window === 'undefined' ) return new Set ( )
@@ -190,6 +194,36 @@ function loadSet(key: string): Set<string> {
190194 }
191195}
192196
197+ function loadCollapsed ( ) : boolean {
198+ if ( typeof window === 'undefined' ) return false
199+ return localStorage . getItem ( COLLAPSED_KEY ) === '1'
200+ }
201+
202+ function useCollapsed ( ) : [ boolean , ( ) => void ] {
203+ const [ collapsed , setCollapsed ] = useState < boolean > ( loadCollapsed )
204+ useEffect ( ( ) => {
205+ localStorage . setItem ( COLLAPSED_KEY , collapsed ? '1' : '0' )
206+ } , [ collapsed ] )
207+ return [ collapsed , ( ) => setCollapsed ( ( v ) => ! v ) ]
208+ }
209+
210+ function CollapseToggle ( { collapsed, onToggle } : { collapsed : boolean ; onToggle : ( ) => void } ) {
211+ const Icon = collapsed ? PanelLeftOpen : PanelLeftClose
212+ return (
213+ < button
214+ type = "button"
215+ onClick = { onToggle }
216+ aria-label = { collapsed ? 'Expand sidebar' : 'Collapse sidebar' }
217+ className = { cn (
218+ 'flex items-center rounded-lg p-2 text-sidebar-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground' ,
219+ collapsed ? 'justify-center' : 'ml-auto'
220+ ) }
221+ >
222+ < Icon size = { 16 } strokeWidth = { 1.75 } />
223+ </ button >
224+ )
225+ }
226+
193227export function Sidebar ( ) {
194228 const { t } = useTranslation ( )
195229 const { isAdmin, isPlatformAdmin } = useAuth ( )
@@ -201,6 +235,7 @@ export function Sidebar() {
201235 const actingAsPlatform = isPlatformAdmin && supportTenant === null
202236 const [ closedSections , setClosedSections ] = useState < Set < string > > ( ( ) => loadSet ( SECTIONS_KEY ) )
203237 const [ closedGroups , setClosedGroups ] = useState < Set < string > > ( ( ) => loadSet ( GROUPS_KEY ) )
238+ const [ collapsed , toggleCollapsed ] = useCollapsed ( )
204239 const { pathname } = useLocation ( )
205240
206241 useEffect ( ( ) => {
@@ -233,12 +268,20 @@ export function Sidebar() {
233268
234269 // Drill-down: when navigating under /settings the sidebar swaps to a sub-nav.
235270 if ( pathname . startsWith ( '/settings' ) ) {
236- return < SettingsSidebar isPathActive = { isPathActive } />
271+ return < SettingsSidebar isPathActive = { isPathActive } collapsed = { collapsed } onToggleCollapsed = { toggleCollapsed } />
237272 }
238273
239274 return (
240- < aside className = "flex h-full w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground" >
241- < nav className = "flex-1 overflow-y-auto overflow-x-hidden p-2" >
275+ < aside
276+ className = { cn (
277+ 'flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-150' ,
278+ collapsed ? 'w-14' : 'w-60'
279+ ) }
280+ >
281+ < div className = "flex p-2" >
282+ < CollapseToggle collapsed = { collapsed } onToggle = { toggleCollapsed } />
283+ </ div >
284+ < nav className = "flex-1 overflow-y-auto overflow-x-hidden p-2 pt-0" >
242285 { sections
243286 . filter ( ( section ) => ! section . adminOnly || isAdmin )
244287 . map ( ( section , idx ) => {
@@ -248,7 +291,7 @@ export function Sidebar() {
248291 key = { section . id }
249292 className = { cn ( idx > 0 && 'mt-2 pt-2 border-t border-sidebar-border/60' ) }
250293 >
251- { section . label && (
294+ { section . label && ! collapsed && (
252295 < button
253296 onClick = { ( ) => toggleSection ( section . id ) }
254297 className = "mb-1 flex w-full items-center justify-between px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/50 hover:text-sidebar-foreground"
@@ -260,7 +303,7 @@ export function Sidebar() {
260303 />
261304 </ button >
262305 ) }
263- { ! isClosed && (
306+ { ( collapsed || ! isClosed ) && (
264307 < div className = "space-y-0.5" >
265308 { section . items
266309 . filter (
@@ -276,12 +319,15 @@ export function Sidebar() {
276319 onToggle = { ( ) => toggleGroup ( item . id ) }
277320 isPathActive = { isPathActive }
278321 isBaseActive = { isBaseActive }
322+ collapsed = { collapsed }
323+ onExpandRequest = { toggleCollapsed }
279324 />
280325 ) : (
281326 < SidebarLeaf
282327 key = { item . to }
283328 item = { item }
284329 active = { item . matchPrefix ? isBaseActive ( item . to ) : isPathActive ( item . to ) }
330+ collapsed = { collapsed }
285331 />
286332 )
287333 ) }
@@ -299,28 +345,40 @@ function SidebarLeaf({
299345 item,
300346 active,
301347 nested,
348+ collapsed,
302349} : {
303350 item : { to : string ; label : string ; icon : LucideIcon }
304351 active : boolean
305352 nested ?: boolean
353+ collapsed ?: boolean
306354} ) {
307355 const { t } = useTranslation ( )
308356 const Icon = item . icon
309- return (
357+ const label = t ( item . label )
358+ const link = (
310359 < Link
311360 to = { item . to }
312361 className = { cn (
313- 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors' ,
362+ 'flex items-center rounded-lg text-sm transition-colors' ,
363+ collapsed ? 'justify-center px-2 py-2' : 'gap-3 px-3 py-2' ,
314364 active
315365 ? 'bg-primary/15 text-primary'
316366 : 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground' ,
317- nested && 'pl-7'
367+ ! collapsed && nested && 'pl-7'
318368 ) }
369+ aria-label = { collapsed ? label : undefined }
319370 >
320371 < Icon size = { 16 } strokeWidth = { 1.75 } className = "shrink-0" />
321- < span className = "whitespace-nowrap" > { t ( item . label ) } </ span >
372+ { ! collapsed && < span className = "whitespace-nowrap" > { label } </ span > }
322373 </ Link >
323374 )
375+ if ( ! collapsed ) return link
376+ return (
377+ < Tooltip >
378+ < TooltipTrigger asChild > { link } </ TooltipTrigger >
379+ < TooltipContent side = "right" > { label } </ TooltipContent >
380+ </ Tooltip >
381+ )
324382}
325383
326384function SidebarGroup ( {
@@ -329,16 +387,45 @@ function SidebarGroup({
329387 onToggle,
330388 isPathActive,
331389 isBaseActive,
390+ collapsed,
391+ onExpandRequest,
332392} : {
333393 item : GroupItem
334394 open : boolean
335395 onToggle : ( ) => void
336396 isPathActive : ( to : string ) => boolean
337397 isBaseActive : ( base : string ) => boolean
398+ collapsed ?: boolean
399+ onExpandRequest ?: ( ) => void
338400} ) {
339401 const { t } = useTranslation ( )
340402 const Icon = item . icon
341403 const baseActive = isBaseActive ( item . basePath )
404+ const label = t ( item . label )
405+
406+ // Collapsed: no sub-menu room, so clicking a group just re-opens the sidebar.
407+ if ( collapsed ) {
408+ const btn = (
409+ < button
410+ onClick = { onExpandRequest }
411+ aria-label = { label }
412+ className = { cn (
413+ 'flex w-full items-center justify-center rounded-lg px-2 py-2 text-sm transition-colors' ,
414+ baseActive
415+ ? 'bg-primary/10 text-primary'
416+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground'
417+ ) }
418+ >
419+ < Icon size = { 16 } strokeWidth = { 1.75 } className = "shrink-0" />
420+ </ button >
421+ )
422+ return (
423+ < Tooltip >
424+ < TooltipTrigger asChild > { btn } </ TooltipTrigger >
425+ < TooltipContent side = "right" > { label } </ TooltipContent >
426+ </ Tooltip >
427+ )
428+ }
342429
343430 return (
344431 < div >
@@ -352,7 +439,7 @@ function SidebarGroup({
352439 ) }
353440 >
354441 < Icon size = { 16 } strokeWidth = { 1.75 } className = "shrink-0" />
355- < span className = "flex-1 whitespace-nowrap text-left" > { t ( item . label ) } </ span >
442+ < span className = "flex-1 whitespace-nowrap text-left" > { label } </ span >
356443 < ChevronDown
357444 size = { 14 }
358445 className = { cn (
@@ -377,7 +464,15 @@ function SidebarGroup({
377464 )
378465}
379466
380- function SettingsSidebar ( { isPathActive } : { isPathActive : ( to : string ) => boolean } ) {
467+ function SettingsSidebar ( {
468+ isPathActive,
469+ collapsed,
470+ onToggleCollapsed,
471+ } : {
472+ isPathActive : ( to : string ) => boolean
473+ collapsed : boolean
474+ onToggleCollapsed : ( ) => void
475+ } ) {
381476 const { t } = useTranslation ( )
382477 const { isAdmin, isPlatformAdmin } = useAuth ( )
383478 const { license } = useBilling ( )
@@ -387,39 +482,55 @@ function SettingsSidebar({ isPathActive }: { isPathActive: (to: string) => boole
387482 if ( item . tenantOnly ) return isAdmin && ! isPlatformAdmin && license ?. mssp === true
388483 return true
389484 } )
485+ const backLabel = t ( 'nav.back' )
390486 return (
391- < aside className = "flex h-full w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground" >
392- < div className = "border-b border-sidebar-border p-2" >
393- < Link
394- to = "/home"
395- className = "flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
396- >
397- < ArrowLeft size = { 16 } strokeWidth = { 1.75 } className = "shrink-0" />
398- < span > { t ( 'nav.back' ) } </ span >
399- </ Link >
487+ < aside
488+ className = { cn (
489+ 'flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-150' ,
490+ collapsed ? 'w-14' : 'w-60'
491+ ) }
492+ >
493+ < div className = "flex border-b border-sidebar-border p-2" >
494+ { collapsed ? (
495+ < Tooltip >
496+ < TooltipTrigger asChild >
497+ < Link
498+ to = "/home"
499+ aria-label = { backLabel }
500+ className = "flex flex-1 items-center justify-center rounded-lg px-2 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
501+ >
502+ < ArrowLeft size = { 16 } strokeWidth = { 1.75 } className = "shrink-0" />
503+ </ Link >
504+ </ TooltipTrigger >
505+ < TooltipContent side = "right" > { backLabel } </ TooltipContent >
506+ </ Tooltip >
507+ ) : (
508+ < Link
509+ to = "/home"
510+ className = "flex flex-1 items-center gap-2 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
511+ >
512+ < ArrowLeft size = { 16 } strokeWidth = { 1.75 } className = "shrink-0" />
513+ < span > { backLabel } </ span >
514+ </ Link >
515+ ) }
400516 </ div >
401- < div className = "px-3 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/50 " >
402- { t ( 'settings.title' ) }
517+ < div className = "flex p-2 pb-0 " >
518+ < CollapseToggle collapsed = { collapsed } onToggle = { onToggleCollapsed } />
403519 </ div >
520+ { ! collapsed && (
521+ < div className = "px-3 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/50" >
522+ { t ( 'settings.title' ) }
523+ </ div >
524+ ) }
404525 < nav className = "flex-1 space-y-0.5 overflow-y-auto overflow-x-hidden p-2 pt-1" >
405- { items . map ( ( item ) => {
406- const Icon = item . icon
407- return (
408- < Link
409- key = { item . to }
410- to = { item . to }
411- className = { cn (
412- 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors' ,
413- isPathActive ( item . to )
414- ? 'bg-primary/15 text-primary'
415- : 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground'
416- ) }
417- >
418- < Icon size = { 16 } strokeWidth = { 1.75 } className = "shrink-0" />
419- < span className = "whitespace-nowrap" > { t ( item . label ) } </ span >
420- </ Link >
421- )
422- } ) }
526+ { items . map ( ( item ) => (
527+ < SidebarLeaf
528+ key = { item . to }
529+ item = { { to : item . to , label : item . label , icon : item . icon } }
530+ active = { isPathActive ( item . to ) }
531+ collapsed = { collapsed }
532+ />
533+ ) ) }
423534 </ nav >
424535 </ aside >
425536 )
0 commit comments