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:
2026-05-14 23:59:30 +02:00
parent 7d33e73eef
commit 202e0b1bc5
4 changed files with 103 additions and 64 deletions

View 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>
);
}