fix(web): convert mobile navigation to drawer shell
This commit is contained in:
@@ -1837,7 +1837,9 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"expand": "展開側欄",
|
||||
"collapse": "收合側欄"
|
||||
"collapse": "收合側欄",
|
||||
"openNavigation": "開啟導航選單",
|
||||
"closeNavigation": "關閉導航選單"
|
||||
},
|
||||
"settings": {
|
||||
"title": "系統設定",
|
||||
|
||||
@@ -1837,7 +1837,9 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"expand": "展開側欄",
|
||||
"collapse": "收合側欄"
|
||||
"collapse": "收合側欄",
|
||||
"openNavigation": "開啟導航選單",
|
||||
"closeNavigation": "關閉導航選單"
|
||||
},
|
||||
"settings": {
|
||||
"title": "系統設定",
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Sidebar } from './sidebar'
|
||||
import { Header } from './header'
|
||||
import { DotMatrixBg } from '@/components/ui/dot-matrix-bg'
|
||||
import { Z_INDEX } from '@/lib/constants/z-index'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDashboardStore } from '@/stores/dashboard.store'
|
||||
|
||||
@@ -50,8 +52,10 @@ export function AppLayout({
|
||||
showBackground = true,
|
||||
fullBleed = false,
|
||||
}: AppLayoutProps) {
|
||||
const tSidebar = useTranslations('sidebar')
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileShell, setMobileShell] = useState(false)
|
||||
const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Phase 19 修復: 全局 SSE 連接
|
||||
@@ -73,6 +77,8 @@ export function AppLayout({
|
||||
setMobileShell(isMobile)
|
||||
if (isMobile) {
|
||||
setCollapsed(false)
|
||||
} else {
|
||||
setMobileNavigationOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +106,11 @@ export function AppLayout({
|
||||
|
||||
// Save collapsed state to localStorage
|
||||
const handleToggle = () => {
|
||||
if (mobileShell) {
|
||||
setMobileNavigationOpen(value => !value)
|
||||
return
|
||||
}
|
||||
|
||||
const newState = !collapsed
|
||||
setCollapsed(newState)
|
||||
localStorage.setItem(SIDEBAR_STATE_KEY, String(newState))
|
||||
@@ -110,8 +121,25 @@ export function AppLayout({
|
||||
const effectiveCollapsed = mounted ? collapsed : false
|
||||
const shellCollapsed = effectiveCollapsed
|
||||
const contentOffsetClass = shellCollapsed
|
||||
? mobileShell ? 'ml-[48px]' : 'ml-[64px]'
|
||||
: 'ml-[224px]'
|
||||
? 'ml-0 md:ml-[64px]'
|
||||
: 'ml-0 md:ml-[224px]'
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavigationOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setMobileNavigationOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [mobileNavigationOpen])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-nothing-gray-50">
|
||||
@@ -131,13 +159,49 @@ export function AppLayout({
|
||||
collapsed={shellCollapsed}
|
||||
compact={mobileShell}
|
||||
onToggle={handleToggle}
|
||||
className="hidden md:flex"
|
||||
/>
|
||||
|
||||
{mobileNavigationOpen && (
|
||||
<div
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
className="md:hidden"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: Z_INDEX.SIDEBAR + 1,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tSidebar('closeNavigation')}
|
||||
onClick={() => setMobileNavigationOpen(false)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
border: 0,
|
||||
background: 'rgba(20, 20, 19, 0.34)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<Sidebar
|
||||
locale={locale}
|
||||
collapsed={false}
|
||||
compact={false}
|
||||
width="min(304px, calc(100vw - 32px))"
|
||||
showToggle={false}
|
||||
onNavigate={() => setMobileNavigationOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<Header
|
||||
locale={locale}
|
||||
sidebarCollapsed={shellCollapsed}
|
||||
compact={mobileShell}
|
||||
onOpenNavigation={() => setMobileNavigationOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { Menu, ShieldCheck } from 'lucide-react'
|
||||
import { Z_INDEX } from '@/lib/constants/z-index'
|
||||
|
||||
// =============================================================================
|
||||
@@ -28,6 +28,7 @@ interface HeaderProps {
|
||||
locale: string
|
||||
sidebarCollapsed?: boolean
|
||||
compact?: boolean
|
||||
onOpenNavigation?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -39,11 +40,13 @@ export function Header({
|
||||
locale,
|
||||
sidebarCollapsed = false,
|
||||
compact = false,
|
||||
onOpenNavigation,
|
||||
className,
|
||||
}: HeaderProps) {
|
||||
const t = useTranslations('locale')
|
||||
const tBrand = useTranslations('brand')
|
||||
const tDashboard = useTranslations('dashboard')
|
||||
const tSidebar = useTranslations('sidebar')
|
||||
const pathname = usePathname()
|
||||
|
||||
const switchLocale = useCallback((newLocale: string) => {
|
||||
@@ -51,7 +54,7 @@ export function Header({
|
||||
window.location.href = newPath
|
||||
}, [locale, pathname])
|
||||
|
||||
const brandWidth = sidebarCollapsed ? 64 : 224
|
||||
const brandWidth = compact ? 64 : sidebarCollapsed ? 64 : 224
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -120,6 +123,29 @@ export function Header({
|
||||
gap: compact ? 8 : 16,
|
||||
background: '#faf9f3',
|
||||
}}>
|
||||
{compact && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenNavigation}
|
||||
aria-label={tSidebar('openNavigation')}
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 8,
|
||||
border: '0.5px solid #d8d4c8',
|
||||
background: '#fff',
|
||||
color: '#334155',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Menu size={18} aria-hidden="true" strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Page title */}
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-heading), Syne, sans-serif',
|
||||
|
||||
@@ -45,7 +45,10 @@ interface SidebarProps {
|
||||
locale: string
|
||||
collapsed?: boolean
|
||||
compact?: boolean
|
||||
width?: number | string
|
||||
showToggle?: boolean
|
||||
onToggle?: () => void
|
||||
onNavigate?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -64,7 +67,10 @@ export function Sidebar({
|
||||
locale,
|
||||
collapsed = false,
|
||||
compact = false,
|
||||
width,
|
||||
showToggle = true,
|
||||
onToggle,
|
||||
onNavigate,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const t = useTranslations('nav')
|
||||
@@ -98,13 +104,13 @@ export function Sidebar({
|
||||
item.aliases?.some(alias => isRouteActive(alias)) === true ||
|
||||
item.relatedPaths?.some(path => isRouteActive(path)) === true
|
||||
)
|
||||
const sidebarWidth = compact && collapsed ? 48 : collapsed ? 64 : 224
|
||||
const sidebarWidth = width ?? (compact && collapsed ? 48 : collapsed ? 64 : 224)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 flex flex-col',
|
||||
className
|
||||
'fixed left-0 flex-col',
|
||||
className ?? 'flex'
|
||||
)}
|
||||
style={{
|
||||
top: 68,
|
||||
@@ -143,6 +149,7 @@ export function Sidebar({
|
||||
key={item.id}
|
||||
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
||||
title={collapsed ? t(item.labelKey) : undefined}
|
||||
onClick={onNavigate}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -196,6 +203,7 @@ export function Sidebar({
|
||||
key={item.id}
|
||||
href={`/${locale}${item.href === '/' ? '' : item.href}`}
|
||||
title={collapsed ? t(item.labelKey) : undefined}
|
||||
onClick={onNavigate}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -237,31 +245,33 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* 折疊按鈕 */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -12,
|
||||
top: 80,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: '#faf9f3',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
aria-label={collapsed ? tSidebar('expand') : tSidebar('collapse')}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3 h-3 text-neutral-400" />
|
||||
)}
|
||||
</button>
|
||||
{showToggle && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -12,
|
||||
top: 80,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: '#faf9f3',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
aria-label={collapsed ? tSidebar('expand') : tSidebar('collapse')}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3 h-3 text-neutral-400" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user