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:
ShukeBta
2026-05-26 22:02:00 +08:00
parent 3a2db6bdd9
commit c094818594
45 changed files with 1938 additions and 1411 deletions
+18 -18
View File
@@ -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>
+5 -17
View File
@@ -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
View File
@@ -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>
)
}
}
+92 -60
View File
@@ -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>
)
}
}