Skip to content

Commit 2cc378b

Browse files
CoderCococlaude
andauthored
feat(web): responsive + mobile pass over the dashboard (#129)
Closes #70 ## Summary - **Hamburger drawer** replaces the fixed 240 px sidebar below the `md` breakpoint; always rendered in DOM so `aria-controls` is valid, toggled with `hidden` + `aria-hidden` - **KPI strip** reflows to 2-col at `sm` and 1-col at `xs`; transitions to 4-col at `md` instead of `lg` - **Game cards** use the full width on mobile with ≥44 px touch targets on every button (copy, start/stop, files, logs) - **Costs estimates table** becomes a label/value card stack below `md`, full sortable table above - **Discord tables** (guilds + per-game permissions) wrapped in `overflow-x-auto` so they scroll horizontally rather than overflow the viewport - **Logs filters** collapse into a togglable drawer on mobile; filter toggle button meets 44 px tap target - **Dialog width** fixed with `w-[calc(100%-2rem)] sm:w-full` so modals don't overflow a 375 px viewport - **Top bar** hides brand text and search input below `sm`; Refresh button collapses to icon-only ## Screenshots ### Dashboard | 375 px (iPhone SE) | 1280 px | |---|---| | ![Dashboard 375px](https://raw.githubusercontent.com/CoderCoco/game-server-deploy/3f815ab/docs/screenshots/issue-70/screenshot-dashboard-375.png) | ![Dashboard 1280px](https://raw.githubusercontent.com/CoderCoco/game-server-deploy/3f815ab/docs/screenshots/issue-70/screenshot-dashboard-1280.png) | ### Costs | 375 px (iPhone SE) | 1280 px | |---|---| | ![Costs 375px](https://raw.githubusercontent.com/CoderCoco/game-server-deploy/3f815ab/docs/screenshots/issue-70/screenshot-costs-375.png) | ![Costs 1280px](https://raw.githubusercontent.com/CoderCoco/game-server-deploy/3f815ab/docs/screenshots/issue-70/screenshot-costs-1280.png) | ## Test plan - [x] `npm run app:test` — 480/480 passing - [x] `npm run app:build` — clean Vite build - [x] Manual review at 375 px, 768 px, and 1280 px via Chrome DevTools emulation - [ ] Spot-check hamburger drawer open/close on a real phone or emulator --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7b84f6a commit 2cc378b

12 files changed

Lines changed: 350 additions & 119 deletions

app/packages/web/src/components/app-layout.component.test.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react';
22
import { describe, it, expect, vi } from 'vitest';
3-
import { act, render, screen } from '@testing-library/react';
3+
import { act, render, screen, within } from '@testing-library/react';
44
import userEvent from '@testing-library/user-event';
55
import { MemoryRouter } from 'react-router-dom';
66
import { AppLayout, LiveIndicator, RefreshAllButton } from './app-layout.component.js';
@@ -97,7 +97,7 @@ describe('AppLayout — skip link and nav landmarks', () => {
9797
});
9898

9999
describe('AppLayout — LiveIndicator', () => {
100-
it('should always render the LIVE label so the chrome is visible from first paint', () => {
100+
it('should render the LIVE label element in the DOM regardless of screen size', () => {
101101
render(
102102
<PollingProvider>
103103
<LiveIndicator />
@@ -126,3 +126,48 @@ describe('AppLayout — LiveIndicator', () => {
126126
expect(dot?.className).toMatch(/var\(--color-cyan\)/);
127127
});
128128
});
129+
130+
describe('AppLayout — mobile navigation', () => {
131+
it('should render a hamburger button that opens the mobile nav', async () => {
132+
const user = userEvent.setup();
133+
render(
134+
<PollingProvider>
135+
<MemoryRouter>
136+
<AppLayout>content</AppLayout>
137+
</MemoryRouter>
138+
</PollingProvider>,
139+
);
140+
const hamburger = screen.getByRole('button', { name: 'Open navigation' });
141+
expect(hamburger).toBeInTheDocument();
142+
await user.click(hamburger);
143+
expect(screen.getByRole('button', { name: 'Close navigation' })).toBeInTheDocument();
144+
});
145+
146+
it('should close the mobile nav when the close button is clicked', async () => {
147+
const user = userEvent.setup();
148+
render(
149+
<PollingProvider>
150+
<MemoryRouter>
151+
<AppLayout>content</AppLayout>
152+
</MemoryRouter>
153+
</PollingProvider>,
154+
);
155+
await user.click(screen.getByRole('button', { name: 'Open navigation' }));
156+
await user.click(screen.getByRole('button', { name: 'Close navigation' }));
157+
expect(screen.queryByRole('button', { name: 'Close navigation' })).not.toBeInTheDocument();
158+
});
159+
160+
it('should close the mobile nav when a nav link is clicked', async () => {
161+
const user = userEvent.setup();
162+
render(
163+
<PollingProvider>
164+
<MemoryRouter initialEntries={['/']}>
165+
<AppLayout>content</AppLayout>
166+
</MemoryRouter>
167+
</PollingProvider>,
168+
);
169+
await user.click(screen.getByRole('button', { name: 'Open navigation' }));
170+
await user.click(within(document.getElementById('mobile-nav')!).getByRole('link', { name: 'Logs' }));
171+
expect(screen.queryByRole('button', { name: 'Close navigation' })).not.toBeInTheDocument();
172+
});
173+
});

app/packages/web/src/components/app-layout.component.tsx

Lines changed: 142 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
MessageSquare,
1414
Settings,
1515
RefreshCw,
16+
Menu,
17+
X,
1618
} from 'lucide-react';
1719

1820
interface 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
*/
44100
export 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>

app/packages/web/src/components/game-card.component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ export function GameCard({ status, estimate, onRefresh, onOpenFiles }: Props) {
234234
<Button
235235
variant="ghost"
236236
size="sm"
237-
className="h-5 w-5 p-0 text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]"
237+
className="min-h-11 min-w-11 p-0 text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]"
238238
onClick={() => void navigator.clipboard.writeText(connectStr)}
239239
aria-label="Copy connect string"
240240
>

app/packages/web/src/components/kpi-strip.component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export function KpiStrip({ statuses, estimates, actualCosts }: Props) {
153153
}, [statuses, estimates, actualCosts]);
154154

155155
return (
156-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
156+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-6">
157157
{tiles.map((t) => (
158158
<KpiTile key={t.label} {...t} />
159159
))}

app/packages/web/src/components/ui/dialog.component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
3232
<DialogPrimitive.Content
3333
ref={ref}
3434
className={cn(
35-
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-[var(--radius-lg)]',
35+
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-[var(--radius-lg)] w-[calc(100%-2rem)] sm:w-full max-h-[90vh] overflow-y-auto',
3636
className,
3737
)}
3838
{...props}

0 commit comments

Comments
 (0)