refactor(layout): single-tree responsive shell (#26)
Pre-fix the dashboard layout mounted BOTH the desktop and mobile shells
to the DOM on every page, hidden via CSS data-shell rules. Two Tabs
providers had data-state="active" concurrently, every fetch fired twice,
every component piece of state lived in two trees, a11y landmarks
duplicated, and half the click attempts hit the wrong layer.
New <AppShell> client wrapper mounts exactly ONE tree based on the
server-classified User-Agent (no hydration mismatch, no first-paint
flash on real mobile devices) plus a runtime matchMedia subscription
that swaps shells when the viewport crosses 1024px (e.g. desktop
browser resized).
Knock-on changes:
- Dashboard layout fetches once and hands the data to AppShell;
AppShell picks Desktop (Sidebar + Topbar + main) or MobileLayout
- Stripped the now-orphan data-shell CSS rules from globals.css —
nothing emits the attribute any more
- MobileLayout drops its data-shell="mobile" attribute (was the lever
the dead CSS rules pulled)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,14 +10,14 @@ import { QueryProvider } from '@/providers/query-provider';
|
|||||||
import { SocketProvider } from '@/providers/socket-provider';
|
import { SocketProvider } from '@/providers/socket-provider';
|
||||||
import { PortProvider } from '@/providers/port-provider';
|
import { PortProvider } from '@/providers/port-provider';
|
||||||
import { PermissionsProvider } from '@/providers/permissions-provider';
|
import { PermissionsProvider } from '@/providers/permissions-provider';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { AppShell } from '@/components/layout/app-shell';
|
||||||
import { Topbar } from '@/components/layout/topbar';
|
|
||||||
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
|
||||||
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||||
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
||||||
|
import { classifyFormFactor } from '@/lib/form-factor';
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const headerList = await headers();
|
||||||
|
const session = await auth.api.getSession({ headers: headerList });
|
||||||
if (!session?.user) redirect('/login');
|
if (!session?.user) redirect('/login');
|
||||||
|
|
||||||
// Super admins have implicit access to every port; everyone else only sees
|
// Super admins have implicit access to every port; everyone else only sees
|
||||||
@@ -35,6 +35,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
? await db.query.ports.findMany({ orderBy: portsTable.name })
|
? await db.query.ports.findMany({ orderBy: portsTable.name })
|
||||||
: portRoles.map((pr) => pr.port);
|
: portRoles.map((pr) => pr.port);
|
||||||
|
|
||||||
|
const initialFormFactor = classifyFormFactor(headerList.get('user-agent'));
|
||||||
|
const user = {
|
||||||
|
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||||||
|
email: session.user.email,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||||
@@ -42,33 +48,18 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<RealtimeToasts />
|
<RealtimeToasts />
|
||||||
<WebVitalsReporter />
|
<WebVitalsReporter />
|
||||||
{/* Desktop shell - hidden by CSS on mobile */}
|
{/* #26: AppShell mounts ONE responsive tree (desktop OR
|
||||||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
* mobile) per render — never both — so pages don't pay the
|
||||||
<Sidebar
|
* double-state, double-fetch, double-Tabs-provider tax. */}
|
||||||
portRoles={portRoles}
|
<AppShell
|
||||||
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
portRoles={portRoles}
|
||||||
user={{
|
isSuperAdmin={profile?.isSuperAdmin ?? false}
|
||||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
user={user}
|
||||||
email: session.user.email,
|
ports={ports}
|
||||||
}}
|
initialFormFactor={initialFormFactor}
|
||||||
ports={ports}
|
>
|
||||||
/>
|
{children}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
</AppShell>
|
||||||
<Topbar
|
|
||||||
ports={ports}
|
|
||||||
user={{
|
|
||||||
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
|
||||||
email: session.user.email,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile shell - hidden by CSS on desktop */}
|
|
||||||
<MobileLayout>{children}</MobileLayout>
|
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</PermissionsProvider>
|
</PermissionsProvider>
|
||||||
</PortProvider>
|
</PortProvider>
|
||||||
|
|||||||
@@ -319,35 +319,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Form-factor shell visibility ──────────────────────────────────────────
|
/* #26: dual-shell CSS visibility rules removed. AppShell now mounts a
|
||||||
* Two shells (desktop + mobile) render to the DOM on every page; CSS hides
|
* single responsive tree based on the server-classified form-factor +
|
||||||
* the inactive one. The data-form-factor body attribute is set server-side
|
* runtime matchMedia, so there is no inactive shell in the DOM to hide.
|
||||||
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
|
* The data-form-factor body attribute is still set on the root layout
|
||||||
* handles desktop browsers resized below lg (1024px), or stripped UAs.
|
* (downstream styles + analytics use it).
|
||||||
*
|
|
||||||
* IMPORTANT: only `display: none` rules are emitted - we never set a positive
|
|
||||||
* display, because the desktop shell uses Tailwind's `flex` class which would
|
|
||||||
* be overridden by `display: block` (same specificity, later cascade).
|
|
||||||
*/
|
*/
|
||||||
[data-shell='mobile'] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023.98px) {
|
|
||||||
[data-shell='desktop'] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
[data-shell='mobile'] {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-form-factor='mobile'] [data-shell='desktop'] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
body[data-form-factor='mobile'] [data-shell='mobile'] {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* React Query Devtools floating button collides with the bottom tab bar's
|
* React Query Devtools floating button collides with the bottom tab bar's
|
||||||
|
|||||||
71
src/components/layout/app-shell.tsx
Normal file
71
src/components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
|
import { Topbar } from '@/components/layout/topbar';
|
||||||
|
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
||||||
|
|
||||||
|
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||||
|
type TopbarProps = ComponentProps<typeof Topbar>;
|
||||||
|
|
||||||
|
interface AppShellProps {
|
||||||
|
portRoles: SidebarProps['portRoles'];
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
user: NonNullable<SidebarProps['user']>;
|
||||||
|
ports: TopbarProps['ports'];
|
||||||
|
/**
|
||||||
|
* Server-rendered form-factor hint (from the request User-Agent). The
|
||||||
|
* shell mounts the matching tree on first render so we never paint the
|
||||||
|
* wrong shell, and only switches if the runtime viewport matchMedia
|
||||||
|
* disagrees (e.g. desktop browser resized below lg).
|
||||||
|
*/
|
||||||
|
initialFormFactor: 'mobile' | 'desktop';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #26: single-tree responsive shell. Pre-fix the layout mounted BOTH
|
||||||
|
* desktop and mobile shells in the DOM and CSS-hid one — doubling React
|
||||||
|
* state, fetches, Tabs providers, and a11y landmarks. AppShell decides
|
||||||
|
* once per render which tree to mount, so a page only ever runs the
|
||||||
|
* effects + queries it actually displays.
|
||||||
|
*
|
||||||
|
* SSR safety: the server passes its UA-classified hint via `initialFormFactor`;
|
||||||
|
* the first client render uses the same value so hydration matches. After
|
||||||
|
* mount, a matchMedia subscription overrides if the viewport disagrees.
|
||||||
|
*/
|
||||||
|
export function AppShell({
|
||||||
|
portRoles,
|
||||||
|
isSuperAdmin,
|
||||||
|
user,
|
||||||
|
ports,
|
||||||
|
initialFormFactor,
|
||||||
|
children,
|
||||||
|
}: AppShellProps) {
|
||||||
|
const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(MOBILE_QUERY);
|
||||||
|
const update = () => setIsMobile(mq.matches);
|
||||||
|
update();
|
||||||
|
mq.addEventListener('change', update);
|
||||||
|
return () => mq.removeEventListener('change', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return <MobileLayout>{children}</MobileLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
|
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
|
<Topbar ports={ports} user={user} />
|
||||||
|
<main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,17 +11,17 @@ import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
||||||
* bar. Renders only when CSS reveals it (data-shell="mobile") - both shells
|
* bar. Mounted by AppShell when the viewport classifies as mobile — never
|
||||||
* are in the DOM, see src/app/globals.css. The bottom tabs and More sheet
|
* concurrent with the desktop tree. The bottom tabs and More sheet derive
|
||||||
* derive the active port slug from the URL themselves, so this layout takes
|
* the active port slug from the URL themselves, so this layout takes no
|
||||||
* no portSlug prop.
|
* portSlug prop.
|
||||||
*/
|
*/
|
||||||
export function MobileLayout({ children }: { children: ReactNode }) {
|
export function MobileLayout({ children }: { children: ReactNode }) {
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-shell="mobile" className="min-h-[100dvh] bg-background">
|
<div className="min-h-[100dvh] bg-background">
|
||||||
<MobileLayoutProvider>
|
<MobileLayoutProvider>
|
||||||
<MobileTopbar />
|
<MobileTopbar />
|
||||||
<main
|
<main
|
||||||
|
|||||||
Reference in New Issue
Block a user