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:
truewhile
2026-09-02 10:48:58 +08:00
committed by GitHub
parent be96acb035
commit 2fc2c17b05
5 changed files with 101 additions and 63 deletions
+3 -4
View File
@@ -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
} }
+2 -2
View File
@@ -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>
+22 -20
View File
@@ -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(
+36 -37
View File
@@ -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>
) )
} }
+38
View File
@@ -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: '媒体库' }