mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
Improve frontend navigation and mobile UX
- Add mobile bottom tab bar for media browsing (Home, Libraries, Favourites, Playlists, More) - Split mobile drawer: media links first, admin links under a Management section - Show contextual back button in mobile header for deep routes - Add reusable PageBackButton and PageHeader components - Add back navigation to playlist detail, library page, and missing media state - Embed queue panels in TaskQueuePage to avoid duplicate page titles - Expose theme toggle in user menu on small screens Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
|||||||
LayoutSidebars,
|
LayoutSidebars,
|
||||||
LayoutWorkspace,
|
LayoutWorkspace,
|
||||||
} from './LayoutSections'
|
} from './LayoutSections'
|
||||||
|
import { MobileBottomNav } from './MobileBottomNav'
|
||||||
|
import { 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'
|
||||||
@@ -51,6 +53,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 = location.pathname.startsWith('/play')
|
||||||
|
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||||
@@ -59,6 +62,7 @@ export function Layout() {
|
|||||||
isAdmin={permissions.isAdmin}
|
isAdmin={permissions.isAdmin}
|
||||||
can={permissions.can}
|
can={permissions.can}
|
||||||
showSidebar={showSidebar}
|
showSidebar={showSidebar}
|
||||||
|
sidebarVariant={showSidebar ? 'admin' : 'media'}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||||
{!isPlayPage && (
|
{!isPlayPage && (
|
||||||
@@ -72,9 +76,13 @@ export function Layout() {
|
|||||||
onLogout={closeProfileAndLogout}
|
onLogout={closeProfileAndLogout}
|
||||||
showSidebar={showSidebar}
|
showSidebar={showSidebar}
|
||||||
hideSearch={hideSearch}
|
hideSearch={hideSearch}
|
||||||
|
pathname={location.pathname}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<LayoutWorkspace routeKey={location.pathname} />
|
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
|
||||||
|
{showMobileBottomNav && (
|
||||||
|
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||||
|
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import { mediaAPI } from '../api/library'
|
import { mediaAPI } from '../api/library'
|
||||||
import type { Media, PlayProfile, User } from '../types'
|
import type { Media, PlayProfile, User } from '../types'
|
||||||
|
import { resolveHeaderBack } from './layoutNavigation'
|
||||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||||
import type { useLayoutProfiles } from './useLayoutProfiles'
|
import type { useLayoutProfiles } from './useLayoutProfiles'
|
||||||
@@ -29,6 +30,7 @@ type LayoutHeaderProps = {
|
|||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
showSidebar?: boolean
|
showSidebar?: boolean
|
||||||
hideSearch?: boolean
|
hideSearch?: boolean
|
||||||
|
pathname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutHeader({
|
export function LayoutHeader({
|
||||||
@@ -41,11 +43,26 @@ export function LayoutHeader({
|
|||||||
onLogout,
|
onLogout,
|
||||||
showSidebar,
|
showSidebar,
|
||||||
hideSearch,
|
hideSearch,
|
||||||
|
pathname = '',
|
||||||
}: LayoutHeaderProps) {
|
}: LayoutHeaderProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const headerBack = resolveHeaderBack(pathname)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
|
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
|
||||||
{/* Left: Mobile Menu button or Brand Logo */}
|
{/* Left: back / menu / brand */}
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-2 shrink-0 sm:gap-3">
|
||||||
|
{headerBack ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(headerBack.to)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--app-border)] px-2.5 py-2 text-xs font-semibold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] lg:hidden"
|
||||||
|
title={headerBack.label}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
<span className="max-w-[4.5rem] truncate sm:max-w-none">{headerBack.label}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
onClick={onOpenMobileDrawer}
|
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"
|
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"
|
||||||
@@ -53,12 +70,7 @@ export function LayoutHeader({
|
|||||||
>
|
>
|
||||||
<Menu size={18} />
|
<Menu size={18} />
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link to="/" className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${showSidebar ? 'lg:hidden' : 'hidden sm:flex'}`}>
|
||||||
to="/"
|
|
||||||
className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${
|
|
||||||
showSidebar ? 'lg:hidden' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src="/brand/logo-192.png"
|
src="/brand/logo-192.png"
|
||||||
alt="MMTL"
|
alt="MMTL"
|
||||||
@@ -303,6 +315,8 @@ function LayoutHeaderActions({
|
|||||||
onUseDefaultProfile={onUseDefaultProfile}
|
onUseDefaultProfile={onUseDefaultProfile}
|
||||||
onSwitchProfile={onSwitchProfile}
|
onSwitchProfile={onSwitchProfile}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
|
themeMode={themeMode}
|
||||||
|
onThemeChange={onThemeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ type LayoutSidebarsProps = Omit<
|
|||||||
> & {
|
> & {
|
||||||
sidebar: LayoutSidebarState
|
sidebar: LayoutSidebarState
|
||||||
showSidebar: boolean
|
showSidebar: boolean
|
||||||
|
sidebarVariant?: 'media' | 'admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
type LayoutWorkspaceProps = {
|
type LayoutWorkspaceProps = {
|
||||||
routeKey: string
|
routeKey: string
|
||||||
|
showMobileBottomNav?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export { LayoutHeader } from './LayoutHeaderSections'
|
export { LayoutHeader } from './LayoutHeaderSections'
|
||||||
@@ -78,6 +80,7 @@ export function LayoutSidebars({
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
can,
|
can,
|
||||||
showSidebar,
|
showSidebar,
|
||||||
|
sidebarVariant = 'admin',
|
||||||
}: LayoutSidebarsProps) {
|
}: LayoutSidebarsProps) {
|
||||||
const content = (
|
const content = (
|
||||||
<LayoutSidebarContent
|
<LayoutSidebarContent
|
||||||
@@ -87,6 +90,7 @@ export function LayoutSidebars({
|
|||||||
can={can}
|
can={can}
|
||||||
onToggleSidebar={sidebar.toggleSidebar}
|
onToggleSidebar={sidebar.toggleSidebar}
|
||||||
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
|
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||||
|
variant={sidebarVariant}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,7 +120,11 @@ export function LayoutSidebars({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
|
||||||
|
const bottomPad = showMobileBottomNav
|
||||||
|
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||||
|
: ''
|
||||||
|
|
||||||
if (routeKey.startsWith('/play')) {
|
if (routeKey.startsWith('/play')) {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-1 h-full w-full overflow-hidden">
|
<main className="flex flex-1 h-full w-full overflow-hidden">
|
||||||
@@ -128,7 +136,7 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
|
<main className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { Link } from 'react-router-dom'
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ArrowLeft, Menu, X } from 'lucide-react'
|
import { ArrowLeft, Menu, X } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { LAYOUT_NAV_ITEMS, type LayoutNavItem } from './layoutNavigation'
|
|
||||||
|
import {
|
||||||
|
LAYOUT_NAV_ITEMS,
|
||||||
|
MEDIA_NAV_ITEMS,
|
||||||
|
type LayoutNavItem,
|
||||||
|
} from './layoutNavigation'
|
||||||
import { SidebarLink } from './LayoutSidebarNav'
|
import { SidebarLink } from './LayoutSidebarNav'
|
||||||
|
|
||||||
export type LayoutSidebarContentProps = {
|
export type LayoutSidebarContentProps = {
|
||||||
@@ -12,6 +17,7 @@ export type LayoutSidebarContentProps = {
|
|||||||
can: (key: string) => boolean
|
can: (key: string) => boolean
|
||||||
onToggleSidebar: () => void
|
onToggleSidebar: () => void
|
||||||
onCloseMobileDrawer: () => void
|
onCloseMobileDrawer: () => void
|
||||||
|
variant?: 'media' | 'admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutSidebarContent({
|
export function LayoutSidebarContent({
|
||||||
@@ -21,9 +27,11 @@ export function LayoutSidebarContent({
|
|||||||
can,
|
can,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onCloseMobileDrawer,
|
onCloseMobileDrawer,
|
||||||
|
variant = 'admin',
|
||||||
}: LayoutSidebarContentProps) {
|
}: LayoutSidebarContentProps) {
|
||||||
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
||||||
const visibleItems = visibleSidebarItems({ isAdmin, can })
|
const mediaItems = visibleSidebarItems({ isAdmin, can, items: MEDIA_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 flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||||
@@ -32,7 +40,19 @@ export function LayoutSidebarContent({
|
|||||||
onToggleSidebar={onToggleSidebar}
|
onToggleSidebar={onToggleSidebar}
|
||||||
onCloseMobileDrawer={onCloseMobileDrawer}
|
onCloseMobileDrawer={onCloseMobileDrawer}
|
||||||
/>
|
/>
|
||||||
<LayoutSidebarNav items={visibleItems} sidebarExpanded={sidebarExpanded} />
|
{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 />
|
||||||
|
)}
|
||||||
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -41,8 +61,9 @@ export function LayoutSidebarContent({
|
|||||||
function visibleSidebarItems({
|
function visibleSidebarItems({
|
||||||
isAdmin,
|
isAdmin,
|
||||||
can,
|
can,
|
||||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): LayoutNavItem[] {
|
items,
|
||||||
return LAYOUT_NAV_ITEMS.filter(
|
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'> & { items: LayoutNavItem[] }): LayoutNavItem[] {
|
||||||
|
return items.filter(
|
||||||
(item) => (!item.adminOnly || isAdmin) && (!item.permission || can(item.permission)),
|
(item) => (!item.adminOnly || isAdmin) && (!item.permission || can(item.permission)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -84,15 +105,37 @@ function LayoutSidebarHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LayoutSidebarSectionLabel({
|
||||||
|
sidebarExpanded,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
sidebarExpanded: boolean
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
if (!sidebarExpanded) return null
|
||||||
|
return (
|
||||||
|
<p className="px-4 pt-4 pb-1 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LayoutSidebarNav({
|
function LayoutSidebarNav({
|
||||||
items,
|
items,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
|
grow = false,
|
||||||
}: {
|
}: {
|
||||||
items: LayoutNavItem[]
|
items: LayoutNavItem[]
|
||||||
sidebarExpanded: boolean
|
sidebarExpanded: boolean
|
||||||
|
grow?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide">
|
<nav
|
||||||
|
className={clsx(
|
||||||
|
'overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide',
|
||||||
|
grow && 'flex-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const ItemIcon = item.icon
|
const ItemIcon = item.icon
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog }
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
import type { PlayProfile } from '../types'
|
import type { PlayProfile } from '../types'
|
||||||
|
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||||
|
import type { ThemeMode } from './useThemeMode'
|
||||||
|
|
||||||
type LayoutUser = {
|
type LayoutUser = {
|
||||||
username?: string
|
username?: string
|
||||||
@@ -22,6 +24,8 @@ type LayoutUserMenuProps = {
|
|||||||
onUseDefaultProfile: () => void
|
onUseDefaultProfile: () => void
|
||||||
onSwitchProfile: (profile: PlayProfile) => void
|
onSwitchProfile: (profile: PlayProfile) => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
|
themeMode?: ThemeMode
|
||||||
|
onThemeChange?: (mode: ThemeMode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutUserMenu({
|
export function LayoutUserMenu({
|
||||||
@@ -35,6 +39,8 @@ export function LayoutUserMenu({
|
|||||||
onUseDefaultProfile,
|
onUseDefaultProfile,
|
||||||
onSwitchProfile,
|
onSwitchProfile,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
themeMode,
|
||||||
|
onThemeChange,
|
||||||
}: LayoutUserMenuProps) {
|
}: LayoutUserMenuProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -103,6 +109,14 @@ export function LayoutUserMenu({
|
|||||||
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onClick={onClose} />
|
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onClick={onClose} />
|
||||||
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onClick={onClose} />
|
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onClick={onClose} />
|
||||||
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onClick={onClose} />
|
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onClick={onClose} />
|
||||||
|
{themeMode && onThemeChange ? (
|
||||||
|
<div className="px-3 py-2 sm:hidden">
|
||||||
|
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||||
|
主题
|
||||||
|
</p>
|
||||||
|
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Menu } from 'lucide-react'
|
||||||
|
|
||||||
|
import { MOBILE_BOTTOM_NAV_ITEMS } from './layoutNavigation'
|
||||||
|
|
||||||
|
type MobileBottomNavProps = {
|
||||||
|
onOpenMenu: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileBottomNav({ onOpenMenu }: MobileBottomNavProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--app-border)] bg-[var(--app-panel)]/95 backdrop-blur-md lg:hidden"
|
||||||
|
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||||
|
aria-label="主导航"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex h-14 max-w-lg items-stretch justify-around px-1">
|
||||||
|
{MOBILE_BOTTOM_NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const active = item.match
|
||||||
|
? item.match(location.pathname)
|
||||||
|
: item.end
|
||||||
|
? location.pathname === item.to
|
||||||
|
: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={clsx(
|
||||||
|
'flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-semibold transition-colors',
|
||||||
|
active ? 'text-brand-600' : 'text-[var(--app-muted)] hover:text-[var(--app-text)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={18} strokeWidth={active ? 2.4 : 2} />
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenMenu}
|
||||||
|
className="flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-semibold text-[var(--app-muted)] transition-colors hover:text-[var(--app-text)]"
|
||||||
|
aria-label="打开更多菜单"
|
||||||
|
>
|
||||||
|
<Menu size={18} />
|
||||||
|
<span>更多</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
type PageBackButtonProps = {
|
||||||
|
to?: string
|
||||||
|
label?: string
|
||||||
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageBackButton({
|
||||||
|
to,
|
||||||
|
label = '返回',
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
}: PageBackButtonProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
navigate(to)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 text-sm font-semibold text-[var(--app-subtle)] shadow-sm transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||||
|
compact && 'px-2.5 py-1.5 text-xs',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={compact ? 15 : 16} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { PageBackButton } from './PageBackButton'
|
||||||
|
|
||||||
|
type PageHeaderProps = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
backTo?: string
|
||||||
|
backLabel?: string
|
||||||
|
onBack?: () => void
|
||||||
|
actions?: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
backTo,
|
||||||
|
backLabel,
|
||||||
|
onBack,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
const showBack = Boolean(backTo || onBack)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={className ?? 'space-y-3'}>
|
||||||
|
{showBack && <PageBackButton to={backTo} label={backLabel} onClick={onBack} />}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">{title}</h1>
|
||||||
|
{description ? <p className="mt-0.5 text-xs text-ink-50 sm:text-sm">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
|
Clock,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Heart,
|
||||||
|
Home,
|
||||||
Library,
|
Library,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
ListMusic,
|
||||||
Settings,
|
Settings,
|
||||||
Tv,
|
Tv,
|
||||||
User,
|
User,
|
||||||
@@ -19,6 +23,20 @@ export type LayoutNavItem = {
|
|||||||
adminOnly?: boolean
|
adminOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HeaderBackTarget = {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MobileBottomNavItem = {
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
end?: boolean
|
||||||
|
match?: (pathname: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop admin sidebar and admin mobile drawer. */
|
||||||
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||||
{ to: '/profile', label: '个人资料', icon: User },
|
{ to: '/profile', label: '个人资料', icon: User },
|
||||||
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
||||||
@@ -26,8 +44,90 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
|||||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||||
// Emby 挂载(设置区):远程 Emby 媒体库挂载
|
|
||||||
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
||||||
// STRM 管理(设置区):网盘目录生成 strm 与元数据同步
|
|
||||||
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Media browsing links for the mobile drawer when browsing libraries. */
|
||||||
|
export const MEDIA_NAV_ITEMS: LayoutNavItem[] = [
|
||||||
|
{ to: '/', label: '首页', icon: Home, end: true },
|
||||||
|
{ to: '/libraries', label: '媒体库', icon: Library },
|
||||||
|
{ to: '/favourites', label: '我的收藏', icon: Heart },
|
||||||
|
{ to: '/playlists', label: '播放列表', icon: ListMusic },
|
||||||
|
{ to: '/history', label: '观看历史', icon: Clock },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
|
||||||
|
{ to: '/', label: '首页', icon: Home, end: true },
|
||||||
|
{
|
||||||
|
to: '/libraries',
|
||||||
|
label: '媒体库',
|
||||||
|
icon: Library,
|
||||||
|
match: (p) => p === '/libraries' || p.startsWith('/library'),
|
||||||
|
},
|
||||||
|
{ to: '/favourites', label: '收藏', icon: Heart },
|
||||||
|
{
|
||||||
|
to: '/playlists',
|
||||||
|
label: '列表',
|
||||||
|
icon: ListMusic,
|
||||||
|
match: (p) => p === '/playlists' || p.startsWith('/playlist'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
|
||||||
|
|
||||||
|
export function isRootMediaPath(pathname: string): boolean {
|
||||||
|
return ROOT_MEDIA_PATHS.has(pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
||||||
|
if (pathname.startsWith('/library/')) {
|
||||||
|
return { to: '/libraries', label: '媒体库' }
|
||||||
|
}
|
||||||
|
if (pathname.startsWith('/playlist/')) {
|
||||||
|
return { to: '/playlists', label: '播放列表' }
|
||||||
|
}
|
||||||
|
if (pathname.startsWith('/media/')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (pathname === '/scraper/queue') {
|
||||||
|
return { to: '/queue', label: '任务队列' }
|
||||||
|
}
|
||||||
|
if (pathname === '/strm/downloads' || pathname === '/strm/uploads') {
|
||||||
|
return { to: '/strm', label: 'STRM 管理' }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pathname === '/profile' ||
|
||||||
|
pathname === '/dlna' ||
|
||||||
|
pathname === '/play-profiles' ||
|
||||||
|
pathname === '/poster-wall'
|
||||||
|
) {
|
||||||
|
return { to: '/', label: '首页' }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pathname === '/settings' ||
|
||||||
|
pathname === '/admin' ||
|
||||||
|
pathname === '/files' ||
|
||||||
|
pathname === '/queue' ||
|
||||||
|
pathname === '/emby-mount' ||
|
||||||
|
pathname === '/strm'
|
||||||
|
) {
|
||||||
|
return { to: '/', label: '首页' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowMobileBottomNav(pathname: string): boolean {
|
||||||
|
if (pathname.startsWith('/play')) return false
|
||||||
|
return (
|
||||||
|
pathname === '/' ||
|
||||||
|
pathname === '/libraries' ||
|
||||||
|
pathname.startsWith('/library') ||
|
||||||
|
pathname.startsWith('/media') ||
|
||||||
|
pathname === '/favourites' ||
|
||||||
|
pathname === '/playlists' ||
|
||||||
|
pathname.startsWith('/playlist') ||
|
||||||
|
pathname === '/history' ||
|
||||||
|
pathname === '/poster-wall'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type SortOrder,
|
type SortOrder,
|
||||||
} from '../utils/mediaSort'
|
} from '../utils/mediaSort'
|
||||||
import { LibraryPageDialogs } from './LibraryPageDialogs'
|
import { LibraryPageDialogs } from './LibraryPageDialogs'
|
||||||
|
import { PageBackButton } from '../components/PageBackButton'
|
||||||
import { LibraryPageHeader } from './LibraryPageHeader'
|
import { LibraryPageHeader } from './LibraryPageHeader'
|
||||||
import { LibraryMediaSections } from './LibraryMediaSections'
|
import { LibraryMediaSections } from './LibraryMediaSections'
|
||||||
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
||||||
@@ -181,6 +182,12 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{!selectedSeries && (
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<PageBackButton to="/libraries" label="全部媒体库" compact />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!selectedSeries && (
|
{!selectedSeries && (
|
||||||
<LibraryPageHeader
|
<LibraryPageHeader
|
||||||
library={library}
|
library={library}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
|
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { PageBackButton } from '../components/PageBackButton'
|
||||||
|
|
||||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||||
@@ -63,8 +65,11 @@ export function MediaDetailLoading() {
|
|||||||
|
|
||||||
export function MediaDetailMissing() {
|
export function MediaDetailMissing() {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
|
<div className="space-y-4 py-12 text-center">
|
||||||
<p className="text-gray-500">媒体资源已被移除或不存在</p>
|
<PageBackButton label="返回上一页" className="mx-auto" />
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white py-16">
|
||||||
|
<p className="text-gray-500">媒体资源已被移除或不存在</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
|
|||||||
|
|
||||||
import { playbackAPI, type PlaylistDetail } from '../api/playback'
|
import { playbackAPI, type PlaylistDetail } from '../api/playback'
|
||||||
import { MediaCard } from '../components/MediaCard'
|
import { MediaCard } from '../components/MediaCard'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
|
||||||
export function PlaylistDetailPage() {
|
export function PlaylistDetailPage() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
@@ -24,10 +25,12 @@ export function PlaylistDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header>
|
<PageHeader
|
||||||
<h1 className="font-display text-3xl font-bold text-ink-600">{detail.playlist.name}</h1>
|
backTo="/playlists"
|
||||||
<p className="text-sm text-ink-50">{detail.items.length} 部影片</p>
|
backLabel="播放列表"
|
||||||
</header>
|
title={detail.playlist.name}
|
||||||
|
description={`${detail.items.length} 部影片`}
|
||||||
|
/>
|
||||||
|
|
||||||
{detail.items.length === 0 && (
|
{detail.items.length === 0 && (
|
||||||
<p className="text-ink-50">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
|
<p className="text-ink-50">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
export function ScraperQueuePage() {
|
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||||
const [snapshot, setSnapshot] = useState<ScrapeQueueSnapshot | null>(null)
|
const [snapshot, setSnapshot] = useState<ScrapeQueueSnapshot | null>(null)
|
||||||
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
|
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -251,7 +251,7 @@ export function ScraperQueuePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 1. Header */}
|
{!embedded && (
|
||||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
|
||||||
@@ -385,6 +385,7 @@ export function ScraperQueuePage() {
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 2. Status Cards */}
|
{/* 2. Status Cards */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ function getFileIcon(filename: string): ReactNode {
|
|||||||
return <File size={15} className="text-gray-400 shrink-0" />
|
return <File size={15} className="text-gray-400 shrink-0" />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
|
export function StrmQueuePanel({
|
||||||
|
kind,
|
||||||
|
embedded = false,
|
||||||
|
}: {
|
||||||
|
kind: 'download' | 'upload'
|
||||||
|
embedded?: boolean
|
||||||
|
}) {
|
||||||
const [snapshot, setSnapshot] = useState<StrmQueueSnapshot | null>(null)
|
const [snapshot, setSnapshot] = useState<StrmQueueSnapshot | null>(null)
|
||||||
const [filter, setFilter] = useState<'all' | StrmTaskStatus>('all')
|
const [filter, setFilter] = useState<'all' | StrmTaskStatus>('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -242,7 +248,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 1. Header */}
|
{!embedded && (
|
||||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
|
||||||
@@ -403,6 +409,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 2. Interactive Status Cards */}
|
{/* 2. Interactive Status Cards */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ export function TaskQueuePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 内容 */}
|
{/* 内容 */}
|
||||||
{type === 'scrape' && <ScraperQueuePage />}
|
{type === 'scrape' && <ScraperQueuePage embedded />}
|
||||||
{type === 'download' && <StrmQueuePanel kind="download" />}
|
{type === 'download' && <StrmQueuePanel kind="download" embedded />}
|
||||||
{type === 'upload' && <StrmQueuePanel kind="upload" />}
|
{type === 'upload' && <StrmQueuePanel kind="upload" embedded />}
|
||||||
{type === 'all' && (
|
{type === 'all' && (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<section>
|
<section>
|
||||||
@@ -125,21 +125,21 @@ export function TaskQueuePage() {
|
|||||||
<Sparkles size={18} className="text-brand-500" />
|
<Sparkles size={18} className="text-brand-500" />
|
||||||
刮削队列
|
刮削队列
|
||||||
</h2>
|
</h2>
|
||||||
<ScraperQueuePage />
|
<ScraperQueuePage embedded />
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
|
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
|
||||||
<Download size={18} className="text-brand-500" />
|
<Download size={18} className="text-brand-500" />
|
||||||
下载队列
|
下载队列
|
||||||
</h2>
|
</h2>
|
||||||
<StrmQueuePanel kind="download" />
|
<StrmQueuePanel kind="download" embedded />
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
|
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
|
||||||
<Upload size={18} className="text-brand-500" />
|
<Upload size={18} className="text-brand-500" />
|
||||||
上传队列
|
上传队列
|
||||||
</h2>
|
</h2>
|
||||||
<StrmQueuePanel kind="upload" />
|
<StrmQueuePanel kind="upload" embedded />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user