mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 20:26:36 +08:00
fix: prevent mobile sidebar nav items from stacking on short screens (#7)
Narrow drawers rendered media links in a flex-1 overflow region above a non-shrinking admin block, so the browse items were clipped into a compressed active pill. Scroll media and admin together, and distinguish /libraries from /libraries?from=admin when highlighting. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
@@ -8,16 +8,15 @@ import {
|
|||||||
LayoutWorkspace,
|
LayoutWorkspace,
|
||||||
} from './LayoutSections'
|
} from './LayoutSections'
|
||||||
import { MobileBottomNav } from './MobileBottomNav'
|
import { MobileBottomNav } from './MobileBottomNav'
|
||||||
import { isPlayerRoute, shouldShowMobileBottomNav } from './layoutNavigation'
|
import { isAdminEntrySearch, isPlayerRoute, shouldShowMobileBottomNav } from './layoutNavigation'
|
||||||
import { useLayoutPermissions } from './useLayoutPermissions'
|
import { useLayoutPermissions } from './useLayoutPermissions'
|
||||||
import { useLayoutProfiles } from './useLayoutProfiles'
|
import { useLayoutProfiles } from './useLayoutProfiles'
|
||||||
import { useLayoutSidebar } from './useLayoutSidebar'
|
import { useLayoutSidebar } from './useLayoutSidebar'
|
||||||
import { useThemeMode } from './useThemeMode'
|
import { useThemeMode } from './useThemeMode'
|
||||||
|
|
||||||
function isMediaView(pathname: string, search: string): boolean {
|
function isMediaView(pathname: string, search: string): boolean {
|
||||||
const params = new URLSearchParams(search)
|
// 从设置/管理后台菜单进入时展示左侧栏
|
||||||
// 从设置/管理后台菜单进入(携带 from=admin 或 from=settings 或 manage=1)时展示左侧栏
|
if (isAdminEntrySearch(search)) {
|
||||||
if (params.get('from') === 'admin' || params.get('from') === 'settings' || params.get('manage') === '1') {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'hidden lg:flex flex-col h-full shrink-0 transition-all duration-300 ease-out',
|
'hidden lg:flex min-h-0 h-full shrink-0 flex-col transition-all duration-300 ease-out',
|
||||||
isSidebarOpen ? 'w-64' : 'w-20',
|
isSidebarOpen ? 'w-64' : 'w-20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -66,7 +66,7 @@ export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileS
|
|||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: '-100%' }}
|
exit={{ x: '-100%' }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||||
className="relative flex w-64 max-w-xs flex-col h-full z-10 shadow-xl"
|
className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -34,25 +34,27 @@ export function LayoutSidebarContent({
|
|||||||
const adminItems = visibleSidebarItems({ isAdmin, can, items: LAYOUT_NAV_ITEMS })
|
const adminItems = visibleSidebarItems({ isAdmin, can, items: LAYOUT_NAV_ITEMS })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
<div className="flex h-full min-h-0 flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||||
<LayoutSidebarHeader
|
<LayoutSidebarHeader
|
||||||
sidebarExpanded={sidebarExpanded}
|
sidebarExpanded={sidebarExpanded}
|
||||||
onToggleSidebar={onToggleSidebar}
|
onToggleSidebar={onToggleSidebar}
|
||||||
onCloseMobileDrawer={onCloseMobileDrawer}
|
onCloseMobileDrawer={onCloseMobileDrawer}
|
||||||
/>
|
/>
|
||||||
{variant === 'media' ? (
|
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-hide">
|
||||||
<>
|
{variant === 'media' ? (
|
||||||
<LayoutSidebarNav items={mediaItems} sidebarExpanded={sidebarExpanded} grow />
|
<>
|
||||||
{adminItems.length > 0 && (
|
<LayoutSidebarNav items={mediaItems} sidebarExpanded={sidebarExpanded} />
|
||||||
<div className="border-t border-[var(--app-border)]">
|
{adminItems.length > 0 && (
|
||||||
<LayoutSidebarSectionLabel sidebarExpanded={sidebarExpanded} label="管理" />
|
<div className="border-t border-[var(--app-border)]">
|
||||||
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
|
<LayoutSidebarSectionLabel sidebarExpanded={sidebarExpanded} label="管理" />
|
||||||
</div>
|
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} compactTop />
|
||||||
)}
|
</div>
|
||||||
</>
|
)}
|
||||||
) : (
|
</>
|
||||||
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} grow />
|
) : (
|
||||||
)}
|
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -78,7 +80,7 @@ function LayoutSidebarHeader({
|
|||||||
onCloseMobileDrawer: () => void
|
onCloseMobileDrawer: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
<div className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||||
<Link to="/" className="flex items-center gap-3">
|
<Link to="/" className="flex items-center gap-3">
|
||||||
<img
|
<img
|
||||||
src="/brand/logo-192.png"
|
src="/brand/logo-192.png"
|
||||||
@@ -123,17 +125,17 @@ function LayoutSidebarSectionLabel({
|
|||||||
function LayoutSidebarNav({
|
function LayoutSidebarNav({
|
||||||
items,
|
items,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
grow = false,
|
compactTop = false,
|
||||||
}: {
|
}: {
|
||||||
items: LayoutNavItem[]
|
items: LayoutNavItem[]
|
||||||
sidebarExpanded: boolean
|
sidebarExpanded: boolean
|
||||||
grow?: boolean
|
compactTop?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide',
|
'px-4 py-5 space-y-1',
|
||||||
grow && 'flex-1',
|
compactTop && 'pt-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
@@ -155,7 +157,7 @@ function LayoutSidebarNav({
|
|||||||
|
|
||||||
function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) {
|
function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
<div className="shrink-0 border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { ChevronDown } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
import { isSidebarLinkActive } from './layoutNavigation'
|
||||||
|
|
||||||
type SidebarGroupProps = {
|
type SidebarGroupProps = {
|
||||||
id: string
|
id: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
@@ -79,44 +81,41 @@ type SidebarLinkProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
|
export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const isActive = isSidebarLinkActive(to, location.pathname, location.search, end)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
end={end}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={({ isActive }) =>
|
className={clsx(
|
||||||
clsx(
|
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
||||||
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
child && 'py-2.5 text-[13px]',
|
||||||
child && 'py-2.5 text-[13px]',
|
isActive
|
||||||
isActive
|
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<span className={clsx(
|
|
||||||
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
|
||||||
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
|
||||||
)}>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
{!collapsed && (
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0, x: -5 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
className="truncate whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</motion.span>
|
|
||||||
)}
|
|
||||||
{collapsed && (
|
|
||||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
>
|
||||||
|
<span className={clsx(
|
||||||
|
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
||||||
|
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||||
|
)}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{!collapsed && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, x: -5 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="truncate whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
{collapsed && (
|
||||||
|
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,44 @@ export function isRootMediaPath(pathname: string): boolean {
|
|||||||
return ROOT_MEDIA_PATHS.has(pathname)
|
return ROOT_MEDIA_PATHS.has(pathname)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin-entry query used to open the management sidebar on library pages. */
|
||||||
|
export function isAdminEntrySearch(search: string): boolean {
|
||||||
|
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||||
|
return (
|
||||||
|
params.get('from') === 'admin' ||
|
||||||
|
params.get('from') === 'settings' ||
|
||||||
|
params.get('manage') === '1'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLibraryPath(pathname: string): boolean {
|
||||||
|
return pathname === '/libraries' || pathname.startsWith('/library')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar active state must honor query strings. NavLink only matches pathname,
|
||||||
|
* so `/libraries` and `/libraries?from=admin` would otherwise both highlight.
|
||||||
|
*/
|
||||||
|
export function isSidebarLinkActive(
|
||||||
|
to: string,
|
||||||
|
pathname: string,
|
||||||
|
search: string,
|
||||||
|
end?: boolean,
|
||||||
|
): boolean {
|
||||||
|
const [toPath, toQuery = ''] = to.split('?')
|
||||||
|
const toIsAdminLibrary = toPath === '/libraries' && isAdminEntrySearch(toQuery)
|
||||||
|
const toIsMediaLibrary = toPath === '/libraries' && !toIsAdminLibrary
|
||||||
|
|
||||||
|
if (toIsAdminLibrary) {
|
||||||
|
return isLibraryPath(pathname) && isAdminEntrySearch(search)
|
||||||
|
}
|
||||||
|
if (toIsMediaLibrary) {
|
||||||
|
return isLibraryPath(pathname) && !isAdminEntrySearch(search)
|
||||||
|
}
|
||||||
|
|
||||||
|
return end ? pathname === toPath : pathname === toPath || pathname.startsWith(`${toPath}/`)
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
||||||
if (pathname.startsWith('/library/')) {
|
if (pathname.startsWith('/library/')) {
|
||||||
return { to: '/libraries', label: '媒体库' }
|
return { to: '/libraries', label: '媒体库' }
|
||||||
|
|||||||
Reference in New Issue
Block a user