mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 04:26:38 +08:00
Merge pull request #6 from truewhile/cursor/frontend-nav-mobile-af8f
Improve mobile navigation and fix playlists page routing
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
|||||||
LayoutWorkspace,
|
LayoutWorkspace,
|
||||||
} from './LayoutSections'
|
} from './LayoutSections'
|
||||||
import { MobileBottomNav } from './MobileBottomNav'
|
import { MobileBottomNav } from './MobileBottomNav'
|
||||||
import { shouldShowMobileBottomNav } from './layoutNavigation'
|
import { 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'
|
||||||
@@ -26,7 +26,7 @@ function isMediaView(pathname: string, search: string): boolean {
|
|||||||
pathname === '/libraries' ||
|
pathname === '/libraries' ||
|
||||||
pathname.startsWith('/library') ||
|
pathname.startsWith('/library') ||
|
||||||
pathname.startsWith('/media') ||
|
pathname.startsWith('/media') ||
|
||||||
pathname.startsWith('/play') ||
|
isPlayerRoute(pathname) ||
|
||||||
pathname === '/favourites' ||
|
pathname === '/favourites' ||
|
||||||
pathname === '/playlists' ||
|
pathname === '/playlists' ||
|
||||||
pathname.startsWith('/playlist') ||
|
pathname.startsWith('/playlist') ||
|
||||||
@@ -52,7 +52,7 @@ export function Layout() {
|
|||||||
|
|
||||||
const showSidebar = !isMediaView(location.pathname, location.search)
|
const showSidebar = !isMediaView(location.pathname, location.search)
|
||||||
const hideSearch = location.pathname.startsWith('/settings')
|
const hideSearch = location.pathname.startsWith('/settings')
|
||||||
const isPlayPage = location.pathname.startsWith('/play')
|
const isPlayPage = isPlayerRoute(location.pathname)
|
||||||
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
|
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||||
|
import { isPlayerRoute } from './layoutNavigation'
|
||||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
|
|||||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
if (routeKey.startsWith('/play')) {
|
if (isPlayerRoute(routeKey)) {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-1 h-full w-full overflow-hidden">
|
<main className="flex flex-1 h-full w-full overflow-hidden">
|
||||||
<RouteErrorBoundary>
|
<RouteErrorBoundary>
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
|
|||||||
|
|
||||||
const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
|
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 {
|
export function isRootMediaPath(pathname: string): boolean {
|
||||||
return ROOT_MEDIA_PATHS.has(pathname)
|
return ROOT_MEDIA_PATHS.has(pathname)
|
||||||
}
|
}
|
||||||
@@ -84,6 +89,9 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
|||||||
if (pathname.startsWith('/library/')) {
|
if (pathname.startsWith('/library/')) {
|
||||||
return { to: '/libraries', label: '媒体库' }
|
return { to: '/libraries', label: '媒体库' }
|
||||||
}
|
}
|
||||||
|
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
|
||||||
|
return { to: '/', label: '首页' }
|
||||||
|
}
|
||||||
if (pathname.startsWith('/playlist/')) {
|
if (pathname.startsWith('/playlist/')) {
|
||||||
return { to: '/playlists', label: '播放列表' }
|
return { to: '/playlists', label: '播放列表' }
|
||||||
}
|
}
|
||||||
@@ -118,7 +126,7 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowMobileBottomNav(pathname: string): boolean {
|
export function shouldShowMobileBottomNav(pathname: string): boolean {
|
||||||
if (pathname.startsWith('/play')) return false
|
if (isPlayerRoute(pathname)) return false
|
||||||
return (
|
return (
|
||||||
pathname === '/' ||
|
pathname === '/' ||
|
||||||
pathname === '/libraries' ||
|
pathname === '/libraries' ||
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
import { FormEvent, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { playbackAPI } from '../api/playback'
|
import { playbackAPI } from '../api/playback'
|
||||||
import { confirmAction } from '../components/confirmAction'
|
import { confirmAction } from '../components/confirmAction'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
import type { Playlist } from '../types'
|
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() {
|
export function PlaylistsPage() {
|
||||||
const [items, setItems] = useState<Playlist[]>([])
|
const [items, setItems] = useState<Playlist[]>([])
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
const refresh = () =>
|
const refresh = () =>
|
||||||
playbackAPI
|
playbackAPI
|
||||||
@@ -26,67 +26,124 @@ export function PlaylistsPage() {
|
|||||||
|
|
||||||
const onCreate = async (e: FormEvent) => {
|
const onCreate = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
const trimmed = name.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
|
||||||
|
setCreating(true)
|
||||||
try {
|
try {
|
||||||
await playbackAPI.createPlaylist(name)
|
await playbackAPI.createPlaylist(trimmed)
|
||||||
toast.success('已创建')
|
toast.success('已创建')
|
||||||
setName('')
|
setName('')
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('创建失败')
|
toast.error('创建失败')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEmpty = !loading && items.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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
|
<input
|
||||||
required
|
required
|
||||||
className="input-base"
|
className="input-base min-w-0 flex-1"
|
||||||
placeholder="新播放列表名称"
|
placeholder="新播放列表名称"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={creating}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="neon-button">
|
<button type="submit" className="neon-button shrink-0" disabled={creating}>
|
||||||
<ListPlus size={16} /> 新建
|
<ListPlus size={16} />
|
||||||
|
{creating ? '创建中…' : '新建'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{loading && <p className="text-sand-500">加载中…</p>}
|
{loading && (
|
||||||
{!loading && items.length === 0 && <p className="text-ink-50">还没有任何播放列表。</p>}
|
<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">
|
{isEmpty && (
|
||||||
{items.map((p) => (
|
<div className="glass-panel flex flex-col items-center gap-4 p-10 text-center">
|
||||||
<div
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-400/10 text-brand-500">
|
||||||
key={p.id}
|
<ListMusic size={28} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="space-y-1">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user