Compare commits

...

5 Commits

Author SHA1 Message Date
github-actions[bot] 0b1fe52e24 chore: bump version to 0.0.70 [skip ci] 2026-09-02 02:49:11 +00:00
truewhile 2fc2c17b05 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>
2026-09-02 10:48:58 +08:00
github-actions[bot] be96acb035 chore: bump version to 0.0.69 [skip ci] 2026-09-02 02:44:26 +00:00
truewhile 6a72e7ab82 Merge pull request #6 from truewhile/cursor/frontend-nav-mobile-af8f
Improve mobile navigation and fix playlists page routing
2026-09-02 10:44:10 +08:00
Cursor Agent 4ebd477ca2 Fix playlists route matching /play and improve playlists page UI
- Add isPlayerRoute() so /playlists no longer hides header and bottom nav
- Add mobile back navigation for playlists, favourites, and history
- Redesign PlaylistsPage with PageHeader, empty state, and card list

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 02:30:14 +00:00
7 changed files with 213 additions and 109 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.68
0.0.70
+5 -6
View File
@@ -8,16 +8,15 @@ import {
LayoutWorkspace,
} from './LayoutSections'
import { MobileBottomNav } from './MobileBottomNav'
import { shouldShowMobileBottomNav } from './layoutNavigation'
import { isAdminEntrySearch, isPlayerRoute, shouldShowMobileBottomNav } from './layoutNavigation'
import { useLayoutPermissions } from './useLayoutPermissions'
import { useLayoutProfiles } from './useLayoutProfiles'
import { useLayoutSidebar } from './useLayoutSidebar'
import { useThemeMode } from './useThemeMode'
function isMediaView(pathname: string, search: string): boolean {
const params = new URLSearchParams(search)
// 从设置/管理后台菜单进入(携带 from=admin 或 from=settings 或 manage=1)时展示左侧栏
if (params.get('from') === 'admin' || params.get('from') === 'settings' || params.get('manage') === '1') {
// 从设置/管理后台菜单进入时展示左侧栏
if (isAdminEntrySearch(search)) {
return false
}
@@ -26,7 +25,7 @@ function isMediaView(pathname: string, search: string): boolean {
pathname === '/libraries' ||
pathname.startsWith('/library') ||
pathname.startsWith('/media') ||
pathname.startsWith('/play') ||
isPlayerRoute(pathname) ||
pathname === '/favourites' ||
pathname === '/playlists' ||
pathname.startsWith('/playlist') ||
@@ -52,7 +51,7 @@ export function Layout() {
const showSidebar = !isMediaView(location.pathname, location.search)
const hideSearch = location.pathname.startsWith('/settings')
const isPlayPage = location.pathname.startsWith('/play')
const isPlayPage = isPlayerRoute(location.pathname)
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
return (
+4 -3
View File
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'
import clsx from 'clsx'
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
import { isPlayerRoute } from './layoutNavigation'
import { RouteErrorBoundary } from './RouteErrorBoundary'
import type { useLayoutSidebar } from './useLayoutSidebar'
@@ -39,7 +40,7 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP
return (
<aside
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',
)}
>
@@ -65,7 +66,7 @@ export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileS
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"
className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl"
>
{children}
</motion.div>
@@ -125,7 +126,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
: ''
if (routeKey.startsWith('/play')) {
if (isPlayerRoute(routeKey)) {
return (
<main className="flex flex-1 h-full w-full overflow-hidden">
<RouteErrorBoundary>
+22 -20
View File
@@ -34,25 +34,27 @@ export function LayoutSidebarContent({
const adminItems = visibleSidebarItems({ isAdmin, can, items: LAYOUT_NAV_ITEMS })
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
sidebarExpanded={sidebarExpanded}
onToggleSidebar={onToggleSidebar}
onCloseMobileDrawer={onCloseMobileDrawer}
/>
{variant === 'media' ? (
<>
<LayoutSidebarNav items={mediaItems} sidebarExpanded={sidebarExpanded} grow />
{adminItems.length > 0 && (
<div className="border-t border-[var(--app-border)]">
<LayoutSidebarSectionLabel sidebarExpanded={sidebarExpanded} label="管理" />
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
</div>
)}
</>
) : (
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} grow />
)}
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-hide">
{variant === 'media' ? (
<>
<LayoutSidebarNav items={mediaItems} sidebarExpanded={sidebarExpanded} />
{adminItems.length > 0 && (
<div className="border-t border-[var(--app-border)]">
<LayoutSidebarSectionLabel sidebarExpanded={sidebarExpanded} label="管理" />
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} compactTop />
</div>
)}
</>
) : (
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
)}
</div>
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
</div>
)
@@ -78,7 +80,7 @@ function LayoutSidebarHeader({
onCloseMobileDrawer: () => void
}) {
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">
<img
src="/brand/logo-192.png"
@@ -123,17 +125,17 @@ function LayoutSidebarSectionLabel({
function LayoutSidebarNav({
items,
sidebarExpanded,
grow = false,
compactTop = false,
}: {
items: LayoutNavItem[]
sidebarExpanded: boolean
grow?: boolean
compactTop?: boolean
}) {
return (
<nav
className={clsx(
'overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide',
grow && 'flex-1',
'px-4 py-5 space-y-1',
compactTop && 'pt-2',
)}
>
{items.map((item) => {
@@ -155,7 +157,7 @@ function LayoutSidebarNav({
function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) {
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
to="/"
className={clsx(
+36 -37
View File
@@ -1,9 +1,11 @@
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 { ChevronDown } from 'lucide-react'
import clsx from 'clsx'
import { isSidebarLinkActive } from './layoutNavigation'
type SidebarGroupProps = {
id: string
icon: ReactNode
@@ -79,44 +81,41 @@ type SidebarLinkProps = {
}
export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
const location = useLocation()
const isActive = isSidebarLinkActive(to, location.pathname, location.search, end)
return (
<NavLink
<Link
to={to}
end={end}
className={({ isActive }) =>
clsx(
'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]',
isActive
? '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)]',
)
}
>
{({ 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>
)}
</>
aria-current={isActive ? 'page' : undefined}
className={clsx(
'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]',
isActive
? '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)]',
)}
</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>
)
}
+47 -1
View File
@@ -76,14 +76,60 @@ export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
/** True only for the fullscreen player route (`/play` or `/play/:id`), not `/playlists`. */
export function isPlayerRoute(pathname: string): boolean {
return pathname === '/play' || pathname.startsWith('/play/')
}
export function isRootMediaPath(pathname: string): boolean {
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 {
if (pathname.startsWith('/library/')) {
return { to: '/libraries', label: '媒体库' }
}
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
return { to: '/', label: '首页' }
}
if (pathname.startsWith('/playlist/')) {
return { to: '/playlists', label: '播放列表' }
}
@@ -118,7 +164,7 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
}
export function shouldShowMobileBottomNav(pathname: string): boolean {
if (pathname.startsWith('/play')) return false
if (isPlayerRoute(pathname)) return false
return (
pathname === '/' ||
pathname === '/libraries' ||
+98 -41
View File
@@ -1,18 +1,18 @@
import { FormEvent, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ListPlus, Trash2 } from 'lucide-react'
import { ChevronRight, ListMusic, ListPlus, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { playbackAPI } from '../api/playback'
import { confirmAction } from '../components/confirmAction'
import { PageHeader } from '../components/PageHeader'
import type { Playlist } from '../types'
// Landing page for playlists. Lists every playlist owned by the current
// user and lets them create / delete one.
export function PlaylistsPage() {
const [items, setItems] = useState<Playlist[]>([])
const [name, setName] = useState('')
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const refresh = () =>
playbackAPI
@@ -26,67 +26,124 @@ export function PlaylistsPage() {
const onCreate = async (e: FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
setCreating(true)
try {
await playbackAPI.createPlaylist(name)
await playbackAPI.createPlaylist(trimmed)
toast.success('已创建')
setName('')
await refresh()
} catch {
toast.error('创建失败')
} finally {
setCreating(false)
}
}
const isEmpty = !loading && items.length === 0
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">播放列表</h1>
<PageHeader
title="播放列表"
description="整理你想稍后观看的影片,随时继续播放"
/>
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_auto]">
<form
onSubmit={onCreate}
className="glass-panel flex flex-col gap-3 sm:flex-row sm:items-center"
>
<input
required
className="input-base"
className="input-base min-w-0 flex-1"
placeholder="新播放列表名称"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={creating}
/>
<button type="submit" className="neon-button">
<ListPlus size={16} /> 新建
<button type="submit" className="neon-button shrink-0" disabled={creating}>
<ListPlus size={16} />
{creating ? '创建中…' : '新建'}
</button>
</form>
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-ink-50">还没有任何播放列表。</p>}
{loading && (
<div className="flex items-center gap-2 py-8 text-ink-50">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
加载中…
</div>
)}
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((p) => (
<div
key={p.id}
className="glass-panel flex items-center justify-between !p-4"
>
<Link
to={`/playlist/${p.id}`}
className="font-medium text-ink-600 transition hover:text-brand-500"
>
{p.name}
{p.is_public && (
<span className="ml-2 rounded-lg border border-primary-400/40 px-1.5 py-0.5 text-xs text-brand-500">
public
</span>
)}
</Link>
<button
onClick={async () => {
if (!(await confirmAction({ title: '删除播放列表', message: `删除「${p.name}」?`, confirmText: '删除' }))) return
await playbackAPI.deletePlaylist(p.id)
toast.success('已删除')
await refresh()
}}
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} />
</button>
{isEmpty && (
<div className="glass-panel flex flex-col items-center gap-4 p-10 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-400/10 text-brand-500">
<ListMusic size={28} />
</div>
))}
</div>
<div className="space-y-1">
<p className="text-lg font-medium text-ink-100">还没有任何播放列表</p>
<p className="text-sm text-sand-500">
在上方输入名称创建列表,或在媒体详情页点击「加入播放列表」
</p>
</div>
</div>
)}
{items.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((p) => (
<div
key={p.id}
className="glass-panel group flex items-center gap-3 !p-4 transition hover:border-primary-400/30"
>
<Link
to={`/playlist/${p.id}`}
className="flex min-w-0 flex-1 items-center gap-3"
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary-400/10 text-brand-500 transition group-hover:bg-primary-400/20">
<ListMusic size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-ink-600 transition group-hover:text-brand-500">
{p.name}
</p>
{p.is_public && (
<span className="mt-0.5 inline-block rounded-md border border-primary-400/40 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-brand-500">
公开
</span>
)}
</div>
<ChevronRight
size={18}
className="shrink-0 text-sand-500 transition group-hover:text-brand-500"
/>
</Link>
<button
type="button"
onClick={async () => {
if (
!(await confirmAction({
title: '删除播放列表',
message: `删除「${p.name}」?`,
confirmText: '删除',
}))
) {
return
}
await playbackAPI.deletePlaylist(p.id)
toast.success('已删除')
await refresh()
}}
className="shrink-0 rounded-lg border border-red-400/40 p-2 text-red-400 transition hover:bg-red-400/10"
title="删除播放列表"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
)
}