mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
UI 全面重设计: 明亮画廊风格 + 剧集合并显示
## 色彩体系重构 - tailwind.config.js: sand/ink 全新色阶 + shadow 阴影预设 - index.css: 全组件系统重写 (card/input/btn/badge/table) ## 核心页面重写 - LoginPage: 纯白卡片 + 品牌光晕 + Framer Motion 入场动画 - HomePage: Billboard 推荐位 + 剧集卡片联动媒体库 - Layout: 白色侧边栏 + 路由过渡动画 - MediaCard: 新增 linkTo prop, 悬停浮层优化 ## 剧集合并显示 - LibraryPage: 使用 groupSeries 合并剧集, 分季选集详情 - 首页剧集卡片自动链接到媒体库详情 ## 全量类名迁移 (43 文件) - text-slate-* -> text-ink-*/text-sand-* - text-primary-* -> text-brand-* - text-white -> text-ink-600 - bg/border-slate-* -> bg/border-sand-* - 500+ 深色硬编码类名清零
This commit is contained in:
@@ -23,10 +23,10 @@ export function APIConfigsPanel() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="h-5 w-5 text-primary-400" />
|
||||
<KeyRound className="h-5 w-5 text-brand-500" />
|
||||
<div>
|
||||
<p className="font-display text-lg font-semibold text-white">外部 API 配置</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="font-display text-lg font-semibold text-ink-600">外部 API 配置</p>
|
||||
<p className="text-xs text-ink-50">
|
||||
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
|
||||
· AES-GCM 加密存储
|
||||
</p>
|
||||
@@ -34,13 +34,13 @@ export function APIConfigsPanel() {
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p className="py-6 text-center text-sm text-slate-500">加载中…</p>
|
||||
<p className="py-6 text-center text-sm text-sand-500">加载中…</p>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="glass-panel overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-white/5 text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="border-b border-white/5 text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">服务</th>
|
||||
<th className="px-4 py-3">密钥</th>
|
||||
@@ -66,16 +66,16 @@ export function APIConfigsPanel() {
|
||||
className="border-t border-white/5 transition hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-white">{item.provider}</p>
|
||||
<p className="font-medium text-ink-600">{item.provider}</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-500">{item.description}</p>
|
||||
<p className="text-xs text-sand-500">{item.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{item.has_key ? (
|
||||
<span className="text-primary-400">{item.masked_key}</span>
|
||||
<span className="text-brand-500">{item.masked_key}</span>
|
||||
) : (
|
||||
<span className="text-slate-600">未配置</span>
|
||||
<span className="text-sand-400">未配置</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -84,7 +84,7 @@ export function APIConfigsPanel() {
|
||||
已配置
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-400/10 px-2 py-0.5 text-xs text-slate-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-sand-300/40 px-2 py-0.5 text-xs text-sand-500">
|
||||
未配置
|
||||
</span>
|
||||
)}
|
||||
@@ -93,7 +93,7 @@ export function APIConfigsPanel() {
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setEditing(item.provider)}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-white"
|
||||
className="rounded p-1.5 text-ink-50 transition hover:bg-white/5 hover:text-white"
|
||||
title="编辑"
|
||||
>
|
||||
<Save size={14} />
|
||||
@@ -104,7 +104,7 @@ export function APIConfigsPanel() {
|
||||
`已配置 ${item.has_key ? '✓' : '✗'} 密钥 (在线测试请用对应功能页面)`,
|
||||
)
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-slate-200"
|
||||
className="rounded p-1.5 text-ink-50 transition hover:bg-white/5 hover:text-ink-200"
|
||||
title="查看状态"
|
||||
>
|
||||
<Eye size={14} />
|
||||
@@ -117,7 +117,7 @@ export function APIConfigsPanel() {
|
||||
toast.success('已清除')
|
||||
refresh()
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-red-400/10 hover:text-red-400"
|
||||
className="rounded p-1.5 text-ink-50 transition hover:bg-red-400/10 hover:text-red-400"
|
||||
title="清除密钥"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -173,8 +173,8 @@ function EditingRow({
|
||||
<tr className="border-t border-white/5 bg-primary-400/5">
|
||||
<td colSpan={4} className="px-4 py-3">
|
||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||
<span className="text-sm font-medium text-white">{item.provider}</span>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
||||
<label className="flex-1 text-xs text-ink-50">
|
||||
API Key
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
@@ -184,7 +184,7 @@ function EditingRow({
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
<label className="flex-1 text-xs text-ink-50">
|
||||
Base URL
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
@@ -193,7 +193,7 @@ function EditingRow({
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<label className="flex items-center gap-2 text-xs text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
@@ -207,7 +207,7 @@ function EditingRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-slate-400/30 px-2 py-1.5 text-xs text-slate-400 hover:text-white"
|
||||
className="rounded border border-sand-400/30 px-2 py-1.5 text-xs text-ink-50 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { Github, Globe, Send } from 'lucide-react'
|
||||
|
||||
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 群组',
|
||||
},
|
||||
{ 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 }) {
|
||||
@@ -23,12 +11,12 @@ export function AppFooter({ className = '' }: { className?: string }) {
|
||||
<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-cream-900/20" />}
|
||||
{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-md px-2 py-1.5 text-xs text-cream-500 transition-colors hover:bg-cream-900/10 hover:text-cream-300"
|
||||
className="flex items-center gap-1.5 rounded-lg 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} />
|
||||
|
||||
+408
-172
@@ -1,40 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
Compass,
|
||||
Copy,
|
||||
Film,
|
||||
FolderTree,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
GalleryHorizontalEnd,
|
||||
Link2,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Rss,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Cloud,
|
||||
Trash2,
|
||||
UserCog,
|
||||
Wrench,
|
||||
Library as LibraryIcon,
|
||||
User as UserIcon,
|
||||
Activity, Bell, Cast, Clock, CloudDownload, Compass, Copy, Film,
|
||||
FolderTree, Globe, HardDrive, Heart, Home, GalleryHorizontalEnd,
|
||||
Link2, ListChecks, ListMusic, LogOut, MessageSquare, Rss, Search,
|
||||
Server, Settings, Sliders, Sparkles, Cloud, Trash2, UserCog, Wrench,
|
||||
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -42,180 +16,442 @@ import type { Library } from '../types'
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Automatically fetch libraries
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setIsSidebarOpen(false)
|
||||
} else {
|
||||
setIsSidebarOpen(true)
|
||||
}
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobileDrawerOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full">
|
||||
{/* ── Sidebar group — collapsed 64px, expands to 240px on hover ── */}
|
||||
<div className="group relative z-30 shrink-0">
|
||||
<aside
|
||||
className={clsx(
|
||||
'flex h-full flex-col border-r border-cream-900/15 bg-surface-600',
|
||||
'w-16 transition-all duration-300 ease-out',
|
||||
'group-hover:w-60',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-3 px-4 py-5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand-500/15">
|
||||
<Film className="h-4 w-4 text-brand-400" />
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'font-display text-lg font-bold tracking-tight text-cream-100',
|
||||
'overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200',
|
||||
'group-hover:opacity-100',
|
||||
)}
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full flex-col bg-white border-r border-gray-200/80">
|
||||
{/* Brand Logo & Brand Title */}
|
||||
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[#111827] to-[#1f2937] shadow-sm">
|
||||
<Film className="h-5 w-5 text-[#c9954a]" />
|
||||
</div>
|
||||
{(isSidebarOpen || isMobileDrawerOpen) && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[#111827]"
|
||||
>
|
||||
MediaStation
|
||||
</span>
|
||||
</Link>
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Toggle Collapse Button for Large Screen */}
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-colors hidden lg:block"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
||||
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
|
||||
|
||||
<SectionHeader label="浏览" />
|
||||
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
|
||||
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
|
||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
|
||||
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="海报墙" />
|
||||
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助手" />
|
||||
|
||||
<SectionHeader label="媒体库" />
|
||||
{libraries.length === 0 && (
|
||||
<div className="hidden px-3 py-1.5 text-xs text-cream-500/50 group-hover:block">
|
||||
暂无媒体库
|
||||
</div>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<SidebarLink
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
icon={<LibraryIcon size={18} />}
|
||||
label={lib.name}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SectionHeader label="自动化" />
|
||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
||||
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
|
||||
|
||||
<SectionHeader label="账号" />
|
||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
|
||||
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" />
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<SectionHeader label="管理" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
|
||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
|
||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Bottom: logout */}
|
||||
<div className="border-t border-cream-900/15 px-3 py-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-cream-400 transition hover:bg-cream-900/10 hover:text-cream-200"
|
||||
title={`退出登录 (${user?.username})`}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span className="overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
退出
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{/* Mobile Drawer Close Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(false)}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-colors block lg:hidden"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<main className="flex flex-1 flex-col overflow-y-auto">
|
||||
<div className="flex-1 px-6 py-8">
|
||||
<Outlet />
|
||||
{/* Navigation List */}
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-6 space-y-6 scrollbar-hide">
|
||||
{/* Navigation Group: Main */}
|
||||
<div>
|
||||
<SectionHeader label="影音中心" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/" icon={<Home size={18} />} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="影音海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 影视助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
<AppFooter className="py-4" />
|
||||
</main>
|
||||
|
||||
{/* Navigation Group: Libraries */}
|
||||
<div>
|
||||
<SectionHeader label="媒体片库" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
{libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && (
|
||||
<div className="px-4 py-2 text-xs text-gray-400 italic">暂无媒体片库</div>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<SidebarLink
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
icon={<LibraryIcon size={18} />}
|
||||
label={lib.name}
|
||||
collapsed={!isSidebarOpen && !isMobileDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Automation */}
|
||||
<div>
|
||||
<SectionHeader label="自动化运维" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 串流" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Account Profile */}
|
||||
<div>
|
||||
<SectionHeader label="个人资料" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Admin Dashboard */}
|
||||
{user?.role === 'admin' && (
|
||||
<div>
|
||||
<SectionHeader label="系统后台" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="后台主页" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行监控" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知配置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载客户端" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时机制" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储分析" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部挂载" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件管家" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="排重清理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 关联" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 智能助教" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统参数" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Logout Action */}
|
||||
<div className="border-t border-gray-100 p-4 bg-gray-50/50">
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login') }}
|
||||
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",
|
||||
(isSidebarOpen || isMobileDrawerOpen) ? "justify-start text-gray-500 hover:bg-red-50 hover:text-red-600" : "justify-center text-gray-400 hover:text-red-600"
|
||||
)}
|
||||
title={`安全登出 (${user?.username})`}
|
||||
>
|
||||
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||
{(isSidebarOpen || isMobileDrawerOpen) && <span>安全退出</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#f9fafb] text-gray-900 font-body select-none">
|
||||
|
||||
{/* 1. Desktop Persistent Sidebar */}
|
||||
<aside className={clsx(
|
||||
"hidden lg:flex flex-col h-full shrink-0 transition-all duration-300 ease-out",
|
||||
isSidebarOpen ? "w-64" : "w-20"
|
||||
)}>
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
{/* 2. Mobile & Tablet Sidebar Drawer (Overlay) */}
|
||||
<AnimatePresence>
|
||||
{isMobileDrawerOpen && (
|
||||
<div className="fixed inset-0 z-50 flex lg:hidden">
|
||||
{/* Backdrop sheet */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsMobileDrawerOpen(false)}
|
||||
className="fixed inset-0 bg-black/15 backdrop-blur-sm"
|
||||
/>
|
||||
{/* Drawer body container */}
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||
className="relative flex w-64 max-w-xs flex-col h-full z-10 shadow-xl"
|
||||
>
|
||||
{sidebarContent}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Main Workspace Container */}
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
|
||||
{/* Top Header Bar */}
|
||||
<header className="flex h-20 shrink-0 items-center justify-between px-6 md:px-8 bg-white/80 border-b border-gray-200/60 backdrop-blur-md z-30">
|
||||
|
||||
{/* Header Left Area: Hamburger and Search */}
|
||||
<div className="flex items-center gap-4 flex-1 max-w-lg">
|
||||
{/* Hamburger button shown on Mobile & Tablet */}
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(true)}
|
||||
className="rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 lg:hidden"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
{/* Premium Integrated Search Form */}
|
||||
<form onSubmit={handleSearchSubmit} className="relative w-full hidden sm:block">
|
||||
<span className={clsx(
|
||||
"absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200",
|
||||
searchFocused ? "text-brand-600" : "text-gray-400"
|
||||
)}>
|
||||
<Search size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
placeholder="搜索电影、电视剧、演员、种子站点..."
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
||||
/>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<span className="rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[9px] font-bold text-gray-400 uppercase tracking-wider">
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Header Right Area: Actions, Notifications, Profile Dropdown */}
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
{/* Quick search button shown ONLY on Mobile screens */}
|
||||
<Link
|
||||
to="/search"
|
||||
className="rounded-xl border border-gray-200 p-2.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 sm:hidden"
|
||||
>
|
||||
<Search size={18} />
|
||||
</Link>
|
||||
|
||||
{/* Quick Discover shortcut button */}
|
||||
<Link
|
||||
to="/discover"
|
||||
className="hidden md:flex items-center gap-2 rounded-xl border border-gray-200 px-4 py-2.5 text-xs font-bold text-gray-500 hover:bg-gray-50 hover:text-gray-900 transition-all"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>发现新片</span>
|
||||
</Link>
|
||||
|
||||
{/* Notification alert bubble */}
|
||||
<button className="relative rounded-xl border border-gray-200 p-2.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-all">
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-white animate-pulse" />
|
||||
</button>
|
||||
|
||||
{/* Horizontal divider lines */}
|
||||
<span className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* Elegant user dropdown list */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsProfileOpen(!isProfileOpen)}
|
||||
className="flex items-center gap-2.5 rounded-full border border-gray-200 p-1 pr-3 hover:bg-gray-50 transition-all"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] text-white font-display text-xs font-bold shadow-sm">
|
||||
{user?.username?.slice(0, 2).toUpperCase() || "US"}
|
||||
</div>
|
||||
<div className="text-left hidden md:block">
|
||||
<p className="text-xs font-bold text-gray-900 leading-none">{user?.username}</p>
|
||||
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">{user?.role}</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isProfileOpen && (
|
||||
<>
|
||||
{/* Backdrop cover for closing drawer click */}
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsProfileOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 mt-3 w-56 origin-top-right rounded-2xl border border-gray-200 bg-white p-2 shadow-xl z-20"
|
||||
>
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||
>
|
||||
<UserIcon size={16} />
|
||||
<span>个人基本信息</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/play-profiles"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||
>
|
||||
<UserCog size={16} />
|
||||
<span>观影 Profile 切换</span>
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>管理主控制台</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="my-1.5 border-t border-gray-100" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
logout();
|
||||
navigate('/login');
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>安全登出系统</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Dynamic Content Workspace Scroll Area ── */}
|
||||
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Absolute Footer Frame */}
|
||||
<AppFooter className="border-t border-gray-200/50 bg-white py-5 text-center text-xs text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Section divider — visible only when sidebar is expanded */
|
||||
function SectionHeader({ label }: { label: string }) {
|
||||
function SectionHeader({ label, visible }: { label: string; visible: boolean }) {
|
||||
if (!visible) return <div className="h-5" />;
|
||||
return (
|
||||
<div className="mt-4 mb-1 overflow-hidden whitespace-nowrap px-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.15em] text-cream-500/50">
|
||||
<div className="px-4 mb-2 mt-4">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Single nav link — icon always visible, label fades in on expand */
|
||||
function SidebarLink({
|
||||
to,
|
||||
icon,
|
||||
label,
|
||||
end,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
end?: boolean
|
||||
}) {
|
||||
interface SidebarLinkProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 relative group",
|
||||
isActive
|
||||
? 'bg-brand-500/10 text-brand-400'
|
||||
: 'text-cream-400 hover:bg-cream-900/10 hover:text-cream-200',
|
||||
? "bg-[#111827] text-white shadow-sm"
|
||||
: "text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex shrink-0 items-center justify-center w-5 h-5">{icon}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'overflow-hidden whitespace-nowrap truncate',
|
||||
'opacity-0 transition-opacity duration-200',
|
||||
'group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={clsx(
|
||||
"flex shrink-0 items-center justify-center w-5 h-5 transition-transform duration-300 group-hover:scale-110",
|
||||
isActive ? "text-[#c9954a]" : "text-gray-400 group-hover:text-gray-700"
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="absolute left-full ml-3 px-2.5 py-1.5 rounded-lg bg-gray-900 text-white text-xs font-semibold opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity shadow-lg z-50 whitespace-nowrap">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,103 @@
|
||||
import { useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Film, Play } from 'lucide-react'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
/**
|
||||
* Compact poster tile — used by library, search, favourites, poster wall, etc.
|
||||
*
|
||||
* 视觉密度调小:之前字体偏大、卡片整体偏宽,导致每页只能塞下 5 个左右;
|
||||
* 现在文字行 truncate + 字号缩到 12px,让 8 列网格不再"挤"。
|
||||
*/
|
||||
export function MediaCard({
|
||||
media,
|
||||
progress,
|
||||
export const MediaCard = ({
|
||||
media, progress, count, rating, linkTo,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className="group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
||||
<Film size={36} />
|
||||
</div>
|
||||
)}
|
||||
media: Media; progress?: number; count?: number; rating?: number; linkTo?: string
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="flex items-center gap-1 text-[11px] text-white/90">
|
||||
<Play size={11} />
|
||||
播放
|
||||
</span>
|
||||
return (
|
||||
<Link to={href} className="group block">
|
||||
<motion.div
|
||||
ref={ref}
|
||||
whileHover={{ scale: 1.04, y: -6 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
|
||||
className="relative overflow-hidden rounded-2xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gray-50">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-400 bg-gray-50">
|
||||
<Film size={28} className="stroke-[1.5]" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode count badge */}
|
||||
{count !== undefined && count > 1 && (
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-lg bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm border border-white/10">
|
||||
<Layers size={10} className="text-[#c9954a]" />
|
||||
<span>{count} 集</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Rating Badge */}
|
||||
{(rating || (media as any).rating) && (
|
||||
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-lg bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm border border-white/10">
|
||||
<Star size={10} fill="currentColor" />
|
||||
<span>{(rating || (media as any).rating).toFixed(1)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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>立即观影</span>
|
||||
</span>
|
||||
<p className="text-[10px] text-gray-300 font-semibold line-clamp-2 leading-relaxed">
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar overlay */}
|
||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-gray-200">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
|
||||
<div
|
||||
className="h-full bg-brand-500/80 transition-colors group-hover:bg-brand-400"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
{/* Media Metadata Info */}
|
||||
<div className="p-4 space-y-1 bg-white border-t border-gray-50">
|
||||
<p className="truncate text-sm font-bold text-gray-900 group-hover:text-brand-500 transition-colors duration-200">
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-500 font-bold uppercase tracking-wider">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
{media.video_codec && (
|
||||
<span className="px-1.5 py-0.5 rounded-md bg-gray-50 text-gray-600 border border-gray-100">
|
||||
{media.video_codec}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-2 py-2">
|
||||
<p className="truncate text-xs font-medium text-cream-200 group-hover:text-cream-100">
|
||||
{media.title}
|
||||
</p>
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user