refactor: split layout shell sections

This commit is contained in:
ShukeBta
2026-06-25 07:57:01 +08:00
parent c61d0190f2
commit 89af1dad32
3 changed files with 562 additions and 277 deletions
+32 -186
View File
@@ -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>
)
+337
View File
@@ -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}
/>
)
}
+193 -91
View File
@@ -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>
)
}