'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; type TopbarProps = ComponentProps; interface AppShellProps { portRoles: SidebarProps['portRoles']; isSuperAdmin: boolean; user: NonNullable; 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 {children}; } return (
{children}
); }