mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
初始化
初始化项目
This commit is contained in:
@@ -1,41 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Github, Globe, Send } from 'lucide-react'
|
||||
|
||||
import { publicAPI } from '../api/public'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const LINKS = [
|
||||
{ href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' },
|
||||
{ href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' },
|
||||
{ href: 'https://t.me/MediaStationGo', icon: Send, label: 'TG 群组' },
|
||||
]
|
||||
|
||||
export function AppFooter({ className = '' }: { className?: string }) {
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const [hideForUsers, setHideForUsers] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
publicAPI.uiConfig().then((config) => setHideForUsers(config.hide_community_links_for_users)).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
if (hideForUsers && role !== 'admin') return null
|
||||
return (
|
||||
<footer className={`flex items-center justify-center gap-1 ${className}`}>
|
||||
{LINKS.map((link, i) => (
|
||||
<span key={link.href} className="flex items-center">
|
||||
{i > 0 && <span className="mx-2 h-3 w-px bg-sand-300" />}
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-xs text-sand-500 transition-colors hover:bg-sand-200 hover:text-ink-50"
|
||||
title={link.label}
|
||||
>
|
||||
<link.icon size={14} />
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -3,17 +3,36 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import {
|
||||
LayoutFrameFooter,
|
||||
LayoutHeader,
|
||||
LayoutSidebars,
|
||||
LayoutWorkspace,
|
||||
} from './LayoutSections'
|
||||
import { useLayoutSearch } from './useLayoutSearch'
|
||||
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') {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library') ||
|
||||
pathname.startsWith('/media') ||
|
||||
pathname.startsWith('/play') ||
|
||||
pathname === '/favourites' ||
|
||||
pathname === '/playlists' ||
|
||||
pathname.startsWith('/playlist') ||
|
||||
pathname === '/history' ||
|
||||
pathname === '/poster-wall'
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -22,11 +41,6 @@ export function Layout() {
|
||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||
const theme = useThemeMode()
|
||||
const search = useLayoutSearch({
|
||||
pathname: location.pathname,
|
||||
locationSearch: location.search,
|
||||
navigate,
|
||||
})
|
||||
const permissions = useLayoutPermissions(user)
|
||||
const sidebar = useLayoutSidebar(location.pathname)
|
||||
const profile = useLayoutProfiles({ activeProfileId, setActiveProfile, user })
|
||||
@@ -34,18 +48,19 @@ export function Layout() {
|
||||
const handleLogout = () => { logout(); navigate('/login') }
|
||||
const closeProfileAndLogout = () => { profile.setIsProfileOpen(false); handleLogout() }
|
||||
|
||||
const showSidebar = !isMediaView(location.pathname, location.search)
|
||||
const hideSearch = location.pathname.startsWith('/settings')
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
<LayoutSidebars
|
||||
sidebar={sidebar}
|
||||
isAdmin={permissions.isAdmin}
|
||||
username={user?.username}
|
||||
can={permissions.can}
|
||||
onLogout={handleLogout}
|
||||
showSidebar={showSidebar}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
<LayoutHeader
|
||||
search={search}
|
||||
permissions={permissions}
|
||||
theme={theme}
|
||||
onOpenMobileDrawer={() => sidebar.setIsMobileDrawerOpen(true)}
|
||||
@@ -53,9 +68,10 @@ export function Layout() {
|
||||
activeProfileId={activeProfileId}
|
||||
profile={profile}
|
||||
onLogout={closeProfileAndLogout}
|
||||
showSidebar={showSidebar}
|
||||
hideSearch={hideSearch}
|
||||
/>
|
||||
<LayoutWorkspace routeKey={location.pathname} />
|
||||
<LayoutFrameFooter />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
|
||||
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 type { PlayProfile, User } from '../types'
|
||||
import { LayoutSearchBox } from './LayoutSearchBox'
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media, PlayProfile, User } from '../types'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
import type { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import type { useLayoutSearch } from './useLayoutSearch'
|
||||
import type { ThemeMode, useThemeMode } from './useThemeMode'
|
||||
|
||||
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
|
||||
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
|
||||
type LayoutThemeState = ReturnType<typeof useThemeMode>
|
||||
|
||||
@@ -19,7 +20,6 @@ type LayoutPermissionState = {
|
||||
}
|
||||
|
||||
type LayoutHeaderProps = {
|
||||
search: LayoutSearchState
|
||||
permissions: LayoutPermissionState
|
||||
theme: LayoutThemeState
|
||||
onOpenMobileDrawer: () => void
|
||||
@@ -27,10 +27,11 @@ type LayoutHeaderProps = {
|
||||
activeProfileId: string | null
|
||||
profile: LayoutProfileState
|
||||
onLogout: () => void
|
||||
showSidebar?: boolean
|
||||
hideSearch?: boolean
|
||||
}
|
||||
|
||||
export function LayoutHeader({
|
||||
search,
|
||||
permissions,
|
||||
theme,
|
||||
onOpenMobileDrawer,
|
||||
@@ -38,10 +39,43 @@ export function LayoutHeader({
|
||||
activeProfileId,
|
||||
profile,
|
||||
onLogout,
|
||||
showSidebar,
|
||||
hideSearch,
|
||||
}: LayoutHeaderProps) {
|
||||
return (
|
||||
<header className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
<LayoutHeaderSearch search={search} onOpenMobileDrawer={onOpenMobileDrawer} />
|
||||
<header className="relative flex h-20 shrink-0 items-center justify-between gap-4 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
{/* Left: Mobile Menu button or Brand Logo */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<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"
|
||||
title="打开菜单"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${
|
||||
showSidebar ? 'lg:hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src="/brand/logo-192.png"
|
||||
alt="MMTL"
|
||||
className="h-9 w-9 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
<span className="hidden font-display text-lg font-black tracking-tight text-[var(--app-text)] sm:inline-block">
|
||||
MMTL
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Middle: Search Box */}
|
||||
<div className="flex-1 max-w-xl mx-auto">
|
||||
{!hideSearch && <LayoutHeaderSearch />}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions (Theme Toggle & User Menu) */}
|
||||
<LayoutHeaderActions
|
||||
permissions={permissions}
|
||||
themeMode={theme.mode}
|
||||
@@ -61,32 +95,163 @@ export function LayoutHeader({
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutHeaderSearch({
|
||||
search,
|
||||
onOpenMobileDrawer,
|
||||
}: {
|
||||
search: LayoutSearchState
|
||||
onOpenMobileDrawer: () => void
|
||||
}) {
|
||||
function LayoutHeaderSearch() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [results, setResults] = useState<Media[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await mediaAPI.search(trimmed, 8)
|
||||
setResults(res.items || [])
|
||||
setIsOpen(true)
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, 250)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = (mediaId: string) => {
|
||||
setIsOpen(false)
|
||||
setQuery('')
|
||||
navigate(`/media/${mediaId}`)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
|
||||
<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"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<LayoutSearchBox
|
||||
query={search.query}
|
||||
focused={search.focused}
|
||||
loading={search.loading}
|
||||
error={search.error}
|
||||
cards={search.cards}
|
||||
total={search.total}
|
||||
onQueryChange={search.setQuery}
|
||||
onFocusedChange={search.setFocused}
|
||||
onSubmit={search.submit}
|
||||
/>
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3.5 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (e.target.value.trim()) setIsOpen(true)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setIsOpen(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索电影、剧集、演员…"
|
||||
className="w-full h-10 pl-10 pr-9 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoaderCircle size={15} className="absolute right-3.5 text-brand-500 animate-spin" />
|
||||
) : query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Search Dropdown Results */}
|
||||
<AnimatePresence>
|
||||
{isOpen && query.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
|
||||
>
|
||||
{results.length === 0 && !loading ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
|
||||
未搜索到与 “{query}” 相关的媒体内容
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||
>
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url, item.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<Film size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-bold text-[var(--app-text)] group-hover:text-brand-500">
|
||||
{item.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--app-muted)] mt-0.5">
|
||||
{item.year > 0 && <span>{item.year}</span>}
|
||||
{item.rating > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[#c9954a]">
|
||||
<Star size={9} fill="currentColor" />
|
||||
{item.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{item.video_codec && (
|
||||
<span className="uppercase text-[9px] px-1 py-0.2 rounded border border-[var(--app-border)]">
|
||||
{item.video_codec}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -108,7 +273,6 @@ type LayoutHeaderActionsProps = {
|
||||
}
|
||||
|
||||
function LayoutHeaderActions({
|
||||
permissions,
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
user,
|
||||
@@ -124,17 +288,16 @@ function LayoutHeaderActions({
|
||||
}: LayoutHeaderActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
|
||||
<LayoutQuickActions permissions={permissions} />
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
|
||||
<LayoutProfileMenu
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isProfileOpen={isProfileOpen}
|
||||
isOpen={isProfileOpen}
|
||||
profiles={profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={activeProfile}
|
||||
onToggleProfile={onToggleProfile}
|
||||
onCloseProfile={onCloseProfile}
|
||||
onToggle={onToggleProfile}
|
||||
onClose={onCloseProfile}
|
||||
onUseDefaultProfile={onUseDefaultProfile}
|
||||
onSwitchProfile={onSwitchProfile}
|
||||
onLogout={onLogout}
|
||||
@@ -142,63 +305,3 @@ function LayoutHeaderActions({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) {
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
to="/search"
|
||||
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 sm:hidden"
|
||||
>
|
||||
<Search size={18} />
|
||||
</Link>
|
||||
{permissions.can('can_view_discover') && (
|
||||
<Link
|
||||
to="/discover"
|
||||
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>发现新片</span>
|
||||
</Link>
|
||||
)}
|
||||
{permissions.isAdmin && (
|
||||
<Link
|
||||
to="/notify-channels"
|
||||
title="通知配置"
|
||||
aria-label="打开通知配置"
|
||||
className="relative 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-all"
|
||||
>
|
||||
<MessageSquareText size={18} />
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutProfileMenu({
|
||||
user,
|
||||
isProfileOpen,
|
||||
profiles,
|
||||
activeProfileId,
|
||||
activeProfile,
|
||||
onToggleProfile,
|
||||
onCloseProfile,
|
||||
onUseDefaultProfile,
|
||||
onSwitchProfile,
|
||||
onLogout,
|
||||
}: Omit<LayoutHeaderActionsProps, 'permissions' | 'themeMode' | 'onThemeChange'>) {
|
||||
return (
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isOpen={isProfileOpen}
|
||||
profiles={profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={activeProfile}
|
||||
onToggle={onToggleProfile}
|
||||
onClose={onCloseProfile}
|
||||
onUseDefaultProfile={onUseDefaultProfile}
|
||||
onSwitchProfile={onSwitchProfile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Library as LibraryIcon, Search } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { seriesCardLink, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
type LayoutSearchBoxProps = {
|
||||
query: string
|
||||
focused: boolean
|
||||
loading: boolean
|
||||
error: string
|
||||
cards: SeriesCard[]
|
||||
total: number
|
||||
onQueryChange: (value: string) => void
|
||||
onFocusedChange: (focused: boolean) => void
|
||||
onSubmit: (event: FormEvent) => void
|
||||
}
|
||||
|
||||
export function LayoutSearchBox({
|
||||
query,
|
||||
focused,
|
||||
loading,
|
||||
error,
|
||||
cards,
|
||||
total,
|
||||
onQueryChange,
|
||||
onFocusedChange,
|
||||
onSubmit,
|
||||
}: LayoutSearchBoxProps) {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="relative hidden w-full sm:block">
|
||||
<span className={clsx(
|
||||
'absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200',
|
||||
focused ? 'text-brand-500' : 'text-[var(--app-muted)]',
|
||||
)}>
|
||||
<Search size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onMouseDown={() => onFocusedChange(true)}
|
||||
onClick={() => onFocusedChange(true)}
|
||||
onFocus={() => onFocusedChange(true)}
|
||||
onBlur={() => window.setTimeout(() => onFocusedChange(false), 120)}
|
||||
placeholder="搜索电影、电视剧、演员、种子站点..."
|
||||
className="w-full rounded-full border border-[var(--app-border)] bg-[var(--app-control-bg)] py-2.5 pl-11 pr-12 text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] outline-none transition-all duration-300 focus:border-brand-500 focus:bg-[var(--app-panel)] focus:ring-4 focus:ring-brand-100/40"
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{focused && trimmedQuery && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ duration: 0.14 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-2">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 px-3 py-4 text-sm text-[var(--app-muted)]">
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
搜索中...
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div className="px-3 py-4 text-sm text-red-500">{error}</div>
|
||||
)}
|
||||
{!loading && !error && cards.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-[var(--app-muted)]">没有找到匹配的本地媒体</div>
|
||||
)}
|
||||
{!loading && !error && cards.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{cards.map((card) => (
|
||||
<SearchResultItem
|
||||
key={card.key}
|
||||
card={card}
|
||||
onClick={() => onFocusedChange(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(trimmedQuery)}`}
|
||||
onClick={() => onFocusedChange(false)}
|
||||
className="flex items-center justify-between border-t border-[var(--app-border)] px-4 py-3 text-sm font-semibold text-brand-500 hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<span>查看全部搜索结果</span>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
{total > 0 ? `${total} 个条目` : 'Enter'}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResultItem({ card, onClick }: { card: SeriesCard; onClick: () => void }) {
|
||||
return (
|
||||
<Link
|
||||
to={seriesCardLink(card)}
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{card.rep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(card.rep.poster_url, card.rep.updated_at)}
|
||||
alt={card.rep.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<LibraryIcon size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--app-text)]">
|
||||
{card.rep.title || card.rep.original_name || '未命名媒体'}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--app-muted)]">
|
||||
{card.rep.year ? <span>{card.rep.year}</span> : null}
|
||||
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
|
||||
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { Outlet } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||
@@ -22,9 +21,10 @@ type LayoutMobileSidebarProps = {
|
||||
|
||||
type LayoutSidebarsProps = Omit<
|
||||
LayoutSidebarContentProps,
|
||||
'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer'
|
||||
'isSidebarOpen' | 'isMobileDrawerOpen' | 'onToggleSidebar' | 'onCloseMobileDrawer'
|
||||
> & {
|
||||
sidebar: LayoutSidebarState
|
||||
showSidebar: boolean
|
||||
}
|
||||
|
||||
type LayoutWorkspaceProps = {
|
||||
@@ -76,26 +76,33 @@ export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileS
|
||||
export function LayoutSidebars({
|
||||
sidebar,
|
||||
isAdmin,
|
||||
username,
|
||||
can,
|
||||
onLogout,
|
||||
showSidebar,
|
||||
}: LayoutSidebarsProps) {
|
||||
const content = (
|
||||
<LayoutSidebarContent
|
||||
isSidebarOpen={sidebar.isSidebarOpen}
|
||||
isMobileDrawerOpen={sidebar.isMobileDrawerOpen}
|
||||
openGroups={sidebar.openGroups}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
can={can}
|
||||
isRouteIn={sidebar.isRouteIn}
|
||||
onToggleGroup={sidebar.toggleGroup}
|
||||
onToggleSidebar={() => sidebar.setIsSidebarOpen((current) => !current)}
|
||||
onToggleSidebar={sidebar.toggleSidebar}
|
||||
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!showSidebar) {
|
||||
return (
|
||||
<>
|
||||
<LayoutMobileSidebar
|
||||
isOpen={sidebar.isMobileDrawerOpen}
|
||||
onClose={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||
>
|
||||
{content}
|
||||
</LayoutMobileSidebar>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayoutDesktopSidebar isSidebarOpen={sidebar.isSidebarOpen}>{content}</LayoutDesktopSidebar>
|
||||
@@ -131,10 +138,4 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutFrameFooter() {
|
||||
return (
|
||||
<AppFooter className="border-t border-[var(--app-border)] bg-[var(--app-panel)] py-5 text-center text-xs text-[var(--app-muted)]" />
|
||||
)
|
||||
}
|
||||
|
||||
export { LayoutSidebarContent }
|
||||
|
||||
@@ -1,44 +1,29 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { LogOut, Menu, X } from 'lucide-react'
|
||||
import { ArrowLeft, Menu, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { LAYOUT_NAV_GROUPS, NAV_GROUP_PATHS, type LayoutNavGroup, type LayoutNavItem } from './layoutNavigation'
|
||||
import { SidebarGroup, SidebarLink } from './LayoutSidebarNav'
|
||||
import { LAYOUT_NAV_ITEMS, type LayoutNavItem } from './layoutNavigation'
|
||||
import { SidebarLink } from './LayoutSidebarNav'
|
||||
|
||||
export type LayoutSidebarContentProps = {
|
||||
isSidebarOpen: boolean
|
||||
isMobileDrawerOpen: boolean
|
||||
openGroups: Record<string, boolean>
|
||||
isAdmin: boolean
|
||||
username?: string
|
||||
can: (key: string) => boolean
|
||||
isRouteIn: (paths: string[]) => boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
onToggleSidebar: () => void
|
||||
onCloseMobileDrawer: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
type VisibleLayoutNavGroup = {
|
||||
group: LayoutNavGroup
|
||||
items: LayoutNavItem[]
|
||||
}
|
||||
|
||||
export function LayoutSidebarContent({
|
||||
isSidebarOpen,
|
||||
isMobileDrawerOpen,
|
||||
openGroups,
|
||||
isAdmin,
|
||||
username,
|
||||
can,
|
||||
isRouteIn,
|
||||
onToggleGroup,
|
||||
onToggleSidebar,
|
||||
onCloseMobileDrawer,
|
||||
onLogout,
|
||||
}: LayoutSidebarContentProps) {
|
||||
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
||||
const visibleGroups = visibleSidebarGroups({ isAdmin, can })
|
||||
const visibleItems = visibleSidebarItems({ isAdmin, can })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
@@ -47,28 +32,19 @@ export function LayoutSidebarContent({
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onCloseMobileDrawer={onCloseMobileDrawer}
|
||||
/>
|
||||
<LayoutSidebarNav
|
||||
groups={visibleGroups}
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
openGroups={openGroups}
|
||||
isRouteIn={isRouteIn}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
<LayoutSidebarLogout sidebarExpanded={sidebarExpanded} username={username} onLogout={onLogout} />
|
||||
<LayoutSidebarNav items={visibleItems} sidebarExpanded={sidebarExpanded} />
|
||||
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function visibleSidebarGroups({
|
||||
function visibleSidebarItems({
|
||||
isAdmin,
|
||||
can,
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): VisibleLayoutNavGroup[] {
|
||||
const isItemVisible = (item: LayoutNavItem) =>
|
||||
(!item.adminOnly || isAdmin) && (!item.permission || can(item.permission))
|
||||
return LAYOUT_NAV_GROUPS
|
||||
.filter((group) => !group.adminOnly || isAdmin)
|
||||
.map((group) => ({ group, items: group.items.filter(isItemVisible) }))
|
||||
.filter(({ items }) => items.length > 0)
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): LayoutNavItem[] {
|
||||
return LAYOUT_NAV_ITEMS.filter(
|
||||
(item) => (!item.adminOnly || isAdmin) && (!item.permission || can(item.permission)),
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarHeader({
|
||||
@@ -84,8 +60,8 @@ function LayoutSidebarHeader({
|
||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/mediastationgo-logo.svg"
|
||||
alt="MediaStationGo"
|
||||
src="/brand/logo-192.png"
|
||||
alt="MMTL"
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
{sidebarExpanded && (
|
||||
@@ -94,7 +70,7 @@ function LayoutSidebarHeader({
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
|
||||
>
|
||||
MediaStationGo
|
||||
MMTL
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
@@ -109,102 +85,47 @@ function LayoutSidebarHeader({
|
||||
}
|
||||
|
||||
function LayoutSidebarNav({
|
||||
groups,
|
||||
sidebarExpanded,
|
||||
openGroups,
|
||||
isRouteIn,
|
||||
onToggleGroup,
|
||||
}: {
|
||||
groups: VisibleLayoutNavGroup[]
|
||||
sidebarExpanded: boolean
|
||||
openGroups: Record<string, boolean>
|
||||
isRouteIn: (paths: string[]) => boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
|
||||
{groups.map(({ group, items }) => (
|
||||
<LayoutSidebarNavGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
items={items}
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
open={openGroups[group.id] ?? group.id === 'media'}
|
||||
active={isRouteIn(NAV_GROUP_PATHS[group.id])}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarNavGroup({
|
||||
group,
|
||||
items,
|
||||
sidebarExpanded,
|
||||
open,
|
||||
active,
|
||||
onToggleGroup,
|
||||
}: {
|
||||
group: LayoutNavGroup
|
||||
items: LayoutNavItem[]
|
||||
sidebarExpanded: boolean
|
||||
open: boolean
|
||||
active: boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
}) {
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<SidebarGroup
|
||||
id={group.id}
|
||||
icon={<GroupIcon size={18} />}
|
||||
label={group.label}
|
||||
collapsed={!sidebarExpanded}
|
||||
open={open}
|
||||
active={active}
|
||||
onToggle={onToggleGroup}
|
||||
>
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide">
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
<SidebarLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
icon={<ItemIcon size={16} />}
|
||||
icon={<ItemIcon size={18} />}
|
||||
label={item.label}
|
||||
end={item.end}
|
||||
child
|
||||
collapsed={!sidebarExpanded}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SidebarGroup>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarLogout({
|
||||
sidebarExpanded,
|
||||
username,
|
||||
onLogout,
|
||||
}: {
|
||||
sidebarExpanded: boolean
|
||||
username?: string
|
||||
onLogout: () => void
|
||||
}) {
|
||||
function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) {
|
||||
return (
|
||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
<Link
|
||||
to="/"
|
||||
className={clsx(
|
||||
'flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout',
|
||||
'flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group',
|
||||
sidebarExpanded
|
||||
? 'justify-start text-[var(--app-muted)] hover:bg-[var(--app-danger-soft)] hover:text-red-500'
|
||||
: 'justify-center text-[var(--app-muted)] hover:text-red-500',
|
||||
? 'justify-start text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
|
||||
: 'justify-center text-[var(--app-muted)] hover:text-[var(--app-text)]',
|
||||
)}
|
||||
title={`安全登出 (${username ?? ''})`}
|
||||
title="返回系统首页"
|
||||
>
|
||||
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||
{sidebarExpanded && <span>安全退出</span>}
|
||||
</button>
|
||||
<ArrowLeft size={18} className="transition-transform group-hover:-translate-x-0.5" />
|
||||
{sidebarExpanded && <span>返回系统首页</span>}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -229,4 +150,4 @@ function SidebarIconButton({
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown, Loader2, LogOut, RotateCw, Settings, User as UserIcon, UserCog } from 'lucide-react'
|
||||
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import type { PlayProfile } from '../types'
|
||||
|
||||
type LayoutUser = {
|
||||
@@ -41,7 +39,6 @@ export function LayoutUserMenu({
|
||||
const location = useLocation()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const lastLocationRef = useRef(`${location.pathname}${location.search}`)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined
|
||||
@@ -71,22 +68,6 @@ export function LayoutUserMenu({
|
||||
if (isOpen) onClose()
|
||||
}, [isOpen, location.pathname, location.search, onClose])
|
||||
|
||||
const applySystemUpdate = async () => {
|
||||
if (updating) return
|
||||
setUpdating(true)
|
||||
try {
|
||||
const status = await adminAPI.systemUpdateApply()
|
||||
toast.success(status.message || '系统更新任务已启动')
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动系统更新失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative" data-testid="layout-user-menu">
|
||||
<button
|
||||
@@ -117,21 +98,11 @@ export function LayoutUserMenu({
|
||||
role="menu"
|
||||
className="absolute right-0 z-50 mt-3 w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
|
||||
>
|
||||
<UserMenuLink to="/profile" icon={<UserIcon size={16} />} label="个人基本信息" onClick={onClose} />
|
||||
{user?.role === 'admin' && (
|
||||
<UserMenuLink to="/admin" icon={<Settings size={16} />} label="管理主控制台" onClick={onClose} />
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applySystemUpdate}
|
||||
disabled={updating}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{updating ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
|
||||
<span>一键更新系统</span>
|
||||
</button>
|
||||
)}
|
||||
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onClick={onClose} />
|
||||
<UserMenuLink to="/favourites" icon={<Heart 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="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onClick={onClose} />
|
||||
<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)]">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export const MediaCard = ({
|
||||
media, progress, count, rating, linkTo, onClick, actions,
|
||||
media, progress, count, rating, linkTo, onClick, actions, compact,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
@@ -15,6 +15,7 @@ export const MediaCard = ({
|
||||
linkTo?: string
|
||||
onClick?: () => void
|
||||
actions?: ReactNode
|
||||
compact?: boolean
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
@@ -95,20 +96,26 @@ export const MediaCard = ({
|
||||
)}
|
||||
|
||||
{/* Premium Hover Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4">
|
||||
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
}`}>
|
||||
<motion.div
|
||||
initial={{ y: 15, opacity: 0 }}
|
||||
whileHover={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 text-xs font-bold text-white shadow-md shadow-brand-500/20">
|
||||
<Play size={10} fill="currentColor" className="text-white" />
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 font-bold text-white shadow-md shadow-brand-500/20 ${
|
||||
compact ? 'py-1.5 text-[11px]' : 'text-xs py-2'
|
||||
}`}>
|
||||
<Play size={compact ? 8 : 10} fill="currentColor" className="text-white" />
|
||||
<span>立即观影</span>
|
||||
</span>
|
||||
<p className="text-[10px] text-gray-200 font-semibold line-clamp-2 leading-relaxed">
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
{!compact && (
|
||||
<p className="text-[10px] text-gray-200 font-semibold line-clamp-2 leading-relaxed">
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -124,8 +131,12 @@ export const MediaCard = ({
|
||||
</div>
|
||||
|
||||
{/* Media Metadata Info */}
|
||||
<div className="space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] p-4">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500">
|
||||
<div className={`space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] ${
|
||||
compact ? 'p-2' : 'p-4'
|
||||
}`}>
|
||||
<p className={`truncate font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
}`}>
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
|
||||
@@ -43,7 +43,7 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { has
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('MediaStationGo route crashed', error, info)
|
||||
console.error('MMTL route crashed', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Activity,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
Compass,
|
||||
FileText,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
Image,
|
||||
KeySquare,
|
||||
FolderOpen,
|
||||
Library,
|
||||
ListMusic,
|
||||
Rss,
|
||||
Search,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
|
||||
export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system'
|
||||
|
||||
export type LayoutNavItem = {
|
||||
to: string
|
||||
label: string
|
||||
@@ -33,80 +16,10 @@ export type LayoutNavItem = {
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
export type LayoutNavGroup = {
|
||||
id: LayoutNavGroupID
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
activePaths: string[]
|
||||
adminOnly?: boolean
|
||||
items: LayoutNavItem[]
|
||||
}
|
||||
|
||||
export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
|
||||
{
|
||||
id: 'media',
|
||||
label: '媒体浏览',
|
||||
icon: Home,
|
||||
activePaths: ['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'],
|
||||
items: [
|
||||
{ to: '/', label: '系统首页', icon: Home, end: true },
|
||||
{ to: '/libraries', label: '媒体库', icon: Library },
|
||||
{ to: '/poster-wall', label: '海报墙', icon: Image },
|
||||
{ to: '/discover', label: '精彩发现', icon: Compass, permission: 'can_view_discover' },
|
||||
{ to: '/search', label: '智能搜索', icon: Search, permission: 'can_use_ai' },
|
||||
{ to: '/dlna', label: 'DLNA 投屏', icon: Cast, permission: 'can_cast' },
|
||||
{ to: '/ai', label: 'AI 助理', icon: Sparkles, permission: 'can_use_ai_assistant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'personal',
|
||||
label: '个人观影',
|
||||
icon: User,
|
||||
activePaths: ['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'],
|
||||
items: [
|
||||
{ to: '/favourites', label: '我的收藏', icon: Heart },
|
||||
{ to: '/playlists', label: '播放列表', icon: ListMusic },
|
||||
{ to: '/history', label: '观看历史', icon: Clock },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'downloads',
|
||||
label: '下载与订阅',
|
||||
icon: CloudDownload,
|
||||
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites', '/tasks'],
|
||||
items: [
|
||||
{ to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
|
||||
{ to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
|
||||
{ to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' },
|
||||
{ to: '/tasks', label: '实时任务', icon: Activity },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: '文件与自动化',
|
||||
icon: HardDrive,
|
||||
activePaths: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/scheduler', '/recycle', '/stats'],
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/storage', label: '存储与文件', icon: HardDrive },
|
||||
{ to: '/strm', label: 'STRM 管理', icon: FileText },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '系统配置',
|
||||
icon: Settings,
|
||||
activePaths: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'],
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/admin', label: '媒体与用户', icon: Settings },
|
||||
{ to: '/settings', label: '系统设置', icon: Sliders },
|
||||
{ to: '/license', label: '授权许可', icon: KeySquare },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const NAV_GROUP_PATHS: Record<LayoutNavGroupID, string[]> = LAYOUT_NAV_GROUPS.reduce(
|
||||
(paths, group) => ({ ...paths, [group.id]: group.activePaths }),
|
||||
{} as Record<LayoutNavGroupID, string[]>,
|
||||
)
|
||||
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/profile', label: '个人资料', icon: User },
|
||||
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
|
||||
|
||||
export function openManageLibrariesDialog(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
|
||||
const close = () => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve()
|
||||
}
|
||||
|
||||
root.render(<ManageLibrariesDialog onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<AdminLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { groupSeries } from '../utils/groupSeries'
|
||||
|
||||
type UseLayoutSearchOptions = {
|
||||
pathname: string
|
||||
locationSearch: string
|
||||
navigate: NavigateFunction
|
||||
}
|
||||
|
||||
export function useLayoutSearch({ pathname, locationSearch, navigate }: UseLayoutSearchOptions) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const searchSeq = useRef(0)
|
||||
const cards = useMemo(() => groupSeries(items).slice(0, 8), [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === '/search') {
|
||||
setQuery(new URLSearchParams(locationSearch).get('q') ?? '')
|
||||
}
|
||||
}, [pathname, locationSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedQuery = query.trim()
|
||||
const seq = ++searchSeq.current
|
||||
if (!focused || !trimmedQuery) {
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const timer = window.setTimeout(() => {
|
||||
mediaAPI
|
||||
.search(trimmedQuery, 24)
|
||||
.then((data) => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setItems(data.items ?? [])
|
||||
setTotal(data.total ?? (data.items ?? []).length)
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError('搜索失败,请稍后再试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === searchSeq.current) setLoading(false)
|
||||
})
|
||||
}, 220)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [focused, query])
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const trimmedQuery = query.trim()
|
||||
if (trimmedQuery) {
|
||||
navigate(`/search?q=${encodeURIComponent(trimmedQuery)}`)
|
||||
setFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cards,
|
||||
error,
|
||||
focused,
|
||||
loading,
|
||||
query,
|
||||
total,
|
||||
setFocused,
|
||||
setQuery,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { NAV_GROUP_PATHS } from './layoutNavigation'
|
||||
|
||||
export function useLayoutSidebar(pathname: string) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ media: true })
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -20,31 +17,15 @@ export function useLayoutSidebar(pathname: string) {
|
||||
setIsMobileDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
const isRouteIn = useCallback(
|
||||
(paths: string[]) =>
|
||||
paths.some((path) => (path === '/' ? pathname === '/' : pathname.startsWith(path))),
|
||||
[pathname],
|
||||
)
|
||||
|
||||
const toggleGroup = useCallback(
|
||||
(key: string) => setOpenGroups((current) => ({ ...current, [key]: !current[key] })),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const active = Object.entries(NAV_GROUP_PATHS).find(([, paths]) => isRouteIn(paths))?.[0]
|
||||
if (active) {
|
||||
setOpenGroups((current) => (current[active] ? current : { ...current, [active]: true }))
|
||||
}
|
||||
}, [isRouteIn])
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setIsSidebarOpen((current) => !current)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isMobileDrawerOpen,
|
||||
isRouteIn,
|
||||
isSidebarOpen,
|
||||
openGroups,
|
||||
setIsMobileDrawerOpen,
|
||||
setIsSidebarOpen,
|
||||
toggleGroup,
|
||||
toggleSidebar,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user