180 lines
7.5 KiB
TypeScript
180 lines
7.5 KiB
TypeScript
'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>
|
||
);
|
||
}
|