@@ -4,20 +4,23 @@ import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarItem } fr
44import { AppTopBar } from '@/components/layout/AppTopBar'
55import { Button } from '@/components/ui/button'
66import { Workflow , KeyRound , Plus , Plug , Archive , CalendarClock , Sun , Moon , Shield } from 'lucide-react'
7- import React , { useState , useEffect } from 'react'
7+ import React , { useState , useEffect , useCallback } from 'react'
88import { useAuthStore } from '@/store/authStore'
99import { hasAdminRole } from '@/utils/auth'
1010import { UserButton } from '@/components/auth/UserButton'
1111import { useAuth , useAuthProvider } from '@/auth/auth-context'
1212import { env } from '@/config/env'
1313import { useThemeStore } from '@/store/themeStore'
14+ import { cn } from '@/lib/utils'
15+ import { setMobilePlacementSidebarClose } from '@/components/layout/Sidebar'
1416
1517interface AppLayoutProps {
1618 children : React . ReactNode
1719}
1820
1921interface SidebarContextValue {
2022 isOpen : boolean
23+ isMobile : boolean
2124 toggle : ( ) => void
2225}
2326
@@ -31,10 +34,30 @@ export function useSidebar() {
3134 return context
3235}
3336
37+
38+ // Custom hook to detect mobile viewport
39+ function useIsMobile ( breakpoint = 768 ) {
40+ const [ isMobile , setIsMobile ] = useState (
41+ typeof window !== 'undefined' ? window . innerWidth < breakpoint : false
42+ )
43+
44+ useEffect ( ( ) => {
45+ const handleResize = ( ) => {
46+ setIsMobile ( window . innerWidth < breakpoint )
47+ }
48+
49+ window . addEventListener ( 'resize' , handleResize )
50+ return ( ) => window . removeEventListener ( 'resize' , handleResize )
51+ } , [ breakpoint ] )
52+
53+ return isMobile
54+ }
55+
3456export function AppLayout ( { children } : AppLayoutProps ) {
35- const [ sidebarOpen , setSidebarOpen ] = useState ( true )
57+ const isMobile = useIsMobile ( )
58+ const [ sidebarOpen , setSidebarOpen ] = useState ( ! isMobile )
3659 const [ , setIsHovered ] = useState ( false )
37- const [ wasExplicitlyOpened , setWasExplicitlyOpened ] = useState ( true )
60+ const [ wasExplicitlyOpened , setWasExplicitlyOpened ] = useState ( ! isMobile )
3861 const location = useLocation ( )
3962 const navigate = useNavigate ( )
4063 const roles = useAuthStore ( ( state ) => state . roles )
@@ -52,36 +75,142 @@ export function AppLayout({ children }: AppLayoutProps) {
5275 : 'dev'
5376
5477 // Auto-collapse sidebar when opening workflow builder, expand for other routes
78+ // On mobile, always start collapsed
79+ useEffect ( ( ) => {
80+ if ( isMobile ) {
81+ setSidebarOpen ( false )
82+ setWasExplicitlyOpened ( false )
83+ } else {
84+ const isWorkflowRoute = location . pathname . startsWith ( '/workflows' ) && location . pathname !== '/'
85+ setSidebarOpen ( ! isWorkflowRoute )
86+ setWasExplicitlyOpened ( ! isWorkflowRoute )
87+ }
88+ } , [ location . pathname , isMobile ] )
89+
90+ // Close sidebar on mobile when navigating
5591 useEffect ( ( ) => {
56- const isWorkflowRoute = location . pathname . startsWith ( '/workflows' ) && location . pathname !== '/'
57- setSidebarOpen ( ! isWorkflowRoute )
58- setWasExplicitlyOpened ( ! isWorkflowRoute )
59- } , [ location . pathname ] )
92+ if ( isMobile ) {
93+ setSidebarOpen ( false )
94+ }
95+ } , [ location . pathname , isMobile ] )
6096
61- // Handle hover to expand sidebar when collapsed
97+ // Set up sidebar close callback for mobile component placement
98+ useEffect ( ( ) => {
99+ if ( isMobile ) {
100+ setMobilePlacementSidebarClose ( ( ) => {
101+ setSidebarOpen ( false )
102+ setWasExplicitlyOpened ( false )
103+ } )
104+ }
105+ return ( ) => {
106+ setMobilePlacementSidebarClose ( ( ) => { } )
107+ }
108+ } , [ isMobile ] )
109+
110+ // Handle hover to expand sidebar when collapsed (desktop only)
62111 const handleMouseEnter = ( ) => {
112+ if ( isMobile ) return
63113 setIsHovered ( true )
64114 if ( ! sidebarOpen ) {
65115 setSidebarOpen ( true )
66116 }
67117 }
68118
69119 const handleMouseLeave = ( ) => {
120+ if ( isMobile ) return
70121 setIsHovered ( false )
71122 // Only collapse if it was expanded due to hover (not explicitly opened)
72123 if ( ! wasExplicitlyOpened && sidebarOpen ) {
73124 setSidebarOpen ( false )
74125 }
75126 }
76127
77- const handleToggle = ( ) => {
128+ const handleToggle = useCallback ( ( ) => {
78129 const newState = ! sidebarOpen
79130 setSidebarOpen ( newState )
80131 setWasExplicitlyOpened ( newState )
81- }
132+ } , [ sidebarOpen ] )
133+
134+ // --- Swipe Gesture Logic for Mobile ---
135+ const [ touchStart , setTouchStart ] = useState < number | null > ( null )
136+
137+ useEffect ( ( ) => {
138+ if ( ! isMobile ) return
139+
140+ const handleTouchStart = ( e : TouchEvent ) => {
141+ const x = e . touches [ 0 ] . clientX
142+ // Start tracking if touching near the left edge to open
143+ if ( ! sidebarOpen && x < 30 ) {
144+ setTouchStart ( x )
145+ }
146+ // Or if sidebar is already open, track anywhere to detect closing swipe
147+ else if ( sidebarOpen ) {
148+ setTouchStart ( x )
149+ }
150+ }
151+
152+ const handleTouchMove = ( e : TouchEvent ) => {
153+ if ( touchStart === null ) return
154+
155+ const currentX = e . touches [ 0 ] . clientX
156+ const diff = currentX - touchStart
157+
158+ // Prevent default scrolling if we are clearly swiping the sidebar
159+ if ( Math . abs ( diff ) > 10 ) {
160+ // If sidebar is closed and we're swiping right (opening)
161+ if ( ! sidebarOpen && diff > 0 ) {
162+ // e.preventDefault() // This might trigger passive warning if not careful
163+ }
164+ // If sidebar is open and we're swiping left (closing)
165+ if ( sidebarOpen && diff < 0 ) {
166+ // e.preventDefault()
167+ }
168+ }
169+ }
170+
171+ const handleTouchEnd = ( e : TouchEvent ) => {
172+ if ( touchStart === null ) return
173+
174+ const endX = e . changedTouches [ 0 ] . clientX
175+ const diff = endX - touchStart
176+ const threshold = 50 // px to trigger toggle
177+
178+ // Swipe right to open
179+ if ( ! sidebarOpen && diff > threshold && touchStart < 30 ) {
180+ setSidebarOpen ( true )
181+ setWasExplicitlyOpened ( true )
182+ }
183+ // Swipe left to close
184+ else if ( sidebarOpen && diff < - threshold ) {
185+ setSidebarOpen ( false )
186+ setWasExplicitlyOpened ( false )
187+ }
188+
189+ setTouchStart ( null )
190+ }
191+
192+ window . addEventListener ( 'touchstart' , handleTouchStart , { passive : true } )
193+ window . addEventListener ( 'touchmove' , handleTouchMove , { passive : true } )
194+ window . addEventListener ( 'touchend' , handleTouchEnd , { passive : true } )
195+
196+ return ( ) => {
197+ window . removeEventListener ( 'touchstart' , handleTouchStart )
198+ window . removeEventListener ( 'touchmove' , handleTouchMove )
199+ window . removeEventListener ( 'touchend' , handleTouchEnd )
200+ }
201+ } , [ isMobile , sidebarOpen , touchStart ] )
202+
203+ // Close sidebar when clicking backdrop on mobile
204+ const handleBackdropClick = useCallback ( ( ) => {
205+ if ( isMobile && sidebarOpen ) {
206+ setSidebarOpen ( false )
207+ setWasExplicitlyOpened ( false )
208+ }
209+ } , [ isMobile , sidebarOpen ] )
82210
83211 const sidebarContextValue : SidebarContextValue = {
84212 isOpen : sidebarOpen ,
213+ isMobile,
85214 toggle : handleToggle
86215 }
87216
@@ -134,12 +263,13 @@ export function AppLayout({ children }: AppLayoutProps) {
134263 if ( ! canManageWorkflows ) return
135264 navigate ( '/workflows/new' )
136265 } }
137- className = "gap-2"
266+ size = { isMobile ? "sm" : "default" }
267+ className = { cn ( "gap-2" , isMobile && "h-8 px-3 text-xs" ) }
138268 disabled = { ! canManageWorkflows }
139269 aria-disabled = { ! canManageWorkflows }
140270 >
141- < Plus className = " h-4 w-4" />
142- New Workflow
271+ < Plus className = { cn ( "w-4 h-4" , isMobile && "w-3.5 h-3.5" ) } />
272+ < span > New < span className = "hidden md:inline" > Workflow</ span > </ span >
143273 </ Button >
144274 )
145275 }
@@ -150,34 +280,50 @@ export function AppLayout({ children }: AppLayoutProps) {
150280 return (
151281 < SidebarContext . Provider value = { sidebarContextValue } >
152282 < ThemeTransition />
153- < div className = "flex h-screen bg-background" >
154- { /* Sidebar - z-[100] ensures it's above all other elements including workflow buttons */ }
283+ < div className = "flex h-screen bg-background overflow-hidden" >
284+ { /* Mobile backdrop overlay */ }
285+ { isMobile && sidebarOpen && (
286+ < div
287+ className = "fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm transition-opacity duration-300"
288+ onClick = { handleBackdropClick }
289+ aria-hidden = "true"
290+ />
291+ ) }
292+
293+ { /* Sidebar */ }
155294 < Sidebar
156- className = { `fixed md:relative z-[100] h-full transition-all duration-300 ${ sidebarOpen ? 'w-64' : 'w-0 md:w-16'
157- } `}
295+ className = { cn (
296+ 'h-full transition-all duration-300 z-[110]' ,
297+ // Mobile: Fixed position, slide in/out
298+ isMobile ? 'fixed left-0 top-0' : 'relative' ,
299+ // Width based on state and device
300+ sidebarOpen ? 'w-72' : isMobile ? 'w-0 -translate-x-full' : 'w-16' ,
301+ // Ensure sidebar is above backdrop on mobile
302+ isMobile && sidebarOpen && 'translate-x-0'
303+ ) }
158304 onMouseEnter = { handleMouseEnter }
159305 onMouseLeave = { handleMouseLeave }
160306 >
307+ { /* Sidebar Header - same style for mobile and desktop */ }
161308 < SidebarHeader className = "flex items-center justify-between p-4 border-b" >
162- < Link to = "/" className = "flex items-center gap-2" >
309+ < Link to = "/" className = "flex items-center gap-2" onClick = { ( ) => isMobile && setSidebarOpen ( false ) } >
163310 < div className = "flex-shrink-0" >
164311 < img
165312 src = "/favicon.ico"
166313 alt = "ShipSec Studio"
167314 className = "w-6 h-6"
168315 onError = { ( e ) => {
169- // Fallback to text if image fails to load
170316 e . currentTarget . style . display = 'none'
171317 e . currentTarget . nextElementSibling ?. classList . remove ( 'hidden' )
172318 } }
173319 />
174320 < span className = "hidden text-sm font-bold" > SS</ span >
175321 </ div >
176322 < span
177- className = { `font-bold text-xl transition-all duration-300 whitespace-nowrap overflow-hidden ${ sidebarOpen
178- ? 'opacity-100 max-w-48'
179- : 'opacity-0 max-w-0'
180- } ` }
323+ className = { cn (
324+ 'font-bold text-xl transition-all duration-300 whitespace-nowrap overflow-hidden' ,
325+ sidebarOpen ? 'opacity-100 max-w-48' : 'opacity-0 max-w-0'
326+ ) }
181327 style = { {
182328 transitionDelay : sidebarOpen ? '150ms' : '0ms' ,
183329 transitionProperty : 'opacity, max-width'
@@ -189,7 +335,9 @@ export function AppLayout({ children }: AppLayoutProps) {
189335 </ SidebarHeader >
190336
191337 < SidebarContent className = "py-0" >
192- < div className = "space-y-1 px-2 mt-2" >
338+ < div className = { cn (
339+ 'px-2 mt-2 space-y-1'
340+ ) } >
193341 { navigationItems . map ( ( item ) => {
194342 const Icon = item . icon
195343 const active = isActive ( item . href )
@@ -198,7 +346,12 @@ export function AppLayout({ children }: AppLayoutProps) {
198346 key = { item . href }
199347 to = { item . href }
200348 onClick = { ( ) => {
201- // Keep sidebar open when navigating to non-workflow routes
349+ // Close sidebar on mobile after navigation
350+ if ( isMobile ) {
351+ setSidebarOpen ( false )
352+ return
353+ }
354+ // Keep sidebar open when navigating to non-workflow routes (desktop)
202355 if ( ! item . href . startsWith ( '/workflows' ) ) {
203356 setSidebarOpen ( true )
204357 setWasExplicitlyOpened ( true )
@@ -207,14 +360,17 @@ export function AppLayout({ children }: AppLayoutProps) {
207360 >
208361 < SidebarItem
209362 isActive = { active }
210- className = "flex items-center gap-3 justify-center md:justify-start"
363+ className = { cn (
364+ 'flex items-center gap-3' ,
365+ sidebarOpen ? 'justify-start px-4' : 'justify-center'
366+ ) }
211367 >
212368 < Icon className = "h-5 w-5 flex-shrink-0" />
213369 < span
214- className = { `transition-all duration-300 whitespace-nowrap overflow-hidden ${ sidebarOpen
215- ? 'opacity-100 max-w-32'
216- : 'opacity-0 max-w-0'
217- } ` }
370+ className = { cn (
371+ 'transition-all duration-300 whitespace-nowrap overflow-hidden flex-1' ,
372+ sidebarOpen ? 'opacity-100' : 'opacity-0 max-w-0'
373+ ) }
218374 style = { {
219375 transitionDelay : sidebarOpen ? '200ms' : '0ms' ,
220376 transitionProperty : 'opacity, max-width'
@@ -277,10 +433,10 @@ export function AppLayout({ children }: AppLayoutProps) {
277433 < div className = "px-2 py-1.5 border-t" >
278434 < div className = "h-4 flex items-center justify-center" >
279435 < span
280- className = { `text-xs text-muted-foreground transition-all duration-300 whitespace-nowrap overflow-hidden block text-center ${ sidebarOpen
281- ? 'opacity-100 max-w-full'
282- : 'opacity-0 max-w-0'
283- } ` }
436+ className = { cn (
437+ 'text-xs text-muted-foreground transition-all duration-300 whitespace-nowrap overflow-hidden block text-center' ,
438+ sidebarOpen ? 'opacity-100 max-w-full' : 'opacity-0 max-w-0'
439+ ) }
284440 style = { {
285441 transitionDelay : sidebarOpen ? '200ms' : '0ms' ,
286442 transitionProperty : 'opacity, max-width'
@@ -293,13 +449,18 @@ export function AppLayout({ children }: AppLayoutProps) {
293449 </ Sidebar >
294450
295451 { /* Main content area */ }
296- < main className = "flex-1 flex flex-col overflow-hidden" >
452+ < main className = { cn (
453+ 'flex-1 flex flex-col overflow-hidden min-w-0' ,
454+ // On mobile, main content takes full width since sidebar is overlay
455+ isMobile ? 'w-full' : ''
456+ ) } >
297457 { /* Only show AppTopBar for non-workflow-builder pages */ }
298458 { ! location . pathname . startsWith ( '/workflows' ) && (
299459 < AppTopBar
300460 sidebarOpen = { sidebarOpen }
301461 onSidebarToggle = { handleToggle }
302462 actions = { getPageActions ( ) }
463+ isMobile = { isMobile }
303464 />
304465 ) }
305466 < div className = "flex-1 overflow-auto" >
0 commit comments