Skip to content

Commit ac0c0fb

Browse files
committed
feat: add proper mobile responsive layout
Mobile behavior (< 1024px / lg breakpoint): - Sidebar hidden by default - Hamburger menu in navbar opens sidebar as overlay - Dark backdrop behind sidebar when open - Tap backdrop or press Escape to close - Auto-closes when navigating to a new page - Body scroll locked when menu is open - Full-width sidebar with close button Desktop behavior (>= 1024px): - Sidebar always visible - Can be collapsed to icon-only mode - Collapse toggle in sidebar footer Also: - Added --mobile-breakpoint CSS variable for consistency - Reduced padding on mobile (p-4) vs desktop (md:p-6) - Main content has no left margin on mobile
1 parent 9982e3b commit ac0c0fb

3 files changed

Lines changed: 110 additions & 17 deletions

File tree

frontend/src/components/dashboard/DashboardLayout.tsx

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect } from 'react'
2-
import { Outlet } from 'react-router-dom'
2+
import { Outlet, useLocation } from 'react-router-dom'
33
import { Sidebar } from './Sidebar'
44
import { TopNav } from './TopNav'
55
import { CommandPalette } from './CommandPalette'
@@ -15,7 +15,9 @@ const SIDEBAR_STORAGE_KEY = 'codeintel-sidebar-collapsed'
1515

1616
export function DashboardLayout({ children }: DashboardLayoutProps) {
1717
const { theme } = useTheme()
18+
const location = useLocation()
1819

20+
// Desktop: collapsed state (narrow sidebar)
1921
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
2022
try {
2123
const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY)
@@ -24,8 +26,12 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
2426
return false
2527
}
2628
})
29+
30+
// Mobile: open/closed state (overlay)
31+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
2732
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
2833

34+
// Persist desktop collapsed state
2935
useEffect(() => {
3036
try {
3137
localStorage.setItem(SIDEBAR_STORAGE_KEY, JSON.stringify(sidebarCollapsed))
@@ -34,6 +40,30 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
3440
}
3541
}, [sidebarCollapsed])
3642

43+
// Close mobile menu on route change
44+
useEffect(() => {
45+
setMobileMenuOpen(false)
46+
}, [location.pathname])
47+
48+
// Close mobile menu on escape key
49+
useEffect(() => {
50+
const handleEscape = (e: KeyboardEvent) => {
51+
if (e.key === 'Escape') setMobileMenuOpen(false)
52+
}
53+
document.addEventListener('keydown', handleEscape)
54+
return () => document.removeEventListener('keydown', handleEscape)
55+
}, [])
56+
57+
// Prevent body scroll when mobile menu is open
58+
useEffect(() => {
59+
if (mobileMenuOpen) {
60+
document.body.style.overflow = 'hidden'
61+
} else {
62+
document.body.style.overflow = ''
63+
}
64+
return () => { document.body.style.overflow = '' }
65+
}, [mobileMenuOpen])
66+
3767
useKeyboardShortcut(SHORTCUTS.COMMAND_PALETTE, () => {
3868
setCommandPaletteOpen(true)
3969
})
@@ -42,26 +72,50 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
4272
setSidebarCollapsed((prev: boolean) => !prev)
4373
})
4474

75+
const handleToggleSidebar = () => {
76+
// On mobile: toggle overlay menu
77+
// On desktop: toggle collapsed state
78+
if (window.innerWidth < 1024) {
79+
setMobileMenuOpen(!mobileMenuOpen)
80+
} else {
81+
setSidebarCollapsed(!sidebarCollapsed)
82+
}
83+
}
84+
4585
return (
4686
<div className="min-h-screen bg-background">
4787
<TopNav
48-
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
88+
onToggleSidebar={handleToggleSidebar}
4989
sidebarCollapsed={sidebarCollapsed}
5090
onOpenCommandPalette={() => setCommandPaletteOpen(true)}
5191
/>
5292

5393
<div className="flex">
94+
{/* Mobile backdrop */}
95+
{mobileMenuOpen && (
96+
<div
97+
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
98+
onClick={() => setMobileMenuOpen(false)}
99+
aria-hidden="true"
100+
/>
101+
)}
102+
54103
<Sidebar
55104
collapsed={sidebarCollapsed}
56-
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
105+
onToggle={handleToggleSidebar}
106+
mobileOpen={mobileMenuOpen}
107+
onMobileClose={() => setMobileMenuOpen(false)}
57108
/>
58109

110+
{/* Main content - no margin on mobile, dynamic margin on desktop */}
59111
<main
60-
className={`flex-1 transition-all duration-300 pt-[var(--navbar-height)] ${
61-
sidebarCollapsed ? 'ml-[var(--sidebar-width-collapsed)]' : 'ml-[var(--sidebar-width)]'
62-
}`}
112+
className={`
113+
flex-1 transition-all duration-300 pt-[var(--navbar-height)]
114+
ml-0 lg:ml-[var(--sidebar-width)]
115+
${sidebarCollapsed ? 'lg:ml-[var(--sidebar-width-collapsed)]' : ''}
116+
`}
63117
>
64-
<div className="p-6">
118+
<div className="p-4 md:p-6">
65119
{children || <Outlet />}
66120
</div>
67121
</main>

frontend/src/components/dashboard/Sidebar.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import {
66
BookOpen,
77
ChevronLeft,
88
ChevronRight,
9-
ExternalLink
9+
ExternalLink,
10+
X
1011
} from 'lucide-react'
1112

1213
interface SidebarProps {
1314
collapsed: boolean
1415
onToggle: () => void
16+
mobileOpen?: boolean
17+
onMobileClose?: () => void
1518
}
1619

1720
interface NavItem {
@@ -31,7 +34,7 @@ const bottomNavItems: NavItem[] = [
3134
{ name: 'Settings', href: '/dashboard/settings', icon: <Settings className="w-5 h-5" /> },
3235
]
3336

34-
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
37+
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
3538
const location = useLocation()
3639

3740
const isActive = (href: string) => {
@@ -41,6 +44,11 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
4144
return location.pathname === href
4245
}
4346

47+
const handleNavClick = () => {
48+
// Close mobile menu when navigating
49+
onMobileClose?.()
50+
}
51+
4452
const NavLink = ({ item }: { item: NavItem }) => {
4553
const active = isActive(item.href)
4654

@@ -52,18 +60,22 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
5260
}
5361
`
5462

63+
// On mobile, always show full labels (not collapsed)
64+
const showLabels = !collapsed || mobileOpen
65+
5566
if (item.external) {
5667
return (
5768
<a
5869
href={item.href}
5970
target="_blank"
6071
rel="noopener noreferrer"
6172
className={baseClasses}
73+
onClick={handleNavClick}
6274
>
6375
<span className={active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}>
6476
{item.icon}
6577
</span>
66-
{!collapsed && (
78+
{showLabels && (
6779
<>
6880
<span className="text-sm font-medium truncate">{item.name}</span>
6981
<ExternalLink className="w-3 h-3 ml-auto opacity-50" />
@@ -74,26 +86,49 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
7486
}
7587

7688
return (
77-
<Link to={item.href} className={baseClasses}>
89+
<Link to={item.href} className={baseClasses} onClick={handleNavClick}>
7890
<span className={active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}>
7991
{item.icon}
8092
</span>
81-
{!collapsed && (
93+
{showLabels && (
8294
<span className="text-sm font-medium truncate">{item.name}</span>
8395
)}
8496
</Link>
8597
)
8698
}
8799

100+
// Determine sidebar width class
101+
const getWidthClass = () => {
102+
if (mobileOpen) return 'w-[var(--sidebar-width)]' // Always full width on mobile when open
103+
if (collapsed) return 'w-[var(--sidebar-width-collapsed)]'
104+
return 'w-[var(--sidebar-width)]'
105+
}
106+
88107
return (
89108
<aside
90109
className={`
91110
fixed left-0 top-[var(--navbar-height)] bottom-0 z-40
92111
flex flex-col border-r border-border bg-background
93112
transition-all duration-300
94-
${collapsed ? 'w-[var(--sidebar-width-collapsed)]' : 'w-[var(--sidebar-width)]'}
113+
${getWidthClass()}
114+
115+
/* Mobile: hidden by default, shown when mobileOpen */
116+
${mobileOpen ? 'translate-x-0' : '-translate-x-full'}
117+
lg:translate-x-0
95118
`}
96119
>
120+
{/* Mobile close button */}
121+
<div className="lg:hidden flex items-center justify-between p-3 border-b border-border">
122+
<span className="font-semibold text-foreground">Menu</span>
123+
<button
124+
onClick={onMobileClose}
125+
className="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
126+
aria-label="Close menu"
127+
>
128+
<X className="w-5 h-5" />
129+
</button>
130+
</div>
131+
97132
<nav className="flex-1 p-3 space-y-1">
98133
{mainNavItems.map((item) => (
99134
<NavLink key={item.href} item={item} />
@@ -105,9 +140,10 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
105140
<NavLink key={item.href} item={item} />
106141
))}
107142

143+
{/* Collapse toggle - desktop only */}
108144
<button
109145
onClick={onToggle}
110-
className="flex items-center gap-3 px-3 py-2.5 w-full rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
146+
className="hidden lg:flex items-center gap-3 px-3 py-2.5 w-full rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
111147
>
112148
{collapsed ? (
113149
<ChevronRight className="w-4 h-4" />

frontend/src/index.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ html {
1818
@layer base {
1919
:root {
2020
/* Layout */
21-
--navbar-height: 3.5rem; /* 56px = h-14 */
22-
--sidebar-width: 15rem; /* 240px = w-60 */
23-
--sidebar-width-collapsed: 4rem; /* 64px = w-16 */
21+
--navbar-height: 3.5rem; /* 56px */
22+
--sidebar-width: 15rem; /* 240px */
23+
--sidebar-width-collapsed: 4rem; /* 64px */
24+
25+
/* Mobile breakpoint - sidebar hidden below this */
26+
--mobile-breakpoint: 1024px; /* lg */
2427

2528
/* Background */
2629
--color-bg-primary: #09090b;

0 commit comments

Comments
 (0)