初始化

初始化项目
This commit is contained in:
truewhile
2026-08-23 22:12:32 +08:00
parent 0bcb1fec87
commit 71bf60c69c
631 changed files with 2121 additions and 76050 deletions
-41
View File
@@ -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>
)
}
+27 -11
View File
@@ -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>
)
+204 -101
View File
@@ -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}
/>
)
}
-143
View File
@@ -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>
)
}
+17 -16
View File
@@ -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 }
+29 -108
View File
@@ -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>
)
}
}
+7 -36
View File
@@ -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)]">
+20 -9
View File
@@ -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)]">
+1 -1
View File
@@ -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() {
+9 -96
View File
@@ -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>
)
}
-85
View File
@@ -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,
}
}
+5 -24
View File
@@ -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,
}
}
}