Compare commits

...

9 Commits

Author SHA1 Message Date
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
github-actions[bot] 0320d956ed chore: bump version to 0.0.68 [skip ci] 2026-09-02 02:23:41 +00:00
truewhile 95395c479a 优化前端返回导航与移动端操作体验
优化前端返回导航与移动端操作体验
2026-09-02 10:23:30 +08:00
Cursor Agent eba7904c99 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>
2026-09-02 02:19:25 +00:00
github-actions[bot] 1171fe9464 chore: bump version to 0.0.67 [skip ci] 2026-09-02 02:09:43 +00:00
truewhile 06b96102c8 Merge pull request #4 from truewhile/cursor/rewrite-readme-af8f
更新 README 与 Docker 镜像为 truewhile 仓库
2026-09-02 10:09:31 +08:00
Cursor Agent 077458df31 Update Docker images to ghcr.io/truewhile/mmtl
Replace legacy shukebta/shukbet image references across all compose
templates, system update defaults, and security docs.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:59:48 +00:00
24 changed files with 511 additions and 108 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
我们优先支持以下版本和部署方式的安全修复:
- 当前 `main` 分支。
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
- 最新发布镜像:`ghcr.io/truewhile/mmtl:latest`。
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
+1 -1
View File
@@ -1 +1 @@
0.0.66
0.0.69
+2 -6
View File
@@ -20,11 +20,7 @@
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
restart: unless-stopped
init: true
@@ -101,7 +97,7 @@ services:
MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200
MMTL_SEARCH_INDEX: mmtl_media
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
+1 -3
View File
@@ -13,9 +13,7 @@
services:
mmtl:
image: ghcr.io/shukebta/mmtl:latest
# Docker Hub 备用:
# image: shukbet/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
container_name: mmtl
restart: unless-stopped
+2 -6
View File
@@ -17,11 +17,7 @@
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
restart: unless-stopped
init: true
@@ -92,7 +88,7 @@ services:
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
MMTL_CACHE_CACHE_DIR: /cache
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MMTL_MEDIA_DIR: /media
+2 -6
View File
@@ -16,11 +16,7 @@
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
restart: unless-stopped
init: true
@@ -112,7 +108,7 @@ services:
MMTL_CACHE_CACHE_DIR: /cache
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
# 路径换算配置。
# 默认推荐在网页里使用容器路径 /media。
+1 -1
View File
@@ -20,7 +20,7 @@ const (
SystemUpdateCommandSettingKey = "system.update.command"
SystemUpdateComposeDirSettingKey = "system.update.compose_dir"
DefaultSystemUpdateImage = "ghcr.io/shukebta/mmtl:latest"
DefaultSystemUpdateImage = "ghcr.io/truewhile/mmtl:latest"
DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest"
)
+3 -3
View File
@@ -10,7 +10,7 @@ import (
func TestRenderSystemUpdateCommand(t *testing.T) {
status := SystemUpdateStatus{
Image: "ghcr.io/shukebta/mmtl:latest",
Image: "ghcr.io/truewhile/mmtl:latest",
WatchtowerImage: "containrrr/watchtower:latest",
ContainerID: "abc123def456",
ContainerName: "mmtl",
@@ -28,7 +28,7 @@ func TestRenderSystemUpdateCommand(t *testing.T) {
for _, want := range []string{
"containrrr/watchtower:latest",
"mmtl",
"ghcr.io/shukebta/mmtl:latest",
"ghcr.io/truewhile/mmtl:latest",
"abc123def456",
} {
if !strings.Contains(got, want) {
@@ -65,7 +65,7 @@ func TestComposeTargetInDirMatchesMMTLCompose(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(`
services:
mmtl:
image: ghcr.io/shukebta/mmtl:latest
image: ghcr.io/truewhile/mmtl:latest
`), 0o600); err != nil {
t.Fatal(err)
}
+11 -3
View File
@@ -7,6 +7,8 @@ import {
LayoutSidebars,
LayoutWorkspace,
} from './LayoutSections'
import { MobileBottomNav } from './MobileBottomNav'
import { isPlayerRoute, shouldShowMobileBottomNav } from './layoutNavigation'
import { useLayoutPermissions } from './useLayoutPermissions'
import { useLayoutProfiles } from './useLayoutProfiles'
import { useLayoutSidebar } from './useLayoutSidebar'
@@ -24,7 +26,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') ||
@@ -50,7 +52,8 @@ 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 (
<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}
can={permissions.can}
showSidebar={showSidebar}
sidebarVariant={showSidebar ? 'admin' : 'media'}
/>
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
{!isPlayPage && (
@@ -72,9 +76,13 @@ export function Layout() {
onLogout={closeProfileAndLogout}
showSidebar={showSidebar}
hideSearch={hideSearch}
pathname={location.pathname}
/>
)}
<LayoutWorkspace routeKey={location.pathname} />
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
{showMobileBottomNav && (
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
)}
</div>
</div>
)
+23 -9
View File
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
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 { mediaAPI } from '../api/library'
import type { Media, PlayProfile, User } from '../types'
import { resolveHeaderBack } from './layoutNavigation'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import type { useLayoutProfiles } from './useLayoutProfiles'
@@ -29,6 +30,7 @@ type LayoutHeaderProps = {
onLogout: () => void
showSidebar?: boolean
hideSearch?: boolean
pathname?: string
}
export function LayoutHeader({
@@ -41,11 +43,26 @@ export function LayoutHeader({
onLogout,
showSidebar,
hideSearch,
pathname = '',
}: LayoutHeaderProps) {
const navigate = useNavigate()
const headerBack = resolveHeaderBack(pathname)
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">
{/* Left: Mobile Menu button or Brand Logo */}
<div className="flex items-center gap-3 shrink-0">
{/* Left: back / menu / brand */}
<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
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"
@@ -53,12 +70,7 @@ export function LayoutHeader({
>
<Menu size={18} />
</button>
<Link
to="/"
className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${
showSidebar ? 'lg:hidden' : ''
}`}
>
<Link to="/" className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${showSidebar ? 'lg:hidden' : 'hidden sm:flex'}`}>
<img
src="/brand/logo-192.png"
alt="MMTL"
@@ -303,6 +315,8 @@ function LayoutHeaderActions({
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
themeMode={themeMode}
onThemeChange={onThemeChange}
/>
</div>
)
+12 -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'
@@ -25,10 +26,12 @@ type LayoutSidebarsProps = Omit<
> & {
sidebar: LayoutSidebarState
showSidebar: boolean
sidebarVariant?: 'media' | 'admin'
}
type LayoutWorkspaceProps = {
routeKey: string
showMobileBottomNav?: boolean
}
export { LayoutHeader } from './LayoutHeaderSections'
@@ -78,6 +81,7 @@ export function LayoutSidebars({
isAdmin,
can,
showSidebar,
sidebarVariant = 'admin',
}: LayoutSidebarsProps) {
const content = (
<LayoutSidebarContent
@@ -87,6 +91,7 @@ export function LayoutSidebars({
can={can}
onToggleSidebar={sidebar.toggleSidebar}
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
variant={sidebarVariant}
/>
)
@@ -116,8 +121,12 @@ export function LayoutSidebars({
)
}
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
if (routeKey.startsWith('/play')) {
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
const bottomPad = showMobileBottomNav
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
: ''
if (isPlayerRoute(routeKey)) {
return (
<main className="flex flex-1 h-full w-full overflow-hidden">
<RouteErrorBoundary>
@@ -128,7 +137,7 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
}
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">
<AnimatePresence mode="wait">
<motion.div
+49 -6
View File
@@ -2,7 +2,12 @@ import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowLeft, Menu, X } from 'lucide-react'
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'
export type LayoutSidebarContentProps = {
@@ -12,6 +17,7 @@ export type LayoutSidebarContentProps = {
can: (key: string) => boolean
onToggleSidebar: () => void
onCloseMobileDrawer: () => void
variant?: 'media' | 'admin'
}
export function LayoutSidebarContent({
@@ -21,9 +27,11 @@ export function LayoutSidebarContent({
can,
onToggleSidebar,
onCloseMobileDrawer,
variant = 'admin',
}: LayoutSidebarContentProps) {
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 (
<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}
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} />
</div>
)
@@ -41,8 +61,9 @@ export function LayoutSidebarContent({
function visibleSidebarItems({
isAdmin,
can,
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): LayoutNavItem[] {
return LAYOUT_NAV_ITEMS.filter(
items,
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'> & { items: LayoutNavItem[] }): LayoutNavItem[] {
return items.filter(
(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({
items,
sidebarExpanded,
grow = false,
}: {
items: LayoutNavItem[]
sidebarExpanded: boolean
grow?: boolean
}) {
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) => {
const ItemIcon = item.icon
return (
+14
View File
@@ -5,6 +5,8 @@ import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog }
import clsx from 'clsx'
import type { PlayProfile } from '../types'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import type { ThemeMode } from './useThemeMode'
type LayoutUser = {
username?: string
@@ -22,6 +24,8 @@ type LayoutUserMenuProps = {
onUseDefaultProfile: () => void
onSwitchProfile: (profile: PlayProfile) => void
onLogout: () => void
themeMode?: ThemeMode
onThemeChange?: (mode: ThemeMode) => void
}
export function LayoutUserMenu({
@@ -35,6 +39,8 @@ export function LayoutUserMenu({
onUseDefaultProfile,
onSwitchProfile,
onLogout,
themeMode,
onThemeChange,
}: LayoutUserMenuProps) {
const location = useLocation()
const rootRef = useRef<HTMLDivElement>(null)
@@ -103,6 +109,14 @@ export function LayoutUserMenu({
<UserMenuLink to="/playlists" icon={<ListMusic 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} />
{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="px-3 py-2">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
+55
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+111 -3
View File
@@ -1,9 +1,13 @@
import type { LucideIcon } from 'lucide-react'
import {
Clock,
FileOutput,
FolderOpen,
Heart,
Home,
Library,
ListChecks,
ListMusic,
Settings,
Tv,
User,
@@ -19,6 +23,20 @@ export type LayoutNavItem = {
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[] = [
{ to: '/profile', label: '个人资料', icon: User },
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
@@ -26,8 +44,98 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
// Emby 挂载(设置区):远程 Emby 媒体库挂载
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
// STRM 管理(设置区):网盘目录生成 strm 与元数据同步
{ 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'])
/** 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)
}
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: '播放列表' }
}
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 (isPlayerRoute(pathname)) return false
return (
pathname === '/' ||
pathname === '/libraries' ||
pathname.startsWith('/library') ||
pathname.startsWith('/media') ||
pathname === '/favourites' ||
pathname === '/playlists' ||
pathname.startsWith('/playlist') ||
pathname === '/history' ||
pathname === '/poster-wall'
)
}
+7
View File
@@ -13,6 +13,7 @@ import {
type SortOrder,
} from '../utils/mediaSort'
import { LibraryPageDialogs } from './LibraryPageDialogs'
import { PageBackButton } from '../components/PageBackButton'
import { LibraryPageHeader } from './LibraryPageHeader'
import { LibraryMediaSections } from './LibraryMediaSections'
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
@@ -181,6 +182,12 @@ export function LibraryPage() {
return (
<div className="space-y-6">
{!selectedSeries && (
<div className="hidden lg:block">
<PageBackButton to="/libraries" label="全部媒体库" compact />
</div>
)}
{!selectedSeries && (
<LibraryPageHeader
library={library}
+7 -2
View File
@@ -1,6 +1,8 @@
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
import { Link } from 'react-router-dom'
import { PageBackButton } from '../components/PageBackButton'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
@@ -63,8 +65,11 @@ export function MediaDetailLoading() {
export function MediaDetailMissing() {
return (
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
<p className="text-gray-500">媒体资源已被移除或不存在</p>
<div className="space-y-4 py-12 text-center">
<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>
)
}
+7 -4
View File
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
import { playbackAPI, type PlaylistDetail } from '../api/playback'
import { MediaCard } from '../components/MediaCard'
import { PageHeader } from '../components/PageHeader'
export function PlaylistDetailPage() {
const { id = '' } = useParams()
@@ -24,10 +25,12 @@ export function PlaylistDetailPage() {
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold text-ink-600">{detail.playlist.name}</h1>
<p className="text-sm text-ink-50">{detail.items.length} 部影片</p>
</header>
<PageHeader
backTo="/playlists"
backLabel="播放列表"
title={detail.playlist.name}
description={`${detail.items.length} 部影片`}
/>
{detail.items.length === 0 && (
<p className="text-ink-50">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
+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>
)
}
+3 -2
View File
@@ -60,7 +60,7 @@ const TYPE_LABELS: Record<string, string> = {
const PAGE_SIZE = 50
export function ScraperQueuePage() {
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
const [snapshot, setSnapshot] = useState<ScrapeQueueSnapshot | null>(null)
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
const [search, setSearch] = useState('')
@@ -251,7 +251,7 @@ export function ScraperQueuePage() {
return (
<div className="space-y-6">
{/* 1. Header */}
{!embedded && (
<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 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>
</div>
</header>
)}
{/* 2. Status Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
+9 -2
View File
@@ -55,7 +55,13 @@ function getFileIcon(filename: string): ReactNode {
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 [filter, setFilter] = useState<'all' | StrmTaskStatus>('all')
const [search, setSearch] = useState('')
@@ -242,7 +248,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
return (
<div className="space-y-6">
{/* 1. Header */}
{!embedded && (
<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 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>
</div>
</header>
)}
{/* 2. Interactive Status Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
+6 -6
View File
@@ -115,9 +115,9 @@ export function TaskQueuePage() {
</div>
{/* 内容 */}
{type === 'scrape' && <ScraperQueuePage />}
{type === 'download' && <StrmQueuePanel kind="download" />}
{type === 'upload' && <StrmQueuePanel kind="upload" />}
{type === 'scrape' && <ScraperQueuePage embedded />}
{type === 'download' && <StrmQueuePanel kind="download" embedded />}
{type === 'upload' && <StrmQueuePanel kind="upload" embedded />}
{type === 'all' && (
<div className="space-y-10">
<section>
@@ -125,21 +125,21 @@ export function TaskQueuePage() {
<Sparkles size={18} className="text-brand-500" />
刮削队列
</h2>
<ScraperQueuePage />
<ScraperQueuePage embedded />
</section>
<section>
<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" />
下载队列
</h2>
<StrmQueuePanel kind="download" />
<StrmQueuePanel kind="download" embedded />
</section>
<section>
<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" />
上传队列
</h2>
<StrmQueuePanel kind="upload" />
<StrmQueuePanel kind="upload" embedded />
</section>
</div>
)}