Files
2026-04-23 18:58:13 +08:00

180 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import * as Dialog from '@radix-ui/react-dialog';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { ClassicCollapsedSidebar } from '@/components/layout/ClassicCollapsedSidebar';
import { IconSidebarLayout } from '@/components/layout/IconSidebarLayout';
import { NavTooltip } from '@/components/layout/nav-tooltip';
import { SidebarNav } from '@/components/layout/SidebarNav';
import { TenantSwitcher } from '@/components/layout/TenantSwitcher';
import { UserMenu } from '@/components/layout/UserMenu';
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
import { useNavMenu } from '@/lib/hooks/use-nav-menu';
import { redirectToAuthorize } from '@/lib/oauth/browser';
import { useAuthStore } from '@/stores/auth-store';
import { useLayoutStore } from '@/stores/layout-store';
export function AppChrome({ children }: { children: React.ReactNode }) {
const authed = useAuthStore((s) => Boolean(s.accessToken));
const sidebarMode = useLayoutStore((s) => s.sidebarMode);
const classicNavRailCollapsed = useLayoutStore((s) => s.classicNavRailCollapsed);
const toggleClassicNavRail = useLayoutStore((s) => s.toggleClassicNavRail);
const { items: nav, loading: navLoading, error: navError } = useNavMenu();
const onMenuNavigate = useMenuNavigation();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showClassicNarrowUi, setShowClassicNarrowUi] = useState(classicNavRailCollapsed);
const classicAsideRef = useRef<HTMLElement>(null);
useEffect(() => {
const mq = window.matchMedia('(min-width: 768px)');
const onChange = () => {
if (mq.matches) setMobileNavOpen(false);
};
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
useEffect(() => {
if (!classicNavRailCollapsed) setShowClassicNarrowUi(false);
}, [classicNavRailCollapsed]);
useEffect(() => {
if (!classicNavRailCollapsed || showClassicNarrowUi) return;
const el = classicAsideRef.current;
const finish = () => {
if (useLayoutStore.getState().classicNavRailCollapsed) setShowClassicNarrowUi(true);
};
const onEnd = (e: TransitionEvent) => {
if (e.propertyName === 'width') {
finish();
el?.removeEventListener('transitionend', onEnd);
}
};
el?.addEventListener('transitionend', onEnd);
const t = window.setTimeout(finish, 600);
return () => {
el?.removeEventListener('transitionend', onEnd);
window.clearTimeout(t);
};
}, [classicNavRailCollapsed, showClassicNarrowUi]);
const classicAsideMotion =
'min-w-0 shrink-0 transition-[width] duration-500 ease-in-out motion-reduce:transition-none';
const asideClass =
sidebarMode === 'icon'
? 'hidden min-h-0 shrink-0 overflow-hidden border-r border-neutral-200 bg-white md:flex md:flex-col'
: `relative hidden h-full min-h-0 flex-col overflow-visible border-r border-neutral-200 bg-white md:flex ${classicAsideMotion}`;
const classicAsideStyle =
sidebarMode !== 'icon' ? { width: classicNavRailCollapsed ? '72px' : '14rem' } : undefined;
const showExpandedClassicChrome = !classicNavRailCollapsed || !showClassicNarrowUi;
const sidebarProps = {
items: nav,
loading: navLoading,
error: navError,
authed,
onMenuNavigate,
};
return (
<div className="flex h-dvh min-h-0 flex-col overflow-hidden">
<header className="flex shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
<div className="flex min-w-0 flex-1 items-center gap-2 md:gap-4">
<button
type="button"
className="rounded-md p-2 text-neutral-700 hover:bg-neutral-100 md:hidden"
aria-label="打开导航菜单"
aria-expanded={mobileNavOpen}
onClick={() => setMobileNavOpen(true)}
>
<span className="text-lg leading-none" aria-hidden>
</span>
</button>
<Link href="/dashboard" className="shrink-0 font-semibold text-neutral-800">
Smart Admin
</Link>
{authed ? <TenantSwitcher /> : null}
</div>
<div className="flex min-w-0 items-center gap-2 text-sm">
{authed ? (
<UserMenu />
) : (
<>
<Link className="text-blue-600 underline" href="/login">
</Link>
<button
type="button"
className="rounded bg-neutral-900 px-2 py-1 text-white"
onClick={() => void redirectToAuthorize()}
>
OAuth
</button>
</>
)}
</div>
</header>
<div className="flex min-h-0 flex-1 items-stretch">
<aside ref={classicAsideRef} className={asideClass} style={classicAsideStyle} aria-label="侧栏">
{sidebarMode === 'icon' ? (
<IconSidebarLayout {...sidebarProps} />
) : showExpandedClassicChrome ? (
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-visible">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<SidebarNav {...sidebarProps} />
</div>
<div className="pointer-events-none absolute inset-0 z-50 hidden md:block" aria-hidden>
<NavTooltip label="收起侧栏(仅一级菜单)" side="right">
<button
type="button"
className="pointer-events-auto absolute left-full top-1/2 z-50 flex h-14 w-3.5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-r-full border border-neutral-200 border-l-0 bg-white text-xs text-neutral-500 shadow-sm transition-transform duration-200 ease-out hover:bg-neutral-50 active:scale-95 motion-reduce:transition-none"
aria-expanded={true}
aria-label="收起侧栏,仅显示一级菜单"
onClick={() => toggleClassicNavRail()}
>
<span aria-hidden></span>
</button>
</NavTooltip>
</div>
</div>
) : (
<ClassicCollapsedSidebar {...sidebarProps} />
)}
</aside>
<div className="flex min-w-0 flex-1 flex-col bg-neutral-100">{children}</div>
</div>
<Dialog.Root open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-100 bg-black/40 md:hidden" />
<Dialog.Content className="fixed left-0 top-0 z-100 flex h-dvh w-56 max-w-[85vw] min-h-0 flex-col border-r border-neutral-200 bg-white p-0 shadow-lg outline-none focus:outline-none md:hidden">
<Dialog.Title className="sr-only"></Dialog.Title>
<Dialog.Description className="sr-only"></Dialog.Description>
<button
type="button"
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-neutral-600 hover:bg-neutral-100"
aria-label="关闭菜单"
onClick={() => setMobileNavOpen(false)}
>
<span aria-hidden></span>
</button>
<div className="min-h-0 flex-1 overflow-y-auto pt-10">
<SidebarNav {...sidebarProps} onInternalNavigate={() => setMobileNavOpen(false)} />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
);
}