Skip to content

Commit 6b56e0b

Browse files
MalteJclaude
andcommitted
ui: responsive mobile layout
Adds a mobile-friendly navigation pattern and consistent responsive page headers across the console: - Sidebar collapses behind a hamburger toggle on screens below the lg breakpoint, opened as a Sheet overlay. Toggle state lives in a tiny useSidebar zustand-style store so Header (toggle button) and Layout (Sheet renderer) can share it without prop drilling. - Header gets shrink-0 on the right-hand controls and a min-w-0 wrapper around OrgProjectSwitcher so long org/project names truncate instead of pushing icons off-screen on narrow viewports. - DataTable: container gets overflow-x-auto + table min-w-max, so wide log/VM/volume tables scroll horizontally instead of squishing columns; search input grows to full-width on mobile, caps at max-w-sm on sm+. - Every feature page header (Clusters, Orgs, Projects, Storage, Firewall, Network, ProjectMembers, ServiceAccounts, VMs, ClusterDetail) stacks the title block above its action buttons on mobile via `flex flex-col gap-3 sm:flex-row`. - index.html: viewport-fit=cover for notched displays; theme-color matches light/dark scheme so the browser chrome blends in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c031339 commit 6b56e0b

20 files changed

Lines changed: 253 additions & 47 deletions

mvirt-ui/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
66
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
77
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
8-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
8+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
9+
<meta name="theme-color" content="#0a0a14" media="(prefers-color-scheme: dark)" />
10+
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)" />
911
<title>mvirt</title>
1012
<link rel="preconnect" href="https://fonts.googleapis.com" />
1113
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

mvirt-ui/src/components/data-display/DataTable.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ export function DataTable<TData, TValue>({
6363
onChange={(event) =>
6464
table.getColumn(searchColumn)?.setFilterValue(event.target.value)
6565
}
66-
className="max-w-sm"
66+
className="w-full sm:max-w-sm"
6767
/>
6868
</div>
6969
)}
70-
<div className="rounded-md border border-border">
71-
<table className="w-full">
70+
<div className="overflow-x-auto rounded-md border border-border">
71+
<table className="w-full min-w-max">
7272
<thead>
7373
{table.getHeaderGroups().map((headerGroup) => (
7474
<tr key={headerGroup.id} className="border-b border-border bg-muted/50">

mvirt-ui/src/components/layout/Header.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useNavigate } from 'react-router-dom'
2-
import { Bell, Moon, Sun, LogOut, User, Check, AlertTriangle, Info, AlertCircle, CheckCircle } from 'lucide-react'
2+
import { Bell, Menu, Moon, Sun, LogOut, User, Check, AlertTriangle, Info, AlertCircle, CheckCircle } from 'lucide-react'
33
import { Button } from '@/components/ui/button'
44
import {
55
DropdownMenu,
@@ -11,6 +11,7 @@ import {
1111
import { useTheme } from '@/hooks/useTheme'
1212
import { useAuth } from '@/hooks/useAuth'
1313
import { useMe, useNotifications, useMarkAllNotificationsRead } from '@/hooks/queries'
14+
import { useSidebar } from '@/hooks/useSidebar'
1415
import { NotificationType } from '@/types'
1516
import { cn } from '@/lib/utils'
1617
import { OrgProjectSwitcher } from './OrgProjectSwitcher'
@@ -36,6 +37,7 @@ export function Header() {
3637
const { data: me } = useMe()
3738
const { data: notifications } = useNotifications()
3839
const markAllRead = useMarkAllNotificationsRead()
40+
const toggleSidebar = useSidebar((s) => s.toggle)
3941

4042
const unreadCount = notifications?.filter((n) => !n.read).length ?? 0
4143

@@ -58,10 +60,23 @@ export function Header() {
5860
}
5961

6062
return (
61-
<header className="flex h-14 items-center justify-between border-b border-border bg-card/80 backdrop-blur-xl px-6">
62-
<OrgProjectSwitcher />
63+
<header className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border bg-card/80 backdrop-blur-xl px-2 md:px-6">
64+
<div className="flex min-w-0 items-center gap-1">
65+
<Button
66+
variant="ghost"
67+
size="icon"
68+
className="h-10 w-10 shrink-0 hover:bg-purple/20 hover:text-purple-light lg:hidden"
69+
onClick={toggleSidebar}
70+
aria-label="Toggle navigation"
71+
>
72+
<Menu className="h-5 w-5" />
73+
</Button>
74+
<div className="min-w-0 flex-1">
75+
<OrgProjectSwitcher />
76+
</div>
77+
</div>
6378

64-
<div className="flex items-center gap-2">
79+
<div className="flex shrink-0 items-center gap-1 md:gap-2">
6580
<DropdownMenu>
6681
<DropdownMenuTrigger asChild>
6782
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-purple/20 hover:text-purple-light relative">

mvirt-ui/src/components/layout/Layout.tsx

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
import { ReactNode } from 'react'
1+
import { ReactNode, useEffect } from 'react'
22
import { useLocation, useNavigate } from 'react-router-dom'
33
import { Building2, FolderKanban, Sparkles } from 'lucide-react'
44
import { Sidebar } from './Sidebar'
55
import { Header } from './Header'
66
import { Button } from '@/components/ui/button'
7+
import {
8+
Sheet,
9+
SheetContent,
10+
SheetTitle,
11+
SheetDescription,
12+
} from '@/components/ui/sheet'
713
import { useOrgs, useProjects } from '@/hooks/queries'
14+
import { useSidebar } from '@/hooks/useSidebar'
815

916
interface LayoutProps {
1017
children: ReactNode
@@ -55,6 +62,15 @@ export function Layout({ children }: LayoutProps) {
5562
const location = useLocation()
5663
const { data: projects, isLoading: projectsLoading } = useProjects()
5764
const { data: orgs, isLoading: orgsLoading } = useOrgs()
65+
const drawerOpen = useSidebar((s) => s.open)
66+
const setDrawerOpen = useSidebar((s) => s.setOpen)
67+
68+
// Belt-and-suspenders close: NavLink onClicks already close, but a back-
69+
// button or programmatic navigation can leave the drawer open. Sync on
70+
// every pathname change.
71+
useEffect(() => {
72+
setDrawerOpen(false)
73+
}, [location.pathname, setDrawerOpen])
5874

5975
const hasProjects = projects && projects.length > 0
6076
const hasOrgs = !!orgs && orgs.length > 0
@@ -77,28 +93,50 @@ export function Layout({ children }: LayoutProps) {
7793
!projectsLoading && !orgsLoading && !hasProjects && !isAdminPage
7894

7995
return (
80-
<div className="flex h-screen flex-col bg-background">
96+
<div className="flex h-dvh flex-col bg-background">
8197
{/* Animated gradient background */}
8298
<div className="bg-gradient-animated" />
8399

84100
<div className="flex flex-1 overflow-hidden">
85-
<Sidebar />
101+
{/* Desktop sidebar — inline, ≥ lg */}
102+
<aside className="hidden lg:flex">
103+
<Sidebar />
104+
</aside>
105+
106+
{/* Mobile sidebar — slide-in drawer, < lg */}
107+
<Sheet open={drawerOpen} onOpenChange={setDrawerOpen}>
108+
<SheetContent side="left" className="w-72 p-0" hideCloseButton>
109+
<SheetTitle className="sr-only">Navigation</SheetTitle>
110+
<SheetDescription className="sr-only">
111+
Workload and admin navigation
112+
</SheetDescription>
113+
<Sidebar variant="sheet" />
114+
</SheetContent>
115+
</Sheet>
116+
86117
<div className="relative z-10 flex flex-1 flex-col overflow-hidden">
87118
<Header />
88-
<main className="main-content flex-1 overflow-auto p-6">
119+
<main className="main-content flex-1 overflow-auto p-4 md:p-6">
89120
{showEmptyState ? <EmptyProjectsState hasOrgs={hasOrgs} /> : children}
90121
</main>
91122
</div>
92123
</div>
93124

94-
{/* Pride stripe */}
95-
<div className="h-1 w-full flex shrink-0">
96-
<div className="flex-1 bg-[#e40303]" />
97-
<div className="flex-1 bg-[#ff8c00]" />
98-
<div className="flex-1 bg-[#ffed00]" />
99-
<div className="flex-1 bg-[#008026]" />
100-
<div className="flex-1 bg-[#24408e]" />
101-
<div className="flex-1 bg-[#732982]" />
125+
{/* Pride stripe — sits above the iOS home-indicator safe area so
126+
neither the stripe nor a home gesture obscures the other. */}
127+
<div className="shrink-0">
128+
<div className="flex h-1 w-full">
129+
<div className="flex-1 bg-[#e40303]" />
130+
<div className="flex-1 bg-[#ff8c00]" />
131+
<div className="flex-1 bg-[#ffed00]" />
132+
<div className="flex-1 bg-[#008026]" />
133+
<div className="flex-1 bg-[#24408e]" />
134+
<div className="flex-1 bg-[#732982]" />
135+
</div>
136+
<div
137+
className="bg-card"
138+
style={{ height: 'env(safe-area-inset-bottom, 0px)' }}
139+
/>
102140
</div>
103141
</div>
104142
)

mvirt-ui/src/components/layout/OrgProjectSwitcher.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,23 @@ export function OrgProjectSwitcher() {
9797
<DropdownMenuTrigger asChild>
9898
<Button
9999
variant="ghost"
100-
className="gap-2 hover:bg-purple/20 hover:text-purple-light"
100+
className="min-w-0 max-w-full gap-2 px-2 hover:bg-purple/20 hover:text-purple-light md:px-3"
101101
>
102-
<Building2 className="h-4 w-4 text-purple-light" />
103-
{triggerLabel}
104-
<ChevronDown className="h-4 w-4 opacity-50" />
102+
<Building2 className="h-4 w-4 shrink-0 text-purple-light" />
103+
<span className="flex min-w-0 items-center truncate text-sm">
104+
{triggerLabel}
105+
</span>
106+
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
105107
</Button>
106108
</DropdownMenuTrigger>
107-
<DropdownMenuContent align="start" className="w-[640px] p-0">
109+
<DropdownMenuContent
110+
align="start"
111+
className="w-[min(640px,calc(100vw-1rem))] p-0"
112+
>
108113
{/* Two-column body — fixed max height, each column scrolls independently. */}
109114
<div className="flex">
110115
{/* Left 40% — Orgs */}
111-
<div className="w-2/5 border-r border-border h-96 overflow-y-auto">
116+
<div className="w-2/5 border-r border-border h-[min(24rem,60vh)] overflow-y-auto">
112117
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border bg-card/40 sticky top-0">
113118
Organizations
114119
</div>
@@ -153,7 +158,7 @@ export function OrgProjectSwitcher() {
153158
</div>
154159

155160
{/* Right 60% — Projects in focused Org */}
156-
<div className="w-3/5 h-96 overflow-y-auto">
161+
<div className="w-3/5 h-[min(24rem,60vh)] overflow-y-auto">
157162
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border bg-card/40 sticky top-0">
158163
{focusedOrg ? `Projects in ${focusedOrg.slug}` : 'Projects'}
159164
</div>

mvirt-ui/src/components/layout/Sidebar.tsx

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { cn } from '@/lib/utils'
1717
import { useApiHealth } from '@/hooks/queries'
1818
import { useIsPlatformAdmin } from '@/hooks/useAuth'
19+
import { useSidebar } from '@/hooks/useSidebar'
1920

2021
const projectNav = [
2122
{ name: 'Virtual Machines', path: '/vms', icon: Server },
@@ -44,15 +45,26 @@ const linkClass = (isActive: boolean) =>
4445
: 'text-foreground/80 border-transparent hover:bg-secondary hover:text-foreground',
4546
)
4647

47-
export function Sidebar() {
48-
// Sidebar lives outside the inner <Routes> tree that defines :projectSlug
49-
// / :orgSlug, so useParams() returns nothing here — pull the active scope
50-
// out of `pathname` directly.
48+
interface SidebarProps {
49+
/**
50+
* When true, the sidebar is rendered inside a Sheet (mobile drawer). The
51+
* outer wrapper drops its fixed width and right border because the Sheet
52+
* owns those; click handlers also close the drawer on navigation.
53+
*/
54+
variant?: 'inline' | 'sheet'
55+
}
56+
57+
/**
58+
* The persistent nav rail. Defaults to the inline (desktop) variant; the
59+
* mobile drawer in Layout renders it with `variant="sheet"`.
60+
*/
61+
export function Sidebar({ variant = 'inline' }: SidebarProps) {
5162
const { pathname } = useLocation()
5263
const projectSlug = pathname.match(/^\/projects\/([^/]+)/)?.[1]
5364
const orgSlug = pathname.match(/^\/orgs\/([^/]+)/)?.[1]
5465
const apiHealth = useApiHealth()
5566
const isAdmin = useIsPlatformAdmin()
67+
const closeDrawer = useSidebar((s) => s.setOpen)
5668

5769
const apiStatus: 'connected' | 'connecting' | 'disconnected' =
5870
apiHealth.isSuccess ? 'connected'
@@ -69,23 +81,36 @@ export function Sidebar() {
6981
: apiStatus === 'disconnected' ? 'Disconnected'
7082
: 'Connecting…'
7183

84+
const handleNavigate = () => {
85+
if (variant === 'sheet') closeDrawer(false)
86+
}
87+
88+
const isSheet = variant === 'sheet'
89+
7290
return (
73-
<div className="relative z-10 flex w-64 flex-col border-r border-border bg-card/80 backdrop-blur-xl">
91+
<div
92+
className={cn(
93+
'relative z-10 flex h-full flex-col bg-card/80 backdrop-blur-xl',
94+
!isSheet && 'w-64 border-r border-border',
95+
)}
96+
>
7497
<Link
7598
to="/"
99+
onClick={handleNavigate}
76100
className="group flex h-14 items-center border-b border-border px-4 hover:bg-secondary/50 transition-colors"
77101
>
78102
<div className="logo-box-shimmer mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-purple to-blue text-white text-lg font-bold shadow-glow-purple">
79103
m
80104
</div>
81105
<span className="logo-shimmer text-lg font-semibold">mvirt</span>
82106
</Link>
83-
<nav className="flex-1 space-y-1 p-2">
107+
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
84108
{projectSlug &&
85109
projectNav.map((item) => (
86110
<NavLink
87111
key={item.name}
88112
to={`/projects/${projectSlug}${item.path}`}
113+
onClick={handleNavigate}
89114
className={({ isActive }) => linkClass(isActive)}
90115
>
91116
<item.icon className="mr-3 h-4 w-4" />
@@ -99,6 +124,7 @@ export function Sidebar() {
99124
key={item.name}
100125
to={`/orgs/${orgSlug}/${item.path}`}
101126
end={item.end}
127+
onClick={handleNavigate}
102128
className={({ isActive }) => linkClass(isActive)}
103129
>
104130
<item.icon className="mr-3 h-4 w-4" />
@@ -111,14 +137,15 @@ export function Sidebar() {
111137
<NavLink
112138
to="/cluster"
113139
end
140+
onClick={handleNavigate}
114141
className={({ isActive }) => linkClass(isActive)}
115142
>
116143
<Cog className="mr-3 h-4 w-4" />
117144
mvirt Admin
118145
</NavLink>
119146
</div>
120147
)}
121-
<div className="border-t border-border p-4">
148+
<div className="border-t border-border p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
122149
<div className="flex items-center gap-2 text-xs text-foreground/60">
123150
<div className={cn('h-2 w-2 rounded-full', dotClass)} />
124151
<span>{statusLabel}</span>

0 commit comments

Comments
 (0)