Skip to content

Commit 98c7637

Browse files
authored
Merge pull request #175 from ShipSecAI/@krishna9358/responsive
feat: Mobile-First Responsive UX & Navigation Overhaul
2 parents d2c78c1 + 78be6ac commit 98c7637

File tree

18 files changed

+1459
-643
lines changed

18 files changed

+1459
-643
lines changed

frontend/src/components/layout/AppLayout.tsx

Lines changed: 196 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,23 @@ import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarItem } fr
44
import { AppTopBar } from '@/components/layout/AppTopBar'
55
import { Button } from '@/components/ui/button'
66
import { 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'
88
import { useAuthStore } from '@/store/authStore'
99
import { hasAdminRole } from '@/utils/auth'
1010
import { UserButton } from '@/components/auth/UserButton'
1111
import { useAuth, useAuthProvider } from '@/auth/auth-context'
1212
import { env } from '@/config/env'
1313
import { useThemeStore } from '@/store/themeStore'
14+
import { cn } from '@/lib/utils'
15+
import { setMobilePlacementSidebarClose } from '@/components/layout/Sidebar'
1416

1517
interface AppLayoutProps {
1618
children: React.ReactNode
1719
}
1820

1921
interface 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+
3456
export 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

Comments
 (0)