mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 15:26: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:
+197
-79
@@ -1,72 +1,92 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Play, Film, Layers } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { seriesAPI, type SeasonGroup } from '../api/series'
|
||||
import type { Library, Media } from '../types'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { imageURL } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
// LibraryPage renders one of two layouts depending on the library type:
|
||||
//
|
||||
// - movie / music / default → flat poster grid with pagination.
|
||||
// - tv / anime → grouped by season with an episode list.
|
||||
//
|
||||
// Admins also get scan + scrape buttons in the header.
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
|
||||
const [library, setLibrary] = useState<Library | null>(null)
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [seasons, setSeasons] = useState<SeasonGroup[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scraping, setScraping] = useState(false)
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
|
||||
const isSeries = library?.type === 'tv' || library?.type === 'anime'
|
||||
|
||||
// 折叠后的剧集卡片
|
||||
const seriesCards = useMemo(() => {
|
||||
if (!isSeries || items.length === 0) return []
|
||||
return groupSeries(items)
|
||||
}, [isSeries, items])
|
||||
|
||||
// 选中的剧集:所有集按季分组
|
||||
const selectedEpisodes = useMemo(() => {
|
||||
if (!selectedSeries || items.length === 0) return []
|
||||
const title = selectedSeries.rep.title.toLowerCase()
|
||||
const eps = items.filter(
|
||||
(m) => (m.title ?? '').toLowerCase() === title || m.tmdb_id === selectedSeries.rep.tmdb_id,
|
||||
)
|
||||
// 按季分组,按集排序
|
||||
const seasons = new Map<number, Media[]>()
|
||||
for (const ep of eps) {
|
||||
const s = ep.season_num || 1
|
||||
if (!seasons.has(s)) seasons.set(s, [])
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([season, episodes]) => ({ season, episodes }))
|
||||
}, [selectedSeries, items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
libraryAPI.list().then((all) => all.find((l) => l.id === id) ?? null),
|
||||
]).then(([lib]) => setLibrary(lib))
|
||||
libraryAPI.list().then((all) => {
|
||||
const lib = all.find((l) => l.id === id) ?? null
|
||||
setLibrary(lib)
|
||||
})
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !library) return
|
||||
setLoading(true)
|
||||
if (library.type === 'tv' || library.type === 'anime') {
|
||||
seriesAPI
|
||||
.seasons(id)
|
||||
.then((s) => setSeasons(s))
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
libraryAPI
|
||||
.listMedia(id)
|
||||
.then((d) => {
|
||||
setItems(d.items)
|
||||
setTotal(d.total)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
// tv/anime 拉取较多集数以支持前端分组
|
||||
const limit = library.type === 'tv' || library.type === 'anime' ? 1000 : 200
|
||||
libraryAPI
|
||||
.listMedia(id, 1, limit)
|
||||
.then((d) => {
|
||||
setItems(d.items)
|
||||
setTotal(d.total)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, library])
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const r = await libraryAPI.scan(id)
|
||||
toast.success(`扫描完成:新增 ${r.added} 项,探测 ${r.probed} 项`)
|
||||
// Force a reload by toggling library state.
|
||||
toast.success(`扫描完成:新增 ${r.added} 项`)
|
||||
setLibrary((l) => (l ? { ...l } : l))
|
||||
} catch {
|
||||
toast.error('扫描失败')
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
} finally { setScanning(false) }
|
||||
}
|
||||
|
||||
const handleScrape = async () => {
|
||||
@@ -74,79 +94,177 @@ export function LibraryPage() {
|
||||
try {
|
||||
await libraryAPI.scrape(id)
|
||||
toast.success('刮削已加入后台队列')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'刮削失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setScraping(false)
|
||||
}
|
||||
} catch { toast.error('刮削失败') }
|
||||
finally { setScraping(false) }
|
||||
}
|
||||
|
||||
const heading = useMemo(
|
||||
() => (library ? `${library.name}` : '媒体库'),
|
||||
[library],
|
||||
)
|
||||
const handleSeriesClick = (card: SeriesCard) => {
|
||||
setSelectedSeries(card)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 2 }} className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-brand-500" />
|
||||
<span className="text-sm text-sand-500">加载中…</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">
|
||||
{heading}
|
||||
{!isSeries && <span className="text-slate-500"> ({total})</span>}
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
{library?.name ?? '媒体库'}
|
||||
{!isSeries && <span className="text-sand-500"> ({total})</span>}
|
||||
</h1>
|
||||
{library && (
|
||||
<p className="text-sm text-slate-400">
|
||||
{library.type} · {library.path}
|
||||
</p>
|
||||
)}
|
||||
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
|
||||
</div>
|
||||
{role === 'admin' && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleScan} disabled={scanning} className="neon-button">
|
||||
{scanning ? '扫描中…' : '立即扫描'}
|
||||
</button>
|
||||
<button onClick={handleScrape} disabled={scraping} className="neon-button">
|
||||
{scraping ? '刮削中…' : '刮削元数据'}
|
||||
</button>
|
||||
<button onClick={handleScan} disabled={scanning} className="btn-outline">{scanning ? '扫描中…' : '立即扫描'}</button>
|
||||
<button onClick={handleScrape} disabled={scraping} className="btn-outline">{scraping ? '刮削中…' : '刮削元数据'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
|
||||
{!isSeries && (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{/* 非剧集:直接展示海报网格 */}
|
||||
{!isSeries && items.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSeries &&
|
||||
seasons.map((s) => (
|
||||
<section key={s.season} className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">
|
||||
{s.season > 0 ? `第 ${s.season} 季` : '未分季'} ({s.episodes.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{s.episodes.map((e) => (
|
||||
<div key={e.id} className="space-y-1">
|
||||
<MediaCard media={e} />
|
||||
{e.episode_num > 0 && (
|
||||
<p className="px-1 text-xs text-slate-400">第 {e.episode_num} 集</p>
|
||||
)}
|
||||
{!isSeries && items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Film className="h-12 w-12 text-sand-400 mb-4" />
|
||||
<p className="text-ink-50">该媒体库暂无内容,触发一次扫描后再来看看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 剧集模式:折叠卡片网格 */}
|
||||
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{seriesCards.map((s) => (
|
||||
<button key={s.rep.id} onClick={() => handleSeriesClick(s)} className="text-left">
|
||||
<MediaCard media={s.rep} count={s.count} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 剧集详情:季/集选择器 */}
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedSeries && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 返回按钮 + 标题 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setSelectedSeries(null)}
|
||||
className="btn-ghost gap-2"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
返回列表
|
||||
</button>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600 truncate">
|
||||
{selectedSeries.rep.title}
|
||||
</h2>
|
||||
<span className="text-sm text-sand-500">共 {selectedSeries.count} 集</span>
|
||||
</div>
|
||||
|
||||
{/* 海报 + 从第一集开始播放 */}
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
<div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card">
|
||||
{selectedSeries.rep.poster_url ? (
|
||||
<img src={imageURL(selectedSeries.rep.poster_url)} alt={selectedSeries.rep.title} className="w-full aspect-[2/3] object-cover" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center aspect-[2/3] text-sand-400"><Film size={40} /></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<p className="text-sm text-ink-50 leading-relaxed">
|
||||
{selectedSeries.rep.overview || '暂无简介'}
|
||||
</p>
|
||||
|
||||
{/* 从第一集开始 */}
|
||||
{(() => {
|
||||
const firstEps = selectedEpisodes.flatMap((s) => s.episodes).filter((e) => e.episode_num > 0).sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
const first = firstEps.length > 0 ? firstEps[0] : null
|
||||
return first ? (
|
||||
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 季 / 集列表 */}
|
||||
<div className="space-y-6">
|
||||
{selectedEpisodes.map(({ season, episodes }) => (
|
||||
<div key={season}>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
第 {season} 季 · {episodes.length} 集
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{episodes.map((ep) => (
|
||||
<Link
|
||||
key={ep.id}
|
||||
to={`/play/${ep.id}`}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.episode_num || '—'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-sand-400 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{isSeries && !loading && seasons.length === 0 && (
|
||||
<p className="text-slate-400">该库尚未发现任何剧集,触发一次扫描后再来看看。</p>
|
||||
{/* 剧集为空 */}
|
||||
{isSeries && seriesCards.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Film className="h-12 w-12 text-sand-400 mb-4" />
|
||||
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = bytes, i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user