mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
refactor: split layout shell sections
This commit is contained in:
+32
-186
@@ -1,14 +1,13 @@
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { LayoutSearchBox } from './LayoutSearchBox'
|
||||
import { LayoutSidebarContent } from './LayoutSidebarContent'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
import {
|
||||
LayoutFrameFooter,
|
||||
LayoutHeader,
|
||||
LayoutSidebars,
|
||||
LayoutWorkspace,
|
||||
} from './LayoutSections'
|
||||
import { useLayoutSearch } from './useLayoutSearch'
|
||||
import { useLayoutPermissions } from './useLayoutPermissions'
|
||||
import { useLayoutProfiles } from './useLayoutProfiles'
|
||||
@@ -28,188 +27,35 @@ export function Layout() {
|
||||
locationSearch: location.search,
|
||||
navigate,
|
||||
})
|
||||
const { can, isAdmin } = useLayoutPermissions(user)
|
||||
const {
|
||||
isMobileDrawerOpen,
|
||||
isRouteIn,
|
||||
isSidebarOpen,
|
||||
openGroups,
|
||||
setIsMobileDrawerOpen,
|
||||
setIsSidebarOpen,
|
||||
toggleGroup,
|
||||
} = useLayoutSidebar(location.pathname)
|
||||
const {
|
||||
activeProfile,
|
||||
isProfileOpen,
|
||||
profiles,
|
||||
setIsProfileOpen,
|
||||
switchProfile,
|
||||
useDefaultProfile,
|
||||
} = useLayoutProfiles({ activeProfileId, setActiveProfile, user })
|
||||
const permissions = useLayoutPermissions(user)
|
||||
const sidebar = useLayoutSidebar(location.pathname)
|
||||
const profile = useLayoutProfiles({ activeProfileId, setActiveProfile, user })
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const sidebarContent = (
|
||||
<LayoutSidebarContent
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
isMobileDrawerOpen={isMobileDrawerOpen}
|
||||
openGroups={openGroups}
|
||||
isAdmin={isAdmin}
|
||||
username={user?.username}
|
||||
can={can}
|
||||
isRouteIn={isRouteIn}
|
||||
onToggleGroup={toggleGroup}
|
||||
onToggleSidebar={() => setIsSidebarOpen((current) => !current)}
|
||||
onCloseMobileDrawer={() => setIsMobileDrawerOpen(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)
|
||||
const handleLogout = () => { logout(); navigate('/login') }
|
||||
const closeProfileAndLogout = () => { profile.setIsProfileOpen(false); handleLogout() }
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
|
||||
{/* 1. Desktop Persistent Sidebar */}
|
||||
<aside className={clsx(
|
||||
"hidden lg:flex flex-col h-full shrink-0 transition-all duration-300 ease-out",
|
||||
isSidebarOpen ? "w-64" : "w-20"
|
||||
)}>
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
{/* 2. Mobile & Tablet Sidebar Drawer (Overlay) */}
|
||||
<AnimatePresence>
|
||||
{isMobileDrawerOpen && (
|
||||
<div className="fixed inset-0 z-50 flex lg:hidden">
|
||||
{/* Backdrop sheet */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsMobileDrawerOpen(false)}
|
||||
className="fixed inset-0 bg-black/15 backdrop-blur-sm"
|
||||
/>
|
||||
{/* Drawer body container */}
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||
className="relative flex w-64 max-w-xs flex-col h-full z-10 shadow-xl"
|
||||
>
|
||||
{sidebarContent}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Main Workspace Container */}
|
||||
<LayoutSidebars
|
||||
sidebar={sidebar}
|
||||
isAdmin={permissions.isAdmin}
|
||||
username={user?.username}
|
||||
can={permissions.can}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
|
||||
{/* Top Header Bar */}
|
||||
<header className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
|
||||
{/* Header Left Area: Hamburger and Search */}
|
||||
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
|
||||
{/* Hamburger button shown on Mobile & Tablet */}
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(true)}
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
<LayoutSearchBox
|
||||
query={search.query}
|
||||
focused={search.focused}
|
||||
loading={search.loading}
|
||||
error={search.error}
|
||||
cards={search.cards}
|
||||
total={search.total}
|
||||
onQueryChange={search.setQuery}
|
||||
onFocusedChange={search.setFocused}
|
||||
onSubmit={search.submit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header Right Area: Actions, Notifications, Profile Dropdown */}
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
|
||||
{/* Quick search button shown ONLY on Mobile screens */}
|
||||
<Link
|
||||
to="/search"
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors sm:hidden"
|
||||
>
|
||||
<Search size={18} />
|
||||
</Link>
|
||||
|
||||
{/* Quick Discover shortcut button */}
|
||||
{can('can_view_discover') && (
|
||||
<Link
|
||||
to="/discover"
|
||||
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>发现新片</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Notification channel settings */}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="/notify-channels"
|
||||
title="通知配置"
|
||||
aria-label="打开通知配置"
|
||||
className="relative rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<MessageSquareText size={18} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Horizontal divider lines */}
|
||||
<LayoutThemeToggle mode={theme.mode} onChange={theme.setMode} />
|
||||
|
||||
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
|
||||
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isOpen={isProfileOpen}
|
||||
profiles={profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={activeProfile}
|
||||
onToggle={() => setIsProfileOpen((open) => !open)}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
onUseDefaultProfile={useDefaultProfile}
|
||||
onSwitchProfile={switchProfile}
|
||||
onLogout={() => {
|
||||
setIsProfileOpen(false)
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Dynamic Content Workspace Scroll Area ── */}
|
||||
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Absolute Footer Frame */}
|
||||
<AppFooter className="border-t border-[var(--app-border)] bg-[var(--app-panel)] py-5 text-center text-xs text-[var(--app-muted)]" />
|
||||
<LayoutHeader
|
||||
search={search}
|
||||
permissions={permissions}
|
||||
theme={theme}
|
||||
onOpenMobileDrawer={() => sidebar.setIsMobileDrawerOpen(true)}
|
||||
user={user}
|
||||
activeProfileId={activeProfileId}
|
||||
profile={profile}
|
||||
onLogout={closeProfileAndLogout}
|
||||
/>
|
||||
<LayoutWorkspace routeKey={location.pathname} />
|
||||
<LayoutFrameFooter />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { PlayProfile, User } from '../types'
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { LayoutSearchBox } from './LayoutSearchBox'
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
import type { ThemeMode } from './useThemeMode'
|
||||
import type { useLayoutSearch } from './useLayoutSearch'
|
||||
import type { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||
import type { useThemeMode } from './useThemeMode'
|
||||
|
||||
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
|
||||
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
|
||||
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
||||
type LayoutThemeState = ReturnType<typeof useThemeMode>
|
||||
|
||||
type LayoutPermissionState = {
|
||||
can: (key: string) => boolean
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
type LayoutSidebarProps = {
|
||||
children: React.ReactNode
|
||||
isSidebarOpen: boolean
|
||||
}
|
||||
|
||||
type LayoutMobileSidebarProps = {
|
||||
children: React.ReactNode
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type LayoutHeaderProps = {
|
||||
search: LayoutSearchState
|
||||
permissions: LayoutPermissionState
|
||||
theme: LayoutThemeState
|
||||
onOpenMobileDrawer: () => void
|
||||
user: User | null | undefined
|
||||
activeProfileId: string | null
|
||||
profile: LayoutProfileState
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
type LayoutSidebarsProps = Omit<
|
||||
LayoutSidebarContentProps,
|
||||
'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer'
|
||||
> & {
|
||||
sidebar: LayoutSidebarState
|
||||
}
|
||||
|
||||
type LayoutWorkspaceProps = {
|
||||
routeKey: string
|
||||
}
|
||||
|
||||
export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'hidden lg:flex flex-col h-full shrink-0 transition-all duration-300 ease-out',
|
||||
isSidebarOpen ? 'w-64' : 'w-20',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileSidebarProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex lg:hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/15 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||
className="relative flex w-64 max-w-xs flex-col h-full z-10 shadow-xl"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutSidebars({
|
||||
sidebar,
|
||||
isAdmin,
|
||||
username,
|
||||
can,
|
||||
onLogout,
|
||||
}: LayoutSidebarsProps) {
|
||||
const content = (
|
||||
<LayoutSidebarContent
|
||||
isSidebarOpen={sidebar.isSidebarOpen}
|
||||
isMobileDrawerOpen={sidebar.isMobileDrawerOpen}
|
||||
openGroups={sidebar.openGroups}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
can={can}
|
||||
isRouteIn={sidebar.isRouteIn}
|
||||
onToggleGroup={sidebar.toggleGroup}
|
||||
onToggleSidebar={() => sidebar.setIsSidebarOpen((current) => !current)}
|
||||
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayoutDesktopSidebar isSidebarOpen={sidebar.isSidebarOpen}>{content}</LayoutDesktopSidebar>
|
||||
<LayoutMobileSidebar
|
||||
isOpen={sidebar.isMobileDrawerOpen}
|
||||
onClose={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||
>
|
||||
{content}
|
||||
</LayoutMobileSidebar>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutHeader({
|
||||
search,
|
||||
permissions,
|
||||
theme,
|
||||
onOpenMobileDrawer,
|
||||
user,
|
||||
activeProfileId,
|
||||
profile,
|
||||
onLogout,
|
||||
}: LayoutHeaderProps) {
|
||||
return (
|
||||
<header className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
<LayoutHeaderSearch search={search} onOpenMobileDrawer={onOpenMobileDrawer} />
|
||||
<LayoutHeaderActions
|
||||
permissions={permissions}
|
||||
themeMode={theme.mode}
|
||||
onThemeChange={theme.setMode}
|
||||
user={user}
|
||||
isProfileOpen={profile.isProfileOpen}
|
||||
profiles={profile.profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={profile.activeProfile}
|
||||
onToggleProfile={() => profile.setIsProfileOpen((open) => !open)}
|
||||
onCloseProfile={() => profile.setIsProfileOpen(false)}
|
||||
onUseDefaultProfile={profile.useDefaultProfile}
|
||||
onSwitchProfile={profile.switchProfile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={routeKey}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutFrameFooter() {
|
||||
return (
|
||||
<AppFooter className="border-t border-[var(--app-border)] bg-[var(--app-panel)] py-5 text-center text-xs text-[var(--app-muted)]" />
|
||||
)
|
||||
}
|
||||
|
||||
export { LayoutSidebarContent }
|
||||
|
||||
function LayoutHeaderSearch({
|
||||
search,
|
||||
onOpenMobileDrawer,
|
||||
}: {
|
||||
search: LayoutSearchState
|
||||
onOpenMobileDrawer: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
|
||||
<button
|
||||
onClick={onOpenMobileDrawer}
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<LayoutSearchBox
|
||||
query={search.query}
|
||||
focused={search.focused}
|
||||
loading={search.loading}
|
||||
error={search.error}
|
||||
cards={search.cards}
|
||||
total={search.total}
|
||||
onQueryChange={search.setQuery}
|
||||
onFocusedChange={search.setFocused}
|
||||
onSubmit={search.submit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type LayoutHeaderActionsProps = {
|
||||
permissions: LayoutPermissionState
|
||||
themeMode: ThemeMode
|
||||
onThemeChange: (mode: ThemeMode) => void
|
||||
user: User | null | undefined
|
||||
isProfileOpen: boolean
|
||||
profiles: PlayProfile[]
|
||||
activeProfileId: string | null
|
||||
activeProfile: PlayProfile | null
|
||||
onToggleProfile: () => void
|
||||
onCloseProfile: () => void
|
||||
onUseDefaultProfile: () => void
|
||||
onSwitchProfile: (profile: PlayProfile) => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
function LayoutHeaderActions({
|
||||
permissions,
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
user,
|
||||
isProfileOpen,
|
||||
profiles,
|
||||
activeProfileId,
|
||||
activeProfile,
|
||||
onToggleProfile,
|
||||
onCloseProfile,
|
||||
onUseDefaultProfile,
|
||||
onSwitchProfile,
|
||||
onLogout,
|
||||
}: LayoutHeaderActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
|
||||
<LayoutQuickActions permissions={permissions} />
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
|
||||
<LayoutProfileMenu
|
||||
user={user}
|
||||
isProfileOpen={isProfileOpen}
|
||||
profiles={profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={activeProfile}
|
||||
onToggleProfile={onToggleProfile}
|
||||
onCloseProfile={onCloseProfile}
|
||||
onUseDefaultProfile={onUseDefaultProfile}
|
||||
onSwitchProfile={onSwitchProfile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) {
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
to="/search"
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors sm:hidden"
|
||||
>
|
||||
<Search size={18} />
|
||||
</Link>
|
||||
{permissions.can('can_view_discover') && (
|
||||
<Link
|
||||
to="/discover"
|
||||
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>发现新片</span>
|
||||
</Link>
|
||||
)}
|
||||
{permissions.isAdmin && (
|
||||
<Link
|
||||
to="/notify-channels"
|
||||
title="通知配置"
|
||||
aria-label="打开通知配置"
|
||||
className="relative rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<MessageSquareText size={18} />
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutProfileMenu({
|
||||
user,
|
||||
isProfileOpen,
|
||||
profiles,
|
||||
activeProfileId,
|
||||
activeProfile,
|
||||
onToggleProfile,
|
||||
onCloseProfile,
|
||||
onUseDefaultProfile,
|
||||
onSwitchProfile,
|
||||
onLogout,
|
||||
}: Omit<LayoutHeaderActionsProps, 'permissions' | 'themeMode' | 'onThemeChange'>) {
|
||||
return (
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isOpen={isProfileOpen}
|
||||
profiles={profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={activeProfile}
|
||||
onToggle={onToggleProfile}
|
||||
onClose={onCloseProfile}
|
||||
onUseDefaultProfile={onUseDefaultProfile}
|
||||
onSwitchProfile={onSwitchProfile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { LogOut, Menu, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { LAYOUT_NAV_GROUPS, NAV_GROUP_PATHS, type LayoutNavItem } from './layoutNavigation'
|
||||
import { LAYOUT_NAV_GROUPS, NAV_GROUP_PATHS, type LayoutNavGroup, type LayoutNavItem } from './layoutNavigation'
|
||||
import { SidebarGroup, SidebarLink } from './LayoutSidebarNav'
|
||||
|
||||
type LayoutSidebarContentProps = {
|
||||
export type LayoutSidebarContentProps = {
|
||||
isSidebarOpen: boolean
|
||||
isMobileDrawerOpen: boolean
|
||||
openGroups: Record<string, boolean>
|
||||
@@ -19,6 +19,11 @@ type LayoutSidebarContentProps = {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
type VisibleLayoutNavGroup = {
|
||||
group: LayoutNavGroup
|
||||
items: LayoutNavItem[]
|
||||
}
|
||||
|
||||
export function LayoutSidebarContent({
|
||||
isSidebarOpen,
|
||||
isMobileDrawerOpen,
|
||||
@@ -33,98 +38,195 @@ export function LayoutSidebarContent({
|
||||
onLogout,
|
||||
}: LayoutSidebarContentProps) {
|
||||
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
||||
const isItemVisible = (item: LayoutNavItem) =>
|
||||
(!item.adminOnly || isAdmin) && (!item.permission || can(item.permission))
|
||||
const visibleGroups = LAYOUT_NAV_GROUPS
|
||||
.filter((group) => !group.adminOnly || isAdmin)
|
||||
.map((group) => ({
|
||||
group,
|
||||
items: group.items.filter(isItemVisible),
|
||||
}))
|
||||
.filter(({ items }) => items.length > 0)
|
||||
const visibleGroups = visibleSidebarGroups({ isAdmin, can })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/mediastationgo-logo.svg"
|
||||
alt="MediaStationGo"
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
{sidebarExpanded && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
|
||||
>
|
||||
MediaStationGo
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors hidden lg:block"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onCloseMobileDrawer}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors block lg:hidden"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
|
||||
{visibleGroups.map(({ group, items }) => {
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<SidebarGroup
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
icon={<GroupIcon size={18} />}
|
||||
label={group.label}
|
||||
collapsed={!sidebarExpanded}
|
||||
open={openGroups[group.id] ?? group.id === 'media'}
|
||||
active={isRouteIn(NAV_GROUP_PATHS[group.id])}
|
||||
onToggle={onToggleGroup}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
<SidebarLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
icon={<ItemIcon size={16} />}
|
||||
label={item.label}
|
||||
end={item.end}
|
||||
child
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SidebarGroup>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className={clsx(
|
||||
'flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout',
|
||||
sidebarExpanded
|
||||
? 'justify-start text-[var(--app-muted)] hover:bg-[var(--app-danger-soft)] hover:text-red-500'
|
||||
: 'justify-center text-[var(--app-muted)] hover:text-red-500',
|
||||
)}
|
||||
title={`安全登出 (${username ?? ''})`}
|
||||
>
|
||||
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||
{sidebarExpanded && <span>安全退出</span>}
|
||||
</button>
|
||||
</div>
|
||||
<LayoutSidebarHeader
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onCloseMobileDrawer={onCloseMobileDrawer}
|
||||
/>
|
||||
<LayoutSidebarNav
|
||||
groups={visibleGroups}
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
openGroups={openGroups}
|
||||
isRouteIn={isRouteIn}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
<LayoutSidebarLogout sidebarExpanded={sidebarExpanded} username={username} onLogout={onLogout} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function visibleSidebarGroups({
|
||||
isAdmin,
|
||||
can,
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): VisibleLayoutNavGroup[] {
|
||||
const isItemVisible = (item: LayoutNavItem) =>
|
||||
(!item.adminOnly || isAdmin) && (!item.permission || can(item.permission))
|
||||
return LAYOUT_NAV_GROUPS
|
||||
.filter((group) => !group.adminOnly || isAdmin)
|
||||
.map((group) => ({ group, items: group.items.filter(isItemVisible) }))
|
||||
.filter(({ items }) => items.length > 0)
|
||||
}
|
||||
|
||||
function LayoutSidebarHeader({
|
||||
sidebarExpanded,
|
||||
onToggleSidebar,
|
||||
onCloseMobileDrawer,
|
||||
}: {
|
||||
sidebarExpanded: boolean
|
||||
onToggleSidebar: () => void
|
||||
onCloseMobileDrawer: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/mediastationgo-logo.svg"
|
||||
alt="MediaStationGo"
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
{sidebarExpanded && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
|
||||
>
|
||||
MediaStationGo
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
<SidebarIconButton className="hidden lg:block" onClick={onToggleSidebar}>
|
||||
<Menu size={18} />
|
||||
</SidebarIconButton>
|
||||
<SidebarIconButton className="block lg:hidden" onClick={onCloseMobileDrawer}>
|
||||
<X size={18} />
|
||||
</SidebarIconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarNav({
|
||||
groups,
|
||||
sidebarExpanded,
|
||||
openGroups,
|
||||
isRouteIn,
|
||||
onToggleGroup,
|
||||
}: {
|
||||
groups: VisibleLayoutNavGroup[]
|
||||
sidebarExpanded: boolean
|
||||
openGroups: Record<string, boolean>
|
||||
isRouteIn: (paths: string[]) => boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
|
||||
{groups.map(({ group, items }) => (
|
||||
<LayoutSidebarNavGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
items={items}
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
open={openGroups[group.id] ?? group.id === 'media'}
|
||||
active={isRouteIn(NAV_GROUP_PATHS[group.id])}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarNavGroup({
|
||||
group,
|
||||
items,
|
||||
sidebarExpanded,
|
||||
open,
|
||||
active,
|
||||
onToggleGroup,
|
||||
}: {
|
||||
group: LayoutNavGroup
|
||||
items: LayoutNavItem[]
|
||||
sidebarExpanded: boolean
|
||||
open: boolean
|
||||
active: boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
}) {
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<SidebarGroup
|
||||
id={group.id}
|
||||
icon={<GroupIcon size={18} />}
|
||||
label={group.label}
|
||||
collapsed={!sidebarExpanded}
|
||||
open={open}
|
||||
active={active}
|
||||
onToggle={onToggleGroup}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
<SidebarLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
icon={<ItemIcon size={16} />}
|
||||
label={item.label}
|
||||
end={item.end}
|
||||
child
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarLogout({
|
||||
sidebarExpanded,
|
||||
username,
|
||||
onLogout,
|
||||
}: {
|
||||
sidebarExpanded: boolean
|
||||
username?: string
|
||||
onLogout: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className={clsx(
|
||||
'flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout',
|
||||
sidebarExpanded
|
||||
? 'justify-start text-[var(--app-muted)] hover:bg-[var(--app-danger-soft)] hover:text-red-500'
|
||||
: 'justify-center text-[var(--app-muted)] hover:text-red-500',
|
||||
)}
|
||||
title={`安全登出 (${username ?? ''})`}
|
||||
>
|
||||
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||
{sidebarExpanded && <span>安全退出</span>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarIconButton({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user