'use client'; import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; import { Sidebar } from '@/components/layout/sidebar'; import { Topbar } from '@/components/layout/topbar'; import { MobileLayoutProvider } from '@/components/layout/mobile/mobile-layout-provider'; import { MobileTopbar } from '@/components/layout/mobile/mobile-topbar'; import { MobileBottomTabs } from '@/components/layout/mobile/mobile-bottom-tabs'; import { MoreSheet } from '@/components/layout/mobile/more-sheet'; import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay'; 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 + H-09: single-tree responsive shell with stable children subtree. * * The shell renders ONE `
` and ONE `` at all * viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs) * conditionally renders. Two payoffs: * * - #26 / first ship: no double-mount of chrome subtrees (Sidebar + * MobileTopbar both running fetches / providers in parallel like the * old layout did). * - H-09: `{children}` stays mounted across viewport flips. A rep * editing an inline field on desktop who resizes through the mobile * breakpoint no longer loses the draft mid-edit — the children tree's * position in the DOM is invariant, so React preserves its state. * * The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only * mount in the mobile branch — they have no desktop counterpart and would * be wasteful to keep mounted otherwise. * * 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'); const [moreOpen, setMoreOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); useEffect(() => { const mq = window.matchMedia(MOBILE_QUERY); const update = () => setIsMobile(mq.matches); update(); mq.addEventListener('change', update); return () => mq.removeEventListener('change', update); }, []); // Build the chrome subtree based on form factor; the children's parent // chain (MobileLayoutProvider > div > main) is invariant across both // branches, so React reconciliation keeps the children subtree mounted // when isMobile flips. const chrome = isMobile ? ( <> ) : ( ); const footer = isMobile ? ( <> setMoreOpen(true)} onSearchClick={() => setSearchOpen(true)} /> ) : null; const desktopTopbar = !isMobile ? : null; return (
{chrome}
{desktopTopbar}
{children}
{footer}
); }