72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|