@@ -13,6 +13,8 @@ import {
1313 MessageSquare ,
1414 Settings ,
1515 RefreshCw ,
16+ Menu ,
17+ X ,
1618} from 'lucide-react' ;
1719
1820interface NavItem {
@@ -35,24 +37,86 @@ const configItems: NavItem[] = [
3537 { to : '/settings' , icon : Settings , label : 'Settings' } ,
3638] ;
3739
40+ /**
41+ * Shared nav sections used by both the desktop sidebar and the mobile drawer.
42+ * Accepts an optional `onNavigate` callback that fires when a nav link is clicked,
43+ * allowing the mobile drawer to close itself on navigation.
44+ *
45+ * `prefix` makes the section heading ids unique so that both the desktop sidebar
46+ * and the mobile drawer can coexist in the DOM without duplicate ids (an HTML
47+ * validity violation that also breaks `aria-labelledby`).
48+ */
49+ function NavSections ( {
50+ currentPath,
51+ onNavigate,
52+ prefix,
53+ } : {
54+ currentPath : string ;
55+ onNavigate ?: ( ) => void ;
56+ prefix : string ;
57+ } ) {
58+ return (
59+ < nav aria-label = "Main navigation" className = "flex-1 overflow-y-auto px-3 py-4 space-y-6" >
60+ { /* Monitoring */ }
61+ < div >
62+ < p id = { `${ prefix } -nav-monitoring` } className = "px-3 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider" >
63+ Monitoring
64+ </ p >
65+ < ul aria-labelledby = { `${ prefix } -nav-monitoring` } className = "space-y-1 list-none" >
66+ { monitoringItems . map ( ( item ) => (
67+ < li key = { item . to + item . label } >
68+ < NavLink item = { item } active = { currentPath === item . to } onNavigate = { onNavigate } />
69+ </ li >
70+ ) ) }
71+ </ ul >
72+ </ div >
73+
74+ { /* Configuration */ }
75+ < div >
76+ < p id = { `${ prefix } -nav-configuration` } className = "px-3 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider" >
77+ Configuration
78+ </ p >
79+ < ul aria-labelledby = { `${ prefix } -nav-configuration` } className = "space-y-1 list-none" >
80+ { configItems . map ( ( item ) => (
81+ < li key = { item . to + item . label } >
82+ < NavLink item = { item } active = { currentPath === item . to } onNavigate = { onNavigate } />
83+ </ li >
84+ ) ) }
85+ </ ul >
86+ </ div >
87+ </ nav >
88+ ) ;
89+ }
90+
3891/**
3992 * Navigation shell — persistent sidebar + top bar that wraps all routed pages.
4093 * Sidebar shows "Monitoring" and "Configuration" sections with active-route
4194 * highlighting (purple gradient + 2px left accent). Top bar displays env pill
4295 * (e.g. "PROD · us-east-1"), search placeholder, and LIVE indicator.
96+ *
97+ * On mobile (below the `md` breakpoint), the sidebar is replaced by an off-canvas drawer that slides
98+ * in from the left when the hamburger button in the top bar is clicked.
4399 */
44100export function AppLayout ( { children } : { children : ReactNode } ) {
45101 const location = useLocation ( ) ;
46102 const [ env , setEnv ] = useState < EnvInfo | null > ( null ) ;
103+ const [ mobileMenuOpen , setMobileMenuOpen ] = useState ( false ) ;
47104
48105 useEffect ( ( ) => {
49106 api . env ( ) . then ( setEnv ) . catch ( console . error ) ;
50107 } , [ ] ) ;
51108
109+ // Close mobile menu whenever the route changes (e.g. browser back/forward).
110+ useEffect ( ( ) => {
111+ setMobileMenuOpen ( false ) ;
112+ } , [ location . pathname ] ) ;
113+
52114 const envLabel = env
53115 ? `${ env . environment } · ${ env . region } `
54116 : 'local' ;
55117
118+ const closeMobileMenu = ( ) => setMobileMenuOpen ( false ) ;
119+
56120 return (
57121 < div className = "flex h-screen bg-background" >
58122 { /* Skip-to-content link — first focusable element, revealed on focus */ }
@@ -63,8 +127,8 @@ export function AppLayout({ children }: { children: ReactNode }) {
63127 Skip to main content
64128 </ a >
65129
66- { /* Sidebar */ }
67- < aside className = "w-60 border-r border-border bg-card flex flex-col" >
130+ { /* Desktop sidebar — hidden on mobile */ }
131+ < aside className = "hidden md:flex w-60 border-r border-border bg-card flex-col" >
68132 { /* Brand */ }
69133 < div className = "px-4 py-5 border-b border-border" >
70134 < div className = "flex items-center gap-2" >
@@ -75,56 +139,78 @@ export function AppLayout({ children }: { children: ReactNode }) {
75139 </ div >
76140 </ div >
77141
78- { /* Nav sections */ }
79- < nav aria-label = "Main navigation" className = "flex-1 overflow-y-auto px-3 py-4 space-y-6" >
80- { /* Monitoring */ }
81- < div >
82- < p id = "nav-monitoring" className = "px-3 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider" >
83- Monitoring
84- </ p >
85- < ul aria-labelledby = "nav-monitoring" className = "space-y-1 list-none" >
86- { monitoringItems . map ( ( item ) => (
87- < li key = { item . to + item . label } >
88- < NavLink item = { item } active = { location . pathname === item . to } />
89- </ li >
90- ) ) }
91- </ ul >
92- </ div >
142+ < NavSections currentPath = { location . pathname } prefix = "desktop" />
143+ </ aside >
144+
145+ { /* Mobile drawer backdrop */ }
146+ { mobileMenuOpen && (
147+ < div
148+ className = "fixed inset-0 z-30 bg-black/60 md:hidden"
149+ onClick = { closeMobileMenu }
150+ aria-hidden = "true"
151+ />
152+ ) }
93153
94- { /* Configuration */ }
95- < div >
96- < p id = "nav-configuration" className = "px-3 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider" >
97- Configuration
98- </ p >
99- < ul aria-labelledby = "nav-configuration" className = "space-y-1 list-none" >
100- { configItems . map ( ( item ) => (
101- < li key = { item . to + item . label } >
102- < NavLink item = { item } active = { location . pathname === item . to } />
103- </ li >
104- ) ) }
105- </ ul >
154+ { /* Mobile off-canvas drawer — always in DOM so aria-controls="mobile-nav" has a valid target */ }
155+ < aside
156+ id = "mobile-nav"
157+ aria-hidden = { ! mobileMenuOpen }
158+ className = { cn (
159+ 'fixed inset-y-0 left-0 z-40 w-60 bg-card border-r border-border flex flex-col md:hidden' ,
160+ ! mobileMenuOpen && 'hidden' ,
161+ ) }
162+ >
163+ { /* Drawer header with close button */ }
164+ < div className = "px-4 py-5 border-b border-border flex items-center justify-between" >
165+ < div className = "flex items-center gap-2" >
166+ < div className = "w-8 h-8 rounded bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center" aria-hidden = "true" >
167+ < Server className = "w-5 h-5 text-white" />
168+ </ div >
169+ < span className = "font-semibold text-foreground" > Game Servers</ span >
170+ </ div >
171+ < button
172+ type = "button"
173+ onClick = { closeMobileMenu }
174+ aria-label = "Close navigation"
175+ className = "min-h-11 min-w-11 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
176+ >
177+ < X className = "w-5 h-5" aria-hidden = "true" />
178+ </ button >
106179 </ div >
107- </ nav >
108- </ aside >
180+
181+ < NavSections currentPath = { location . pathname } onNavigate = { closeMobileMenu } prefix = "mobile" />
182+ </ aside >
109183
110184 { /* Main content */ }
111- < div className = "flex-1 flex flex-col overflow-hidden" >
185+ < div className = "flex-1 min-w-0 flex flex-col overflow-hidden" >
112186 { /* Top bar */ }
113- < header className = "h-14 border-b border-border bg-card flex items-center justify-between px-6" >
187+ < header className = "h-14 border-b border-border bg-card flex items-center justify-between px-4 md:px- 6" >
114188 < div className = "flex items-center gap-4" >
115- < h1 className = "text-lg font-semibold text-foreground" > Game Server Manager</ h1 >
116- < span className = "inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20" >
189+ { /* Hamburger button — only visible on mobile */ }
190+ < button
191+ type = "button"
192+ onClick = { ( ) => setMobileMenuOpen ( true ) }
193+ aria-label = "Open navigation"
194+ aria-expanded = { mobileMenuOpen }
195+ aria-controls = "mobile-nav"
196+ className = "shrink-0 md:hidden min-h-11 min-w-11 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
197+ >
198+ < Menu className = "w-5 h-5" aria-hidden = "true" />
199+ </ button >
200+
201+ < h1 className = "hidden sm:block text-lg font-semibold text-foreground shrink-0" > Game Server Manager</ h1 >
202+ < span className = "inline-flex shrink-0 items-center px-2.5 py-0.5 rounded text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20" >
117203 { envLabel }
118204 </ span >
119205 </ div >
120206
121207 < div className = "flex items-center gap-4" >
122208 { /* Search placeholder — not yet functional; hidden from keyboard/screen readers */ }
123- < div className = "relative" aria-hidden = "true" >
209+ < div className = "relative hidden sm:block " aria-hidden = "true" >
124210 < input
125211 type = "text"
126212 placeholder = "Search... ⌘K"
127- className = "w-64 px-3 py-1.5 text-sm bg-muted border border-border rounded focus:outline-none focus:ring-2 focus:ring-purple-500/50"
213+ className = "w-48 lg:w- 64 px-3 py-1.5 text-sm bg-muted border border-border rounded focus:outline-none focus:ring-2 focus:ring-purple-500/50"
128214 readOnly
129215 tabIndex = { - 1 }
130216 />
@@ -135,7 +221,7 @@ export function AppLayout({ children }: { children: ReactNode }) {
135221
136222 { /* Avatar placeholder — decorative */ }
137223 < div
138- className = "w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center"
224+ className = "shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-purple-700 flex items-center justify-center"
139225 aria-hidden = "true"
140226 >
141227 < span className = "text-xs font-medium text-white" > OP</ span >
@@ -144,7 +230,7 @@ export function AppLayout({ children }: { children: ReactNode }) {
144230 </ header >
145231
146232 { /* Page content */ }
147- < main id = "main" tabIndex = { - 1 } className = "flex-1 overflow-auto p-8" >
233+ < main id = "main" tabIndex = { - 1 } className = "flex-1 overflow-auto p-4 md:p- 8" >
148234 { children }
149235 </ main >
150236 </ div >
@@ -171,7 +257,7 @@ export function RefreshAllButton() {
171257 disabled = { Object . keys ( pollers ) . length === 0 }
172258 >
173259 < RefreshCw className = { cn ( 'size-3.5' , anyLoading && 'motion-safe:animate-spin' ) } aria-hidden = "true" />
174- Refresh
260+ < span className = "hidden sm:inline" > Refresh</ span >
175261 </ Button >
176262 ) ;
177263}
@@ -204,12 +290,20 @@ export function LiveIndicator() {
204290 aria-label = { statusLabel }
205291 >
206292 < div className = { cn ( 'w-2 h-2 rounded-full' , dotClass ) } aria-hidden = "true" />
207- < span className = { cn ( 'text-xs font-medium' , labelClass ) } aria-hidden = "true" > LIVE</ span >
293+ < span className = { cn ( 'hidden sm:inline text-xs font-medium' , labelClass ) } aria-hidden = "true" > LIVE</ span >
208294 </ div >
209295 ) ;
210296}
211297
212- function NavLink ( { item, active } : { item : NavItem ; active : boolean } ) {
298+ function NavLink ( {
299+ item,
300+ active,
301+ onNavigate,
302+ } : {
303+ item : NavItem ;
304+ active : boolean ;
305+ onNavigate ?: ( ) => void ;
306+ } ) {
213307 const Icon = item . icon ;
214308 const className = cn (
215309 'relative flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors' ,
@@ -226,7 +320,12 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
226320 ) ;
227321 }
228322 return (
229- < Link to = { item . to } className = { className } aria-current = { active ? 'page' : undefined } >
323+ < Link
324+ to = { item . to }
325+ className = { className }
326+ aria-current = { active ? 'page' : undefined }
327+ onClick = { onNavigate }
328+ >
230329 < Icon className = "w-4 h-4" aria-hidden = "true" />
231330 { item . label }
232331 </ Link >
0 commit comments